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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user