From cccdf9f137b0009be8b1ac11fa9455944942eea7 Mon Sep 17 00:00:00 2001 From: joaquin Date: Mon, 18 May 2026 00:11:06 +0200 Subject: [PATCH] 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. --- backend/api/admin/providers.py | 91 +++++++++- backend/core/health_checker.py | 115 ++++++++++++ backend/core/pool.py | 40 ++++- backend/core/restream.py | 72 ++++++-- backend/database.py | 11 ++ backend/main.py | 5 +- backend/models/provider.py | 19 +- frontend/src/pages/Providers.tsx | 300 ++++++++++++++++++++++++------- 8 files changed, 567 insertions(+), 86 deletions(-) create mode 100644 backend/core/health_checker.py diff --git a/backend/api/admin/providers.py b/backend/api/admin/providers.py index e8b788f..f4a1128 100644 --- a/backend/api/admin/providers.py +++ b/backend/api/admin/providers.py @@ -8,8 +8,9 @@ from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel +from sqlalchemy.orm import selectinload from ...database import get_db, AsyncSessionLocal -from ...models.provider import ProviderAccount +from ...models.provider import ProviderAccount, ProviderUrl from ...models.channel import Category, Channel, ChannelProviderMap, ContentType from ...models.notification import NotificationDismissal from ...models.epg import EpgSource @@ -42,6 +43,19 @@ class ProviderUpdate(BaseModel): auto_sync_hours: int = 0 +class ProviderUrlOut(BaseModel): + id: int + url: str + priority: int + is_active: bool + status: str + response_ms: int | None = None + last_checked_at: datetime | None = None + + class Config: + from_attributes = True + + class ProviderOut(BaseModel): id: int name: str @@ -53,6 +67,7 @@ class ProviderOut(BaseModel): expiry_date: datetime | None = None auto_sync_hours: int last_sync_categories: str | None # raw JSON, parsed on frontend + urls: list[ProviderUrlOut] = [] class Config: from_attributes = True @@ -78,10 +93,15 @@ def _jellyfin_provider_subq(): async def list_providers(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): result = await db.execute( select(ProviderAccount) + .options(selectinload(ProviderAccount.extra_urls)) .where(ProviderAccount.id.notin_(_jellyfin_provider_subq())) .order_by(ProviderAccount.id) ) - return result.scalars().all() + providers = result.scalars().all() + # Map extra_urls to the urls field expected by ProviderOut + for p in providers: + p.urls = p.extra_urls + return providers async def _sync_epg_source(db: AsyncSession, provider: ProviderAccount) -> None: @@ -629,6 +649,73 @@ async def _run_sync( _sync_status[provider_id] = {"status": "error", "progress": str(e), "stats": stats} +# ── Provider URL management ──────────────────────────────────────────── + +class ProviderUrlCreate(BaseModel): + url: str + priority: int = 10 + is_active: bool = True + + +@router.get("/{provider_id}/urls", response_model=list[ProviderUrlOut]) +async def list_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(ProviderUrl) + .where(ProviderUrl.provider_account_id == provider_id) + .order_by(ProviderUrl.priority) + ) + return result.scalars().all() + + +@router.post("/{provider_id}/urls", response_model=ProviderUrlOut) +async def add_provider_url(provider_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + pu = ProviderUrl(provider_account_id=provider_id, **data.model_dump()) + db.add(pu) + await db.commit() + await db.refresh(pu) + return pu + + +@router.put("/{provider_id}/urls/{url_id}", response_model=ProviderUrlOut) +async def update_provider_url(provider_id: int, url_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id) + ) + pu = result.scalar_one_or_none() + if not pu: + raise HTTPException(404, "URL not found") + pu.url = data.url + pu.priority = data.priority + pu.is_active = data.is_active + await db.commit() + await db.refresh(pu) + return pu + + +@router.delete("/{provider_id}/urls/{url_id}") +async def delete_provider_url(provider_id: int, url_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + result = await db.execute( + select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id) + ) + pu = result.scalar_one_or_none() + if not pu: + raise HTTPException(404, "URL not found") + await db.delete(pu) + await db.commit() + return {"ok": True} + + +@router.post("/{provider_id}/urls/check-all") +async def check_all_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)): + from ...core.health_checker import check_provider_urls + result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(404, "Provider not found") + results = await check_provider_urls(provider_id) + return results + + async def _upsert_category(db: AsyncSession, cat_data: dict, ctype: ContentType, provider_id: int) -> Category: provider_cat_id = str(cat_data.get("category_id", "")) cat_name = cat_data.get("category_name", "Unknown") diff --git a/backend/core/health_checker.py b/backend/core/health_checker.py new file mode 100644 index 0000000..66ec71b --- /dev/null +++ b/backend/core/health_checker.py @@ -0,0 +1,115 @@ +"""Background health checker for provider URLs.""" +import asyncio +import logging +import time +from datetime import datetime, timezone + +import aiohttp +from sqlalchemy import select + +from ..database import AsyncSessionLocal +from ..models.provider import ProviderAccount, ProviderUrl + +logger = logging.getLogger(__name__) + +CHECK_INTERVAL = 300 # 5 minutes + + +async def _check_url(base_url: str, username: str, password: str) -> tuple[str, int | None]: + url = f"{base_url.rstrip('/')}/player_api.php" + params = {"username": username, "password": password, "action": "user_info"} + start = time.time() + try: + timeout = aiohttp.ClientTimeout(total=8) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, params=params, ssl=False) as resp: + elapsed_ms = int((time.time() - start) * 1000) + if resp.status == 200: + try: + data = await resp.json(content_type=None) + if "user_info" in data: + return "ok", elapsed_ms + except Exception: + pass + return "error", elapsed_ms + except asyncio.TimeoutError: + return "timeout", None + except Exception: + return "error", None + + +async def check_provider_urls(provider_account_id: int) -> list[dict]: + """Check all URLs for a specific provider. Returns list of result dicts.""" + async with AsyncSessionLocal() as db: + result = await db.execute( + select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password) + .join(ProviderAccount) + .where( + ProviderUrl.provider_account_id == provider_account_id, + ProviderUrl.is_active == True, # noqa: E712 + ) + ) + rows = result.all() + + if not rows: + return [] + + tasks = [_check_url(url, username, password) for _, url, username, password in rows] + results = await asyncio.gather(*tasks, return_exceptions=True) + + now = datetime.now(timezone.utc) + output = [] + async with AsyncSessionLocal() as db: + for (url_id, url, _, _), check_result in zip(rows, results): + if isinstance(check_result, Exception): + status, response_ms = "error", None + else: + status, response_ms = check_result + pu = await db.get(ProviderUrl, url_id) + if pu: + pu.status = status + pu.response_ms = response_ms + pu.last_checked_at = now + output.append({"id": url_id, "url": url, "status": status, "response_ms": response_ms}) + await db.commit() + + return output + + +async def health_check_loop() -> None: + """Runs forever, checking all active provider URLs every CHECK_INTERVAL seconds.""" + logger.info("Provider URL health checker started") + while True: + await asyncio.sleep(CHECK_INTERVAL) + try: + async with AsyncSessionLocal() as db: + result = await db.execute( + select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password) + .join(ProviderAccount) + .where(ProviderUrl.is_active == True) # noqa: E712 + ) + rows = result.all() + + if not rows: + continue + + tasks = [_check_url(url, username, password) for _, url, username, password in rows] + check_results = await asyncio.gather(*tasks, return_exceptions=True) + + now = datetime.now(timezone.utc) + async with AsyncSessionLocal() as db: + for (url_id, _, _, _), check_result in zip(rows, check_results): + if isinstance(check_result, Exception): + status, response_ms = "error", None + else: + status, response_ms = check_result + pu = await db.get(ProviderUrl, url_id) + if pu: + pu.status = status + pu.response_ms = response_ms + pu.last_checked_at = now + await db.commit() + + logger.info(f"Health check done: {len(rows)} URLs checked") + except Exception as e: + logger.error(f"Health check loop error: {e}") diff --git a/backend/core/pool.py b/backend/core/pool.py index 625d2aa..54909f1 100644 --- a/backend/core/pool.py +++ b/backend/core/pool.py @@ -112,9 +112,10 @@ class ProviderPool: provider_name = next( (pm.get("provider_name", "") for pm in provider_maps if pm["provider_account_id"] == account_id), "" ) + stream_urls = await self._build_stream_urls(account_id, stream_url) group = BroadcastGroup( channel_id=channel_id, - stream_url=stream_url, + stream_urls=stream_urls, provider_account_id=account_id, channel_name=channel_name, provider_name=provider_name, @@ -239,6 +240,43 @@ class ProviderPool: rest = [pm for pm in provider_maps if pm["provider_account_id"] not in pref_set] return preferred + rest + async def _build_stream_urls(self, account_id: int, primary_stream_url: str) -> list[str]: + """Build list of stream URLs for failover: one per active ProviderUrl, substituting base URL.""" + from urllib.parse import urlparse, urlunparse + from ..models.provider import ProviderUrl + from ..database import AsyncSessionLocal + from sqlalchemy import select + + parsed = urlparse(primary_stream_url) + + async with AsyncSessionLocal() as db: + result = await db.execute( + select(ProviderUrl) + .where( + ProviderUrl.provider_account_id == account_id, + ProviderUrl.is_active == True, # noqa: E712 + ) + .order_by(ProviderUrl.priority) + ) + provider_urls = result.scalars().all() + + if not provider_urls: + return [primary_stream_url] + + urls = [] + for pu in provider_urls: + alt = urlparse(pu.url.rstrip('/')) + reconstructed = urlunparse(( + alt.scheme or parsed.scheme, + alt.netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + )) + urls.append(reconstructed) + return urls + async def _find_free_slot(self, provider_maps: list[dict]) -> tuple[int | None, str | None]: for pm in provider_maps: account_id = pm["provider_account_id"] diff --git a/backend/core/restream.py b/backend/core/restream.py index 466733d..f387e01 100644 --- a/backend/core/restream.py +++ b/backend/core/restream.py @@ -51,6 +51,14 @@ _SENTINEL = object() # signals end-of-stream to client queues _FFMPEG = shutil.which("ffmpeg") or "ffmpeg" +def _url_netloc(url: str) -> str: + from urllib.parse import urlparse + try: + return urlparse(url).netloc + except Exception: + return url + + @dataclass class ClientHandle: client_id: str @@ -75,14 +83,24 @@ class BroadcastGroup: def __init__( self, channel_id: int, - stream_url: str, + stream_urls: list[str], provider_account_id: int, channel_name: str = "", provider_name: str = "", + # kept for backward compat in callers that still pass stream_url as kwarg + stream_url: str | None = None, ): self.channel_id = channel_id self.channel_name = channel_name - self.stream_url = stream_url + # Support old callers that pass a single stream_url + if stream_urls: + self._stream_urls = stream_urls + elif stream_url: + self._stream_urls = [stream_url] + else: + self._stream_urls = [] + self.stream_url = self._stream_urls[0] if self._stream_urls else "" + self._active_url: str = self.stream_url self.provider_account_id = provider_account_id self.provider_name = provider_name self.started_at = time.time() @@ -212,6 +230,9 @@ class BroadcastGroup: # ffmpeg process info "ffmpeg_pid": self._ffmpeg_pid, "ffmpeg_cpu_pct": round(self._ffmpeg_cpu, 2), + # multi-domain failover info + "active_url_domain": _url_netloc(self._active_url), + "url_count": len(self._stream_urls), } # ------------------------------------------------------------------ @@ -256,11 +277,16 @@ class BroadcastGroup: async def _pump_with_retry(self) -> None: consecutive = 0 base_wait = 0.5 + url_idx = 0 + n = len(self._stream_urls) or 1 try: while self._running: + current_url = self._stream_urls[url_idx % n] + self._active_url = current_url try: - await self._pump_once() + await self._pump_once(current_url) + url_idx = 0 # reset to primary after successful pump break except asyncio.CancelledError: raise @@ -269,18 +295,31 @@ class BroadcastGroup: break consecutive += 1 self._reconnect_count += 1 - wait = min(base_wait * (2 ** (consecutive - 1)), _BACKOFF_CAP) + next_idx = (url_idx + 1) % n + cycle_done = (consecutive % n == 0) + + if n > 1: + logger.warning( + f"[ch{self.channel_id}] Domain {_url_netloc(current_url)} failed " + f"(attempt {consecutive}), trying {_url_netloc(self._stream_urls[next_idx])}: {e}" + ) + else: + logger.warning( + f"[ch{self.channel_id}] Upstream error (attempt {consecutive}): {e}" + ) + + url_idx = next_idx self._status = "reconnecting" - logger.warning( - f"[ch{self.channel_id}] ffmpeg exited (attempt {consecutive}), " - f"restarting in {wait:.1f}s: {e}" - ) - # Drain queues so stale buffered chunks are not delivered - # after the fresh ffmpeg process starts. - async with self._lock: - for handle in self._clients.values(): - _drain_queue(handle.queue) - await asyncio.sleep(wait) + + if cycle_done: + # All domains tried — drain queues and back off before next cycle + wait = min(base_wait * (2 ** (consecutive // n - 1)), _BACKOFF_CAP) + logger.warning(f"[ch{self.channel_id}] All {n} domain(s) failed, retrying in {wait:.1f}s") + async with self._lock: + for handle in self._clients.values(): + _drain_queue(handle.queue) + await asyncio.sleep(wait) + # else: try next domain immediately (no sleep) finally: self._status = "stopped" self._running = False @@ -292,7 +331,7 @@ class BroadcastGroup: except asyncio.QueueFull: pass - async def _pump_once(self) -> None: + async def _pump_once(self, url: str | None = None) -> None: """ Spawn ffmpeg in copy (remux) mode and pump its stdout to all client queues until ffmpeg exits or self._running goes False. @@ -302,6 +341,7 @@ class BroadcastGroup: to clients. PTS/DTS values are normalised by ffmpeg across reconnects, eliminating A/V desync entirely. """ + stream_url = url if url is not None else self.stream_url cmd = [ _FFMPEG, "-hide_banner", "-loglevel", "warning", @@ -316,7 +356,7 @@ class BroadcastGroup: # stops sending bytes for STALL_TIMEOUT seconds ffmpeg closes # the connection and tries to reconnect (or exits if it gives up). "-timeout", str(int(STALL_TIMEOUT * 1_000_000)), - "-i", self.stream_url, + "-i", stream_url, "-c", "copy", # remux only — zero transcoding, bit-for-bit quality "-f", "mpegts", "pipe:1", diff --git a/backend/database.py b/backend/database.py index c7b2df1..03c7762 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,5 +1,6 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import text from .config import settings engine = create_async_engine( @@ -27,5 +28,15 @@ async def get_db(): async def init_db(): from .models import provider, channel, user, epg, custom_category, log, notification, jellyfin # noqa: ensure all models imported + from .models.provider import ProviderUrl # noqa: ensure ProviderUrl is registered async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + # Migrate: create provider_urls entries from existing base_url if not already done + await conn.execute(text(""" + INSERT INTO provider_urls (provider_account_id, url, priority, is_active, status) + SELECT id, base_url, 0, true, 'unknown' + FROM provider_accounts + WHERE id NOT IN (SELECT DISTINCT provider_account_id FROM provider_urls WHERE provider_account_id IS NOT NULL) + ON CONFLICT DO NOTHING + """)) + await conn.commit() diff --git a/backend/main.py b/backend/main.py index f3767f5..e5978cd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ from .config import settings from .database import init_db, AsyncSessionLocal from .core.pool import pool from .core.epg_manager import epg_background_task +from .core.health_checker import health_check_loop from .database import get_db logging.basicConfig( @@ -67,13 +68,15 @@ async def lifespan(app: FastAPI): await pool.init() epg_task = asyncio.create_task(epg_background_task(get_db)) auto_sync_task = asyncio.create_task(_auto_sync_loop()) + health_task = asyncio.create_task(health_check_loop()) logger.info("KiraStream ready") yield logger.info("KiraStream shutting down...") await pool.close() epg_task.cancel() auto_sync_task.cancel() - for t in (epg_task, auto_sync_task): + health_task.cancel() + for t in (epg_task, auto_sync_task, health_task): try: await t except asyncio.CancelledError: diff --git a/backend/models/provider.py b/backend/models/provider.py index dc5b1dc..4f46a65 100644 --- a/backend/models/provider.py +++ b/backend/models/provider.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, func +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, func from sqlalchemy.orm import relationship from ..database import Base @@ -22,3 +22,20 @@ class ProviderAccount(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) channel_maps = relationship("ChannelProviderMap", back_populates="provider_account", cascade="all, delete-orphan") + extra_urls = relationship("ProviderUrl", back_populates="provider_account", cascade="all, delete-orphan", order_by="ProviderUrl.priority") + + +class ProviderUrl(Base): + __tablename__ = "provider_urls" + + id = Column(Integer, primary_key=True, index=True) + provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False, index=True) + url = Column(String(500), nullable=False) + priority = Column(Integer, default=0, nullable=False) # 0=primary, higher=lower priority + is_active = Column(Boolean, default=True, nullable=False) + status = Column(String(20), default="unknown", nullable=False) # unknown|ok|error|timeout + response_ms = Column(Integer, nullable=True) + last_checked_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + provider_account = relationship("ProviderAccount", back_populates="extra_urls") diff --git a/frontend/src/pages/Providers.tsx b/frontend/src/pages/Providers.tsx index 68fef38..0c8dded 100644 --- a/frontend/src/pages/Providers.tsx +++ b/frontend/src/pages/Providers.tsx @@ -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>({}) + // URL management state + const [expandedUrls, setExpandedUrls] = useState>(new Set()) + const [addingUrl, setAddingUrl] = useState(null) + const [newUrlForm, setNewUrlForm] = useState({ url: '', priority: 10, is_active: true }) + const [checkingUrls, setCheckingUrls] = useState>(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 ( + + ● {url.response_ms != null ? `${url.response_ms}ms` : 'OK'} + + ) + } + if (url.status === 'error') { + return ● Error + } + if (url.status === 'timeout') { + return ● Timeout + } + return ○ Desconocido + } + 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() {
{providers.map(p => ( -
-
-
- {p.name} - {p.auto_sync_hours > 0 && ( - - Auto {p.auto_sync_hours}h - +
+
+
+
+ {p.name} + {p.auto_sync_hours > 0 && ( + + Auto {p.auto_sync_hours}h + + )} +
+
+ {p.base_url} • {p.username} • {p.max_connections} conex. + {(() => { const b = expiryBadge(p.expiry_date); return b ? {b.label} : null })()} +
+ {p.last_sync_at &&
Último sync: {new Date(p.last_sync_at).toLocaleString('es-ES')}
} + {testResult[p.id] !== undefined && ( +
+ {testResult[p.id] ? '✓ Conexión OK' : '✗ Sin conexión'} +
+ )} + {syncProgress[p.id] && ( +
+ {syncing.has(p.id) && } + {syncProgress[p.id]} + {syncStats[p.id] && syncing.has(p.id) && ( + + ({Object.entries(syncStats[p.id]).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(', ')}) + + )} +
)}
-
- {p.base_url} • {p.username} • {p.max_connections} conex. - {(() => { const b = expiryBadge(p.expiry_date); return b ? {b.label} : null })()} -
- {p.last_sync_at &&
Último sync: {new Date(p.last_sync_at).toLocaleString('es-ES')}
} - {testResult[p.id] !== undefined && ( -
- {testResult[p.id] ? '✓ Conexión OK' : '✗ Sin conexión'} -
- )} - {syncProgress[p.id] && ( -
- {syncing.has(p.id) && } - {syncProgress[p.id]} - {syncStats[p.id] && syncing.has(p.id) && ( - - ({Object.entries(syncStats[p.id]).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(', ')}) - - )} -
- )} -
-
- - {p.last_sync_at && ( +
- )} - - - + {p.last_sync_at && ( + + )} + + + + +
+ + {/* URL management section */} + {expandedUrls.has(p.id) && ( +
+
+ Dominios (failover) +
+ + +
+
+ + {(p.urls ?? []).length === 0 && addingUrl !== p.id && ( +
No hay URLs adicionales configuradas
+ )} + + {(p.urls ?? []).map(u => ( +
+
+
+ {u.url} + {u.priority === 0 && primaria} + {!u.is_active && inactiva} +
+
+
+ {urlStatusBadge(u)} +
+ +
+ ))} + + {addingUrl === p.id && ( +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ +
+ + +
+
+
+ )} +
+ )}
))} {providers.length === 0 && (