feat: multi-domain failover for provider accounts

Each ProviderAccount can now have multiple base URLs (provider_urls table).
On stream failure, BroadcastGroup cycles to the next domain immediately
with no wait; backs off only after all domains have been tried once.
Background health checker pings every domain every 5 min via player_api.php
and updates status/response_ms. Admin UI shows domain list with color-coded
status badges and a "Verificar todos" button per provider.
This commit is contained in:
joaquin
2026-05-18 00:11:06 +02:00
parent 3a450214ab
commit cccdf9f137
8 changed files with 567 additions and 86 deletions
+235 -65
View File
@@ -2,6 +2,16 @@ import { useEffect, useRef, useState } from 'react'
import { Plus, Trash2, RefreshCw, Wifi, Search, ChevronDown, ChevronRight, Bell, Edit2 } from 'lucide-react'
import api from '../lib/api'
interface ProviderUrl {
id: number
url: string
priority: number
is_active: boolean
status: 'unknown' | 'ok' | 'error' | 'timeout'
response_ms: number | null
last_checked_at: string | null
}
interface Provider {
id: number
name: string
@@ -13,6 +23,7 @@ interface Provider {
expiry_date: string | null
auto_sync_hours: number
last_sync_categories: string | null // JSON
urls: ProviderUrl[]
}
function expiryBadge(dateStr: string | null): { label: string; cls: string } | null {
@@ -89,6 +100,12 @@ export default function Providers() {
const [updatesSearch, setUpdatesSearch] = useState('')
const [updatesCount, setUpdatesCount] = useState<Record<number, number>>({})
// URL management state
const [expandedUrls, setExpandedUrls] = useState<Set<number>>(new Set())
const [addingUrl, setAddingUrl] = useState<number | null>(null)
const [newUrlForm, setNewUrlForm] = useState({ url: '', priority: 10, is_active: true })
const [checkingUrls, setCheckingUrls] = useState<Set<number>>(new Set())
async function load() {
const { data } = await api.get('/providers/')
setProviders(data)
@@ -319,6 +336,54 @@ export default function Providers() {
}
}
function toggleUrlSection(id: number) {
setExpandedUrls(prev => {
const n = new Set(prev)
n.has(id) ? n.delete(id) : n.add(id)
return n
})
}
async function addUrl(providerId: number) {
await api.post(`/providers/${providerId}/urls`, newUrlForm)
setAddingUrl(null)
setNewUrlForm({ url: '', priority: 10, is_active: true })
load()
}
async function deleteUrl(providerId: number, urlId: number) {
if (!confirm('¿Eliminar esta URL de failover?')) return
await api.delete(`/providers/${providerId}/urls/${urlId}`)
load()
}
async function checkAllUrls(providerId: number) {
setCheckingUrls(c => new Set(c).add(providerId))
try {
await api.post(`/providers/${providerId}/urls/check-all`)
load()
} finally {
setCheckingUrls(c => { const n = new Set(c); n.delete(providerId); return n })
}
}
function urlStatusBadge(url: ProviderUrl) {
if (url.status === 'ok') {
return (
<span className="text-green-400 text-xs font-mono">
● {url.response_ms != null ? `${url.response_ms}ms` : 'OK'}
</span>
)
}
if (url.status === 'error') {
return <span className="text-red-400 text-xs font-mono">● Error</span>
}
if (url.status === 'timeout') {
return <span className="text-yellow-400 text-xs font-mono">● Timeout</span>
}
return <span className="text-gray-500 text-xs font-mono">○ Desconocido</span>
}
const totalSelected = Object.values(selectedCats).reduce((a, s) => a + s.size, 0)
const totalAvailable = Object.values(availableCats).reduce((a, arr) => a + arr.length, 0)
@@ -628,77 +693,182 @@ export default function Providers() {
<div className="space-y-3">
{providers.map(p => (
<div key={p.id} className="bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{p.name}</span>
{p.auto_sync_hours > 0 && (
<span className="text-xs bg-dark-700 text-gray-400 px-1.5 py-0.5 rounded">
Auto {p.auto_sync_hours}h
</span>
<div key={p.id} className="bg-dark-800 border border-dark-700 rounded-xl overflow-hidden">
<div className="p-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{p.name}</span>
{p.auto_sync_hours > 0 && (
<span className="text-xs bg-dark-700 text-gray-400 px-1.5 py-0.5 rounded">
Auto {p.auto_sync_hours}h
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap mt-0.5">
<span className="text-xs text-gray-500 truncate">{p.base_url} • {p.username} • {p.max_connections} conex.</span>
{(() => { const b = expiryBadge(p.expiry_date); return b ? <span className={`text-xs px-1.5 py-0.5 rounded ${b.cls}`}>{b.label}</span> : null })()}
</div>
{p.last_sync_at && <div className="text-xs text-gray-600 mt-0.5">Último sync: {new Date(p.last_sync_at).toLocaleString('es-ES')}</div>}
{testResult[p.id] !== undefined && (
<div className={`text-xs mt-0.5 ${testResult[p.id] ? 'text-green-400' : 'text-red-400'}`}>
{testResult[p.id] ? '✓ Conexión OK' : '✗ Sin conexión'}
</div>
)}
{syncProgress[p.id] && (
<div className={`text-xs mt-1 ${syncProgress[p.id].startsWith('✓') ? 'text-green-400' : syncProgress[p.id].startsWith('Error') ? 'text-red-400' : 'text-blue-400'}`}>
{syncing.has(p.id) && <RefreshCw size={10} className="inline animate-spin mr-1" />}
{syncProgress[p.id]}
{syncStats[p.id] && syncing.has(p.id) && (
<span className="text-gray-500 ml-1">
({Object.entries(syncStats[p.id]).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(', ')})
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-wrap mt-0.5">
<span className="text-xs text-gray-500 truncate">{p.base_url} • {p.username} • {p.max_connections} conex.</span>
{(() => { const b = expiryBadge(p.expiry_date); return b ? <span className={`text-xs px-1.5 py-0.5 rounded ${b.cls}`}>{b.label}</span> : null })()}
</div>
{p.last_sync_at && <div className="text-xs text-gray-600 mt-0.5">Último sync: {new Date(p.last_sync_at).toLocaleString('es-ES')}</div>}
{testResult[p.id] !== undefined && (
<div className={`text-xs mt-0.5 ${testResult[p.id] ? 'text-green-400' : 'text-red-400'}`}>
{testResult[p.id] ? '✓ Conexión OK' : '✗ Sin conexión'}
</div>
)}
{syncProgress[p.id] && (
<div className={`text-xs mt-1 ${syncProgress[p.id].startsWith('✓') ? 'text-green-400' : syncProgress[p.id].startsWith('Error') ? 'text-red-400' : 'text-blue-400'}`}>
{syncing.has(p.id) && <RefreshCw size={10} className="inline animate-spin mr-1" />}
{syncProgress[p.id]}
{syncStats[p.id] && syncing.has(p.id) && (
<span className="text-gray-500 ml-1">
({Object.entries(syncStats[p.id]).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(', ')})
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => test(p.id)}
disabled={testing === p.id}
title="Probar conexión"
className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-blue-400 transition-colors"
>
{testing === p.id ? <RefreshCw size={15} className="animate-spin" /> : <Wifi size={15} />}
</button>
{p.last_sync_at && (
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => openUpdates(p)}
title="Ver novedades del proveedor"
className="relative p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors"
onClick={() => test(p.id)}
disabled={testing === p.id}
title="Probar conexión"
className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-blue-400 transition-colors"
>
<Bell size={15} />
{(updatesCount[p.id] ?? 0) > 0 && (
<span className="absolute -top-1 -right-1 bg-yellow-500 text-black text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{updatesCount[p.id] > 9 ? '9+' : updatesCount[p.id]}
</span>
)}
{testing === p.id ? <RefreshCw size={15} className="animate-spin" /> : <Wifi size={15} />}
</button>
)}
<button
onClick={() => openCategoryPicker(p)}
disabled={syncing.has(p.id)}
title="Sincronizar canales"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 text-xs text-gray-300 hover:text-white transition-colors disabled:opacity-50"
>
<RefreshCw size={13} className={syncing.has(p.id) ? 'animate-spin' : ''} />
{syncing.has(p.id) ? 'Sincronizando...' : 'Sync canales'}
</button>
<button onClick={() => openEdit(p)} title="Editar proveedor" className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors">
<Edit2 size={15} />
</button>
<button onClick={() => del(p.id)} className="p-2 rounded-lg bg-dark-700 hover:bg-red-900/40 text-gray-500 hover:text-red-400 transition-colors">
<Trash2 size={15} />
</button>
{p.last_sync_at && (
<button
onClick={() => openUpdates(p)}
title="Ver novedades del proveedor"
className="relative p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors"
>
<Bell size={15} />
{(updatesCount[p.id] ?? 0) > 0 && (
<span className="absolute -top-1 -right-1 bg-yellow-500 text-black text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{updatesCount[p.id] > 9 ? '9+' : updatesCount[p.id]}
</span>
)}
</button>
)}
<button
onClick={() => openCategoryPicker(p)}
disabled={syncing.has(p.id)}
title="Sincronizar canales"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 text-xs text-gray-300 hover:text-white transition-colors disabled:opacity-50"
>
<RefreshCw size={13} className={syncing.has(p.id) ? 'animate-spin' : ''} />
{syncing.has(p.id) ? 'Sincronizando...' : 'Sync canales'}
</button>
<button onClick={() => openEdit(p)} title="Editar proveedor" className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors">
<Edit2 size={15} />
</button>
<button
onClick={() => toggleUrlSection(p.id)}
title="Gestionar dominios de failover"
className={`p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors ${expandedUrls.has(p.id) ? 'text-primary-400' : 'text-gray-400 hover:text-primary-400'}`}
>
{expandedUrls.has(p.id) ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
<button onClick={() => del(p.id)} className="p-2 rounded-lg bg-dark-700 hover:bg-red-900/40 text-gray-500 hover:text-red-400 transition-colors">
<Trash2 size={15} />
</button>
</div>
</div>
{/* URL management section */}
{expandedUrls.has(p.id) && (
<div className="border-t border-dark-700 bg-dark-900/40 p-4 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Dominios (failover)</span>
<div className="flex gap-2">
<button
onClick={() => checkAllUrls(p.id)}
disabled={checkingUrls.has(p.id)}
className="flex items-center gap-1 text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-blue-400 transition-colors disabled:opacity-50"
>
<RefreshCw size={11} className={checkingUrls.has(p.id) ? 'animate-spin' : ''} />
Verificar todos
</button>
<button
onClick={() => { setAddingUrl(p.id); setNewUrlForm({ url: '', priority: 10, is_active: true }) }}
className="flex items-center gap-1 text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-green-400 transition-colors"
>
<Plus size={11} />
Añadir URL
</button>
</div>
</div>
{(p.urls ?? []).length === 0 && addingUrl !== p.id && (
<div className="text-xs text-gray-600 text-center py-2">No hay URLs adicionales configuradas</div>
)}
{(p.urls ?? []).map(u => (
<div key={u.id} className="flex items-center gap-3 bg-dark-800 rounded-lg px-3 py-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-gray-300 truncate">{u.url}</span>
{u.priority === 0 && <span className="text-[10px] bg-primary-900/40 text-primary-400 px-1 rounded">primaria</span>}
{!u.is_active && <span className="text-[10px] bg-dark-600 text-gray-500 px-1 rounded">inactiva</span>}
</div>
</div>
<div className="shrink-0">
{urlStatusBadge(u)}
</div>
<button
onClick={() => deleteUrl(p.id, u.id)}
className="p-1 rounded text-gray-600 hover:text-red-400 transition-colors"
>
<Trash2 size={12} />
</button>
</div>
))}
{addingUrl === p.id && (
<div className="bg-dark-800 rounded-lg p-3 space-y-2 border border-dark-600">
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<label className="block text-[10px] text-gray-500 mb-1">URL base (ej: http://mirror.com:8080)</label>
<input
value={newUrlForm.url}
onChange={e => setNewUrlForm(f => ({ ...f, url: e.target.value }))}
placeholder="http://..."
className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs focus:outline-none focus:border-primary-500"
/>
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-1">Prioridad (0=más alta)</label>
<input
type="number" min={0} max={999}
value={newUrlForm.priority}
onChange={e => setNewUrlForm(f => ({ ...f, priority: +e.target.value }))}
className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs"
/>
</div>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={newUrlForm.is_active}
onChange={e => setNewUrlForm(f => ({ ...f, is_active: e.target.checked }))}
/>
Activa
</label>
<div className="flex gap-2 ml-auto">
<button onClick={() => setAddingUrl(null)} className="text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400">Cancelar</button>
<button
onClick={() => addUrl(p.id)}
disabled={!newUrlForm.url}
className="text-xs px-2 py-1 rounded bg-primary-500 hover:bg-primary-600 disabled:opacity-40 font-medium"
>
Guardar
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
))}
{providers.length === 0 && (