Mejoras de estabilidad de stream IPTV y nuevo registro de fallos
- restream.py: eliminar -reconnect_streamed de ffmpeg para evitar rebobinados al reconectar (el proveedor re-enviaba desde keyframe anterior causando PTS backward). El loop de Python gestiona las reconexiones con conexión fresca. - restream.py: eliminar _drain_queue del loop de reconexión para que el buffer de la cola cubra el tiempo de reconexión sin pantalla negra en el player. - restream.py: añadir buffer_server_bytes/secs/capacity a stats() y método _log_stream_event() para persistir eventos de caída y recuperación en BD. - models/stream_event.py: nuevo modelo StreamEvent para registro de fallos. - database.py: registrar StreamEvent en init_db. - api/admin/logs.py: nuevos endpoints GET/DELETE /logs/events. - Dashboard.tsx: mostrar reserva de buffer del servidor (capacidad + estado). - Logs.tsx: añadir pestaña "Fallos de stream" con tabla de eventos persistidos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KiraStream Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3465
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "kirastream-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"axios": "^1.7.9",
|
||||
"recharts": "^2.13.3",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import Login from './pages/Login'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Providers from './pages/Providers'
|
||||
import Users from './pages/Users'
|
||||
import Catalogs from './pages/Catalogs'
|
||||
import EPG from './pages/EPG'
|
||||
import CustomCategories from './pages/CustomCategories'
|
||||
import Logs from './pages/Logs'
|
||||
import Settings from './pages/Settings'
|
||||
import Jellyfin from './pages/Jellyfin'
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter basename="/panel">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="providers" element={<Providers />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="catalogs" element={<Catalogs />} />
|
||||
<Route path="custom-categories" element={<CustomCategories />} />
|
||||
<Route path="epg" element={<EPG />} />
|
||||
<Route path="logs" element={<Logs />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="jellyfin" element={<Jellyfin />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { LayoutDashboard, Server, Users, List, Tv, LogOut, Wifi, Star, ScrollText, Settings, Bell, RefreshCw, X, ChevronRight, Film } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import api from '../lib/api'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/providers', icon: Server, label: 'Proveedores' },
|
||||
{ to: '/users', icon: Users, label: 'Usuarios' },
|
||||
{ to: '/catalogs', icon: List, label: 'Catálogos' },
|
||||
{ to: '/custom-categories', icon: Star, label: 'Cat. personalizadas' },
|
||||
{ to: '/jellyfin', icon: Film, label: 'Jellyfin' },
|
||||
{ to: '/epg', icon: Tv, label: 'EPG' },
|
||||
{ to: '/logs', icon: ScrollText, label: 'Registros' },
|
||||
{ to: '/settings', icon: Settings, label: 'Ajustes' },
|
||||
]
|
||||
|
||||
interface NotificationCategory { id: string; name: string; type: string }
|
||||
interface ProviderNotification { provider_id: number; provider_name: string; categories: NotificationCategory[] }
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
live: 'bg-blue-900/30 text-blue-300 border-blue-800',
|
||||
movie: 'bg-purple-900/30 text-purple-300 border-purple-800',
|
||||
series: 'bg-green-900/30 text-green-300 border-green-800',
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const [notifOpen, setNotifOpen] = useState(false)
|
||||
const [notifications, setNotifications] = useState<ProviderNotification[]>([])
|
||||
const [notifTotal, setNotifTotal] = useState(0)
|
||||
const [notifLoading, setNotifLoading] = useState(false)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token')
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
setNotifLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/providers/notifications/all')
|
||||
setNotifications(data.notifications || [])
|
||||
setNotifTotal(data.total || 0)
|
||||
} catch {
|
||||
setNotifications([])
|
||||
setNotifTotal(0)
|
||||
} finally {
|
||||
setNotifLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Poll notification count every 2 minutes
|
||||
const check = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/providers/notifications/all')
|
||||
setNotifTotal(data.total || 0)
|
||||
if (notifOpen) setNotifications(data.notifications || [])
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
check()
|
||||
const interval = setInterval(check, 120_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [notifOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (notifOpen) loadNotifications()
|
||||
}, [notifOpen])
|
||||
|
||||
// Close panel when clicking outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
setNotifOpen(false)
|
||||
}
|
||||
}
|
||||
if (notifOpen) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [notifOpen])
|
||||
|
||||
async function dismiss(providerId: number, catId: string, catType: string) {
|
||||
await api.post('/providers/notifications/dismiss', {
|
||||
provider_id: providerId,
|
||||
category_id: catId,
|
||||
category_type: catType,
|
||||
})
|
||||
setNotifications(prev =>
|
||||
prev
|
||||
.map(p =>
|
||||
p.provider_id === providerId
|
||||
? { ...p, categories: p.categories.filter(c => !(c.id === catId && c.type === catType)) }
|
||||
: p
|
||||
)
|
||||
.filter(p => p.categories.length > 0)
|
||||
)
|
||||
setNotifTotal(t => Math.max(0, t - 1))
|
||||
}
|
||||
|
||||
async function dismissAll(providerId: number) {
|
||||
await api.post(`/providers/notifications/dismiss-provider/${providerId}`)
|
||||
setNotifications(prev => prev.filter(p => p.provider_id !== providerId))
|
||||
const removed = notifications.find(p => p.provider_id === providerId)?.categories.length ?? 0
|
||||
setNotifTotal(t => Math.max(0, t - removed))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 bg-dark-800 border-r border-dark-700 flex flex-col shrink-0">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-dark-700">
|
||||
<Wifi className="text-primary-500" size={22} />
|
||||
<span className="font-bold text-lg tracking-tight flex-1">KiraStream</span>
|
||||
{/* Notification bell */}
|
||||
<div className="relative" ref={panelRef}>
|
||||
<button
|
||||
onClick={() => setNotifOpen(v => !v)}
|
||||
className={clsx(
|
||||
'relative p-1 rounded-lg transition-colors',
|
||||
notifOpen ? 'bg-dark-600 text-yellow-400' : 'text-gray-500 hover:text-yellow-400 hover:bg-dark-700'
|
||||
)}
|
||||
title="Notificaciones"
|
||||
>
|
||||
<Bell size={16} />
|
||||
{notifTotal > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-yellow-500 text-black text-[9px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{notifTotal > 99 ? '99+' : notifTotal}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{notifOpen && (
|
||||
<div className="absolute left-0 top-full mt-2 w-80 bg-dark-800 border border-dark-700 rounded-xl shadow-2xl z-50 flex flex-col max-h-[70vh]">
|
||||
<div className="p-3 border-b border-dark-700 flex items-center justify-between shrink-0">
|
||||
<span className="font-semibold text-sm">Nuevas categorías</span>
|
||||
<button onClick={() => setNotifOpen(false)} className="text-gray-500 hover:text-white">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notifLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<RefreshCw size={18} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-500 text-sm">
|
||||
Sin novedades pendientes ✓
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto flex-1 p-2 space-y-3">
|
||||
{notifications.map(prov => (
|
||||
<div key={prov.provider_id} className="bg-dark-900/50 rounded-lg border border-dark-700 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-dark-700/40">
|
||||
<span className="text-xs font-semibold text-gray-300 truncate">{prov.provider_name}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs text-gray-500">{prov.categories.length}</span>
|
||||
<button
|
||||
onClick={() => dismissAll(prov.provider_id)}
|
||||
className="text-xs text-gray-500 hover:text-red-400 ml-1 px-1.5 py-0.5 rounded hover:bg-dark-600"
|
||||
title="Descartar todas"
|
||||
>
|
||||
Ignorar todas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
|
||||
{prov.categories.map(cat => (
|
||||
<div key={`${cat.id}_${cat.type}`} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-dark-700 group">
|
||||
<span className="text-xs flex-1 truncate">{cat.name}</span>
|
||||
<span className={`text-[10px] px-1 py-0.5 rounded border shrink-0 ${TYPE_COLORS[cat.type]}`}>
|
||||
{TYPE_LABELS[cat.type]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => dismiss(prov.provider_id, cat.id, cat.type)}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-opacity shrink-0"
|
||||
title="Descartar"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-2 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setNotifOpen(false)
|
||||
navigate('/providers')
|
||||
}}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
|
||||
>
|
||||
Ir a Proveedores para sincronizar <ChevronRight size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{NAV.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-500/20 text-primary-400'
|
||||
: 'text-gray-400 hover:bg-dark-700 hover:text-gray-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={17} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-3 p-4 text-sm text-gray-500 hover:text-red-400 border-t border-dark-700 transition-colors"
|
||||
>
|
||||
<LogOut size={16} /> Cerrar sesión
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-auto bg-dark-900">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-dark-900 text-gray-100 font-sans antialiased;
|
||||
}
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { @apply bg-dark-800; }
|
||||
::-webkit-scrollbar-thumb { @apply bg-dark-600 rounded; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({ baseURL: '/api/admin' })
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
export async function login(username: string, password: string): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.append('username', username)
|
||||
form.append('password', password)
|
||||
const { data } = await axios.post('/api/admin/auth/token', form)
|
||||
return data.access_token
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface GroupedCategory { name: string; type: string; category_ids: number[]; provider_count: number }
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Películas', series: 'Series' }
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
live: 'bg-blue-900/40 text-blue-300',
|
||||
movie: 'bg-purple-900/40 text-purple-300',
|
||||
series: 'bg-green-900/40 text-green-300',
|
||||
}
|
||||
|
||||
export default function Catalogs() {
|
||||
const [categories, setCategories] = useState<GroupedCategory[]>([])
|
||||
const [filter, setFilter] = useState<string>('')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/catalogs/grouped').then(r => setCategories(r.data))
|
||||
}, [])
|
||||
|
||||
const filtered = categories.filter(c => {
|
||||
const matchType = !typeFilter || c.type === typeFilter
|
||||
const matchSearch = !filter || c.name.toLowerCase().includes(filter.toLowerCase())
|
||||
return matchType && matchSearch
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<h1 className="text-xl font-bold">Catálogos de Categorías</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
Categorías importadas de los proveedores. El badge indica cuántos proveedores cubren cada categoría
|
||||
(= streams simultáneos posibles). Asígnalas a usuarios desde la sección Usuarios.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
placeholder="Buscar categoría..."
|
||||
className="w-full bg-dark-800 border border-dark-700 rounded-lg pl-8 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="bg-dark-800 border border-dark-700 rounded-lg px-3 py-2 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="live">Directo</option>
|
||||
<option value="movie">Películas</option>
|
||||
<option value="series">Series</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{filtered.map(c => (
|
||||
<div key={c.name + c.type} className="bg-dark-800 border border-dark-700 rounded-xl p-3 flex items-center gap-2">
|
||||
<span className="text-sm truncate flex-1 min-w-0" title={c.name}>{c.name}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{c.provider_count > 0 && (
|
||||
<span
|
||||
title={`${c.provider_count} proveedor${c.provider_count !== 1 ? 'es' : ''} — ${c.provider_count} stream${c.provider_count !== 1 ? 's' : ''} simultáneo${c.provider_count !== 1 ? 's' : ''} posible${c.provider_count !== 1 ? 's' : ''}`}
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-medium ${c.provider_count > 1 ? 'bg-green-900/50 text-green-400' : 'bg-dark-600 text-gray-500'}`}
|
||||
>
|
||||
{c.provider_count}p
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${TYPE_COLORS[c.type]}`}>
|
||||
{TYPE_LABELS[c.type]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="col-span-full bg-dark-800 border border-dark-700 border-dashed rounded-xl p-8 text-center text-gray-500 text-sm">
|
||||
{categories.length === 0 ? 'Sincroniza un proveedor para ver las categorías' : 'No se encontraron categorías'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">{filtered.length} categorías mostradas de {categories.length} totales</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import api from '../lib/api'
|
||||
import { Plus, Trash2, Search, X, Tv, Film, Clapperboard, Star } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type CatType = 'live' | 'movie' | 'series'
|
||||
|
||||
interface Category {
|
||||
id: number
|
||||
name: string
|
||||
type: CatType
|
||||
is_visible: boolean
|
||||
item_count: number
|
||||
}
|
||||
|
||||
interface CategoryChannel {
|
||||
item_id: number
|
||||
channel_id: number
|
||||
name: string
|
||||
logo: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface SearchChannel {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<CatType, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
||||
const TYPE_ICONS: Record<CatType, typeof Tv> = { live: Tv, movie: Film, series: Clapperboard }
|
||||
const TYPE_COLORS: Record<CatType, string> = {
|
||||
live: 'bg-blue-500/20 text-blue-400',
|
||||
movie: 'bg-purple-500/20 text-purple-400',
|
||||
series: 'bg-orange-500/20 text-orange-400',
|
||||
}
|
||||
|
||||
export default function CustomCategories() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [selected, setSelected] = useState<Category | null>(null)
|
||||
const [channels, setChannels] = useState<CategoryChannel[]>([])
|
||||
const [searchQ, setSearchQ] = useState('')
|
||||
const [searchType, setSearchType] = useState<CatType | ''>('')
|
||||
const [searchResults, setSearchResults] = useState<SearchChannel[]>([])
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newType, setNewType] = useState<CatType>('live')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
const { data } = await api.get('/custom-categories')
|
||||
setCategories(data)
|
||||
}, [])
|
||||
|
||||
const loadChannels = useCallback(async (cat: Category) => {
|
||||
const { data } = await api.get(`/custom-categories/${cat.id}/channels`)
|
||||
setChannels(data)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadCategories() }, [loadCategories])
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) loadChannels(selected)
|
||||
}, [selected, loadChannels])
|
||||
|
||||
const selectCategory = (cat: Category) => {
|
||||
setSelected(cat)
|
||||
setShowSearch(false)
|
||||
setSearchQ('')
|
||||
setSearchResults([])
|
||||
}
|
||||
|
||||
const createCategory = async () => {
|
||||
if (!newName.trim()) return
|
||||
await api.post('/custom-categories', { name: newName.trim(), type: newType })
|
||||
setNewName('')
|
||||
setShowCreate(false)
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
const deleteCategory = async (cat: Category) => {
|
||||
if (!confirm(`¿Eliminar categoría "${cat.name}"?`)) return
|
||||
await api.delete(`/custom-categories/${cat.id}`)
|
||||
if (selected?.id === cat.id) setSelected(null)
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
const toggleVisible = async (cat: Category) => {
|
||||
await api.patch(`/custom-categories/${cat.id}`, { is_visible: !cat.is_visible })
|
||||
await loadCategories()
|
||||
if (selected?.id === cat.id) setSelected({ ...cat, is_visible: !cat.is_visible })
|
||||
}
|
||||
|
||||
const doSearch = useCallback(async (q: string, t: string) => {
|
||||
if (!q && !t) { setSearchResults([]); return }
|
||||
const { data } = await api.get('/custom-categories/channels/search', { params: { q, type: t } })
|
||||
setSearchResults(data)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => doSearch(searchQ, searchType), 300)
|
||||
return () => clearTimeout(id)
|
||||
}, [searchQ, searchType, doSearch])
|
||||
|
||||
const addChannel = async (ch: SearchChannel) => {
|
||||
if (!selected) return
|
||||
try {
|
||||
await api.post(`/custom-categories/${selected.id}/channels`, { channel_id: ch.id })
|
||||
await loadChannels(selected)
|
||||
await loadCategories()
|
||||
} catch (e: any) {
|
||||
if (e.response?.status !== 409) alert('Error al añadir canal')
|
||||
}
|
||||
}
|
||||
|
||||
const removeChannel = async (item: CategoryChannel) => {
|
||||
if (!selected) return
|
||||
await api.delete(`/custom-categories/${selected.id}/channels/${item.item_id}`)
|
||||
await loadChannels(selected)
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left: Category list */}
|
||||
<aside className="w-72 border-r border-dark-700 flex flex-col bg-dark-800">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star size={18} className="text-yellow-400" />
|
||||
<h2 className="font-semibold text-sm">Categorías personalizadas</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="p-3 border-b border-dark-700 bg-dark-900 space-y-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && createCategory()}
|
||||
placeholder="Nombre de la categoría"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={e => setNewType(e.target.value as CatType)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="live">Directo</option>
|
||||
<option value="movie">Película</option>
|
||||
<option value="series">Serie</option>
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={createCategory} className="flex-1 bg-primary-500 hover:bg-primary-600 text-white text-xs py-1.5 rounded-lg transition-colors">
|
||||
Crear
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="flex-1 bg-dark-700 hover:bg-dark-600 text-xs py-1.5 rounded-lg transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{categories.length === 0 && (
|
||||
<p className="text-xs text-gray-500 text-center py-8">No hay categorías personalizadas</p>
|
||||
)}
|
||||
{categories.map(cat => {
|
||||
const Icon = TYPE_ICONS[cat.type]
|
||||
return (
|
||||
<div
|
||||
key={cat.id}
|
||||
onClick={() => selectCategory(cat)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer group transition-colors',
|
||||
selected?.id === cat.id ? 'bg-primary-500/20' : 'hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<Icon size={14} className={TYPE_COLORS[cat.type].split(' ')[1]} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={clsx('text-sm font-medium truncate', selected?.id === cat.id ? 'text-primary-300' : 'text-gray-200')}>
|
||||
{cat.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{TYPE_LABELS[cat.type]} · {cat.item_count} canales</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => toggleVisible(cat)}
|
||||
className={clsx('text-xs px-1.5 py-0.5 rounded', cat.is_visible ? 'text-green-400' : 'text-gray-600')}
|
||||
title={cat.is_visible ? 'Visible (clic para ocultar)' : 'Oculto (clic para mostrar)'}
|
||||
>
|
||||
{cat.is_visible ? '●' : '○'}
|
||||
</button>
|
||||
<button onClick={() => deleteCategory(cat)} className="text-red-500 hover:text-red-400 p-0.5 rounded">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right: Channel list */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{!selected ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<Star size={40} className="mx-auto mb-3 text-gray-700" />
|
||||
<p>Selecciona una categoría para gestionar sus canales</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between bg-dark-800">
|
||||
<div>
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
{(() => { const Icon = TYPE_ICONS[selected.type]; return <Icon size={16} className={TYPE_COLORS[selected.type].split(' ')[1]} /> })()}
|
||||
{selected.name}
|
||||
<span className={clsx('text-xs px-2 py-0.5 rounded-full', TYPE_COLORS[selected.type])}>{TYPE_LABELS[selected.type]}</span>
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{channels.length} canales asignados</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<Plus size={14} /> Añadir canales
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search panel */}
|
||||
{showSearch && (
|
||||
<div className="border-b border-dark-700 bg-dark-900 p-4 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
autoFocus
|
||||
value={searchQ}
|
||||
onChange={e => setSearchQ(e.target.value)}
|
||||
placeholder="Buscar canal..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={searchType}
|
||||
onChange={e => setSearchType(e.target.value as CatType | '')}
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="live">Directo</option>
|
||||
<option value="movie">Película</option>
|
||||
<option value="series">Serie</option>
|
||||
</select>
|
||||
<button onClick={() => setShowSearch(false)} className="text-gray-500 hover:text-gray-300 p-2">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 max-h-48 overflow-y-auto">
|
||||
{searchResults.map(ch => {
|
||||
const already = channels.some(c => c.channel_id === ch.id)
|
||||
return (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => !already && addChannel(ch)}
|
||||
disabled={already}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors',
|
||||
already ? 'opacity-40 cursor-not-allowed bg-dark-700' : 'bg-dark-700 hover:bg-dark-600 cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{ch.logo ? (
|
||||
<img src={ch.logo} alt="" className="w-6 h-6 object-contain rounded" onError={e => (e.currentTarget.style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-6 h-6 bg-dark-600 rounded" />
|
||||
)}
|
||||
<span className="truncate flex-1">{ch.name}</span>
|
||||
{already && <span className="text-xs text-green-500">✓</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{searchResults.length === 0 && (searchQ || searchType) && (
|
||||
<p className="col-span-2 text-xs text-gray-500 text-center py-4">Sin resultados</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel list */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{channels.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<p>Esta categoría está vacía.</p>
|
||||
<p className="text-xs mt-1">Usa "Añadir canales" para añadir contenido.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{channels.map((ch, idx) => (
|
||||
<div key={ch.item_id} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-dark-800 hover:bg-dark-700 group transition-colors">
|
||||
<span className="text-xs text-gray-600 w-6 text-right">{idx + 1}</span>
|
||||
{ch.logo ? (
|
||||
<img src={ch.logo} alt="" className="w-8 h-8 object-contain rounded" onError={e => (e.currentTarget.style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-dark-600 rounded" />
|
||||
)}
|
||||
<span className="flex-1 text-sm">{ch.name}</span>
|
||||
<span className={clsx('text-xs px-1.5 py-0.5 rounded', TYPE_COLORS[ch.type as CatType] || 'bg-gray-700 text-gray-400')}>
|
||||
{TYPE_LABELS[ch.type as CatType] || ch.type}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeChannel(ch)}
|
||||
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-400 p-1 transition-all"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Activity, Server, Users, Wifi, Clock, X, Film, Tv, Clapperboard, RefreshCw, AlertTriangle, Cpu, HeartPulse, ShieldCheck } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface StreamClient {
|
||||
client_id: string
|
||||
username: string
|
||||
}
|
||||
|
||||
interface StreamInfo {
|
||||
video_codec?: string
|
||||
video_profile?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fps?: number
|
||||
audio_codec?: string
|
||||
audio_channels?: number
|
||||
audio_sample_rate?: number
|
||||
bitrate_bps?: number
|
||||
}
|
||||
|
||||
interface StreamSession {
|
||||
stream_type: 'live' | 'movie' | 'series'
|
||||
channel_id: number
|
||||
channel_name: string
|
||||
provider_account_id: number
|
||||
provider_name: string
|
||||
client_count: number
|
||||
clients: StreamClient[]
|
||||
started_at: number
|
||||
bytes_pumped: number
|
||||
bps_down: number
|
||||
bps_up: number
|
||||
running: boolean
|
||||
// stream health
|
||||
status?: 'connecting' | 'ok' | 'reconnecting' | 'stopped'
|
||||
reconnect_count?: number
|
||||
last_data_ago?: number
|
||||
stream_info?: StreamInfo
|
||||
// ffmpeg process info
|
||||
ffmpeg_pid?: number | null
|
||||
ffmpeg_cpu_pct?: number
|
||||
// A/V health (from health monitor)
|
||||
av_health?: 'ok' | 'warning' | 'error'
|
||||
av_desync_count?: number // cumulative ffmpeg AV-warning events
|
||||
corrections?: number // auto-corrections by health monitor
|
||||
buffer_pct?: number // queue fill % of slowest client (0=fast, 100=blocked)
|
||||
buffer_server_bytes?: number // bytes buffered for fastest client (reconnect coverage)
|
||||
buffer_server_secs?: number // estimated seconds that buffer covers
|
||||
buffer_capacity?: number // total queue capacity in bytes
|
||||
}
|
||||
|
||||
interface SlotInfo {
|
||||
id: number
|
||||
name: string
|
||||
max_connections: number
|
||||
status: 'busy' | 'free'
|
||||
channel_id: number | null
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'
|
||||
return (bytes / 1073741824).toFixed(2) + ' GB'
|
||||
}
|
||||
|
||||
function formatBps(bps: number) {
|
||||
if (bps <= 0) return '—'
|
||||
if (bps < 1024) return bps.toFixed(0) + ' B/s'
|
||||
if (bps < 1048576) return (bps / 1024).toFixed(0) + ' KB/s'
|
||||
return (bps / 1048576).toFixed(1) + ' MB/s'
|
||||
}
|
||||
|
||||
function formatDuration(startTs: number) {
|
||||
const secs = Math.floor(Date.now() / 1000 - startTs)
|
||||
const h = Math.floor(secs / 3600)
|
||||
const m = Math.floor((secs % 3600) / 60)
|
||||
const s = secs % 60
|
||||
return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`
|
||||
}
|
||||
|
||||
function formatResolution(info: StreamInfo) {
|
||||
if (!info.width || !info.height) return null
|
||||
const label =
|
||||
info.width >= 3840 ? '4K' :
|
||||
info.width >= 1920 ? 'FHD' :
|
||||
info.width >= 1280 ? 'HD' :
|
||||
info.width >= 720 ? 'SD' : 'LD'
|
||||
return `${info.width}×${info.height} (${label})`
|
||||
}
|
||||
|
||||
function StreamInfoBadge({ info }: { info: StreamInfo }) {
|
||||
if (!info || Object.keys(info).length === 0) {
|
||||
return <span className="text-gray-600 text-xs italic">Detectando...</span>
|
||||
}
|
||||
const res = formatResolution(info)
|
||||
const fps = info.fps ? `${info.fps} fps` : null
|
||||
const vcodec = info.video_codec ? info.video_codec.toUpperCase() : null
|
||||
const acodec = info.audio_codec ? info.audio_codec.toUpperCase() : null
|
||||
const br = info.bitrate_bps ? `${(info.bitrate_bps / 1_000_000).toFixed(1)} Mb/s` : null
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{res && <div className="text-gray-200 font-mono text-xs">{res}{fps ? ` · ${fps}` : ''}</div>}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{vcodec && (
|
||||
<span className="bg-blue-900/40 text-blue-300 text-[10px] px-1.5 py-0.5 rounded border border-blue-800">
|
||||
{vcodec}
|
||||
</span>
|
||||
)}
|
||||
{acodec && (
|
||||
<span className="bg-purple-900/40 text-purple-300 text-[10px] px-1.5 py-0.5 rounded border border-purple-800">
|
||||
{acodec}{info.audio_channels ? ` ${info.audio_channels}ch` : ''}
|
||||
</span>
|
||||
)}
|
||||
{br && <span className="text-gray-500 text-[10px]">{br}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthBadge({ status, reconnectCount }: {
|
||||
status?: string
|
||||
reconnectCount?: number
|
||||
}) {
|
||||
if (!status || status === 'ok') {
|
||||
if (reconnectCount && reconnectCount > 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 text-yellow-300 border border-yellow-800" title={`${reconnectCount} reconexión(es)`}>
|
||||
<RefreshCw size={9} /> {reconnectCount}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-green-500"><span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse inline-block" /> OK</span>
|
||||
}
|
||||
if (status === 'connecting') {
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-gray-400"><RefreshCw size={9} className="animate-spin" /> Conectando</span>
|
||||
}
|
||||
if (status === 'reconnecting') {
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-orange-400"><RefreshCw size={9} className="animate-spin" /> Reconectando {reconnectCount ? `(×${reconnectCount})` : ''}</span>
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-red-400"><AlertTriangle size={9} /> {status}</span>
|
||||
}
|
||||
|
||||
function AVHealthBadge({ health, desyncs, corrections }: {
|
||||
health?: 'ok' | 'warning' | 'error'
|
||||
desyncs?: number
|
||||
corrections?: number
|
||||
}) {
|
||||
const hasCorrections = corrections != null && corrections > 0
|
||||
const hasDesyncs = desyncs != null && desyncs > 0
|
||||
|
||||
const badge = health === 'error'
|
||||
? <span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 text-red-300 border border-red-800">
|
||||
<AlertTriangle size={9} /> A/V error
|
||||
</span>
|
||||
: health === 'warning'
|
||||
? <span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 text-yellow-300 border border-yellow-800">
|
||||
<HeartPulse size={9} /> A/V aviso
|
||||
</span>
|
||||
: <span className="inline-flex items-center gap-1 text-[10px] text-emerald-500">
|
||||
<ShieldCheck size={9} /> A/V ok
|
||||
</span>
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||
{badge}
|
||||
{hasCorrections && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-violet-900/40 text-violet-300 border border-violet-800" title="Correcciones automáticas del monitor de salud">
|
||||
<RefreshCw size={9} /> ×{corrections}
|
||||
</span>
|
||||
)}
|
||||
{hasDesyncs && (
|
||||
<span className="text-[10px] text-gray-600 tabular-nums" title="Eventos A/V en stderr de ffmpeg (informativos)">
|
||||
{desyncs} ev.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BufferBar({ pct, serverSecs, serverCapacity }: {
|
||||
pct?: number
|
||||
serverSecs?: number
|
||||
serverCapacity?: number
|
||||
}) {
|
||||
const v = pct ?? 0
|
||||
const color =
|
||||
v >= 70 ? 'bg-red-500' :
|
||||
v >= 40 ? 'bg-yellow-400' :
|
||||
'bg-emerald-500'
|
||||
const label =
|
||||
v >= 70 ? 'text-red-400' :
|
||||
v >= 40 ? 'text-yellow-400' :
|
||||
'text-emerald-500'
|
||||
|
||||
const srvSecs = serverSecs ?? 0
|
||||
const capMB = ((serverCapacity ?? 0) / 1048576).toFixed(0)
|
||||
// 0 is healthy: queue is empty because client consumes at the same rate data arrives.
|
||||
// The protection kicks in during a reconnect: the queue is NOT drained (unlike before),
|
||||
// so whatever was buffered at the moment of disconnect is preserved. Capacity = 32 MB.
|
||||
const srvLabel = srvSecs > 0.5 ? `${srvSecs.toFixed(1)}s` : 'ok'
|
||||
const srvColor = srvSecs > 10 ? 'text-yellow-400' : 'text-emerald-500'
|
||||
const srvTooltip = `Buffer servidor: ${srvSecs > 0 ? srvSecs.toFixed(1) + 's de cobertura' : '0 — normal en reposo'}. Capacidad máx: ${capMB} MB. El buffer se activa durante reconexiones del proveedor.`
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
<div
|
||||
className="flex items-center justify-between"
|
||||
title={`Cola cliente: ${v.toFixed(0)}% — 0%=consume sin retraso, 100%=cliente lento`}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500">Cola cliente</span>
|
||||
<span className={`text-[10px] font-mono tabular-nums ${label}`}>{v.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 rounded-full bg-dark-700 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-700 ${color}`}
|
||||
style={{ width: `${Math.min(v, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between pt-0.5"
|
||||
title={srvTooltip}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500">Reserva ({capMB} MB)</span>
|
||||
<span className={`text-[10px] font-mono tabular-nums ${srvColor}`}>{srvLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
live: <Tv size={12} className="text-blue-400" />,
|
||||
movie: <Film size={12} className="text-purple-400" />,
|
||||
series: <Clapperboard size={12} className="text-green-400" />,
|
||||
}
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
live: 'bg-blue-900/30 border-blue-800 text-blue-300',
|
||||
movie: 'bg-purple-900/30 border-purple-800 text-purple-300',
|
||||
series: 'bg-green-900/30 border-green-800 text-green-300',
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [streams, setStreams] = useState<StreamSession[]>([])
|
||||
const [slots, setSlots] = useState<SlotInfo[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [killing, setKilling] = useState<string | null>(null)
|
||||
const [killError, setKillError] = useState<string | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const wsUrl = `${proto}://${window.location.host}/api/admin/monitoring/ws`
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => setConnected(true)
|
||||
ws.onclose = () => setConnected(false)
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'state' || msg.type === 'update') {
|
||||
setStreams(msg.data)
|
||||
}
|
||||
}
|
||||
return () => ws.close()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/monitoring/slots').then(r => setSlots(r.data)).catch(() => {})
|
||||
}, [streams])
|
||||
|
||||
async function killClient(clientId: string, username: string) {
|
||||
if (!confirm(`¿Cortar la conexión de "${username}"?`)) return
|
||||
setKilling(clientId)
|
||||
setKillError(null)
|
||||
try {
|
||||
await api.post('/monitoring/kill', { client_id: clientId })
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
setKillError(msg || 'Error al cortar la conexión')
|
||||
setTimeout(() => setKillError(null), 4000)
|
||||
} finally {
|
||||
setKilling(null)
|
||||
}
|
||||
}
|
||||
|
||||
const totalClients = streams.reduce((a, s) => a + s.client_count, 0)
|
||||
const busySlots = slots.filter(s => s.status === 'busy').length
|
||||
const totalBpsDown = streams.reduce((a, s) => a + (s.bps_down || 0), 0)
|
||||
const totalCpu = streams.reduce((a, s) => a + (s.ffmpeg_cpu_pct ?? 0), 0)
|
||||
const anyReconnecting = streams.some(s => s.status === 'reconnecting')
|
||||
const streamsOk = streams.filter(s => !s.av_health || s.av_health === 'ok').length
|
||||
const streamsWarn = streams.filter(s => s.av_health === 'warning').length
|
||||
const streamsErr = streams.filter(s => s.av_health === 'error').length
|
||||
const totalCorrections = streams.reduce((a, s) => a + (s.corrections ?? 0), 0)
|
||||
|
||||
const healthColor =
|
||||
streamsErr > 0 ? 'text-red-400' :
|
||||
streamsWarn > 0 ? 'text-yellow-400' :
|
||||
'text-emerald-400'
|
||||
const healthValue =
|
||||
streams.length === 0 ? '—' :
|
||||
streamsErr > 0 ? `${streamsErr} error${streamsErr > 1 ? 'es' : ''}` :
|
||||
streamsWarn > 0 ? `${streamsWarn} aviso${streamsWarn > 1 ? 's' : ''}` :
|
||||
`${streamsOk} ok`
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Dashboard</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{anyReconnecting && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-orange-400 bg-orange-900/30 border border-orange-800 px-2 py-1 rounded-lg">
|
||||
<RefreshCw size={11} className="animate-spin" />
|
||||
Reconectando stream(s)...
|
||||
</span>
|
||||
)}
|
||||
{streamsErr > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-red-400 bg-red-900/30 border border-red-800 px-2 py-1 rounded-lg">
|
||||
<AlertTriangle size={11} />
|
||||
{streamsErr} stream{streamsErr > 1 ? 's' : ''} con error A/V
|
||||
</span>
|
||||
)}
|
||||
<div className={`flex items-center gap-2 text-xs ${connected ? 'text-green-400' : 'text-red-400'}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400 animate-pulse' : 'bg-red-400'}`} />
|
||||
{connected ? 'Conectado en tiempo real' : 'Desconectado'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{killError && (
|
||||
<div className="bg-red-900/40 border border-red-700 rounded-lg px-4 py-2 text-sm text-red-300">
|
||||
{killError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<StatCard icon={<Activity size={20} className="text-blue-400" />} label="Streams activos" value={streams.length} />
|
||||
<StatCard icon={<Users size={20} className="text-green-400" />} label="Usuarios conectados" value={totalClients} />
|
||||
<StatCard icon={<Server size={20} className="text-orange-400" />} label="Slots ocupados" value={`${busySlots}/${slots.length}`} />
|
||||
<StatCard icon={<Wifi size={20} className="text-purple-400" />} label="↓ Total proveedor" value={formatBps(totalBpsDown)} />
|
||||
<StatCard
|
||||
icon={<Cpu size={20} className={totalCpu > 80 ? 'text-red-400' : totalCpu > 40 ? 'text-yellow-400' : 'text-cyan-400'} />}
|
||||
label="ffmpeg CPU total"
|
||||
value={`${totalCpu.toFixed(2)}%`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<HeartPulse size={20} className={healthColor} />}
|
||||
label={`Salud A/V${totalCorrections > 0 ? ` · ${totalCorrections} corr.` : ''}`}
|
||||
value={healthValue}
|
||||
valueClass={healthColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider slots */}
|
||||
{slots.length > 0 && (
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-400 mb-3">Estado de Slots del Proveedor</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{slots.map(slot => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border ${
|
||||
slot.status === 'busy'
|
||||
? 'bg-orange-900/30 border-orange-700 text-orange-300'
|
||||
: 'bg-green-900/30 border-green-800 text-green-400'
|
||||
}`}
|
||||
>
|
||||
<span className="font-bold">{slot.name}</span>
|
||||
<span className="ml-2 opacity-70">{slot.status === 'busy' ? `ch#${slot.channel_id}` : 'libre'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active streams table */}
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700">
|
||||
<div className="p-4 border-b border-dark-700">
|
||||
<h2 className="text-sm font-semibold text-gray-300">Streams en Reproducción</h2>
|
||||
</div>
|
||||
{streams.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500 text-sm">No hay streams activos</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 border-b border-dark-700">
|
||||
<th className="px-4 py-2">Tipo</th>
|
||||
<th className="px-4 py-2">Canal</th>
|
||||
<th className="px-4 py-2">Proveedor</th>
|
||||
<th className="px-4 py-2">Stream</th>
|
||||
<th className="px-4 py-2">Estado</th>
|
||||
<th className="px-4 py-2">Salud A/V</th>
|
||||
<th className="px-4 py-2">Usuarios</th>
|
||||
<th className="px-4 py-2">↓ / ↑</th>
|
||||
<th className="px-4 py-2">Tiempo</th>
|
||||
<th className="px-4 py-2">Datos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{streams.map((s, idx) => (
|
||||
<tr key={idx} className="border-b border-dark-700 hover:bg-dark-700/30 align-top">
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border ${TYPE_COLORS[s.stream_type]}`}>
|
||||
{TYPE_ICONS[s.stream_type]}
|
||||
{TYPE_LABELS[s.stream_type]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="font-medium text-gray-200 text-xs leading-tight block max-w-[160px] truncate" title={s.channel_name}>
|
||||
{s.channel_name}
|
||||
</span>
|
||||
<span className="text-gray-600 text-xs">#{s.channel_id}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="bg-orange-900/40 text-orange-300 text-xs px-2 py-0.5 rounded">
|
||||
{s.provider_name || `#${s.provider_account_id}`}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 min-w-[160px]">
|
||||
{s.stream_info
|
||||
? <StreamInfoBadge info={s.stream_info} />
|
||||
: <span className="text-gray-600 text-xs italic">—</span>
|
||||
}
|
||||
</td>
|
||||
|
||||
{/* Stream status + ffmpeg */}
|
||||
<td className="px-4 py-2.5">
|
||||
<HealthBadge
|
||||
status={s.status}
|
||||
reconnectCount={s.reconnect_count}
|
||||
/>
|
||||
{s.ffmpeg_pid && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-1 bg-cyan-900/40 text-cyan-300 text-[10px] px-1.5 py-0.5 rounded border border-cyan-800 font-mono">
|
||||
<Cpu size={9} />
|
||||
ffmpeg
|
||||
</span>
|
||||
{s.ffmpeg_cpu_pct !== undefined && (
|
||||
<span className={`font-mono text-[10px] tabular-nums ${
|
||||
s.ffmpeg_cpu_pct > 80 ? 'text-red-400' :
|
||||
s.ffmpeg_cpu_pct > 40 ? 'text-yellow-400' :
|
||||
'text-gray-400'
|
||||
}`}>
|
||||
{s.ffmpeg_cpu_pct.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* A/V health + buffer */}
|
||||
<td className="px-4 py-2.5 min-w-[140px]">
|
||||
<AVHealthBadge
|
||||
health={s.av_health}
|
||||
desyncs={s.av_desync_count}
|
||||
corrections={s.corrections}
|
||||
/>
|
||||
<BufferBar pct={s.buffer_pct} serverSecs={s.buffer_server_secs} serverCapacity={s.buffer_capacity} />
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
{s.clients.map(c => (
|
||||
<div key={c.client_id} className="flex items-center gap-1.5">
|
||||
<span className="text-gray-300 text-xs font-medium">{c.username}</span>
|
||||
<button
|
||||
onClick={() => killClient(c.client_id, c.username)}
|
||||
disabled={killing === c.client_id}
|
||||
title={`Cortar conexión de ${c.username}`}
|
||||
className="p-0.5 rounded hover:bg-red-900/40 text-gray-600 hover:text-red-400 transition-colors disabled:opacity-40 shrink-0"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs">
|
||||
<div className="text-cyan-400">↓ {formatBps(s.bps_down)}</div>
|
||||
<div className="text-green-400">↑ {formatBps(s.bps_up)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 font-mono text-xs">
|
||||
<Clock size={11} className="inline mr-1" />
|
||||
{formatDuration(s.started_at)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs font-mono">{formatBytes(s.bytes_pumped)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, valueClass }: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string | number
|
||||
valueClass?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="bg-dark-700 p-2.5 rounded-lg">{icon}</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className={`text-2xl font-bold ${valueClass ?? ''}`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Trash2, RefreshCw } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface EpgSource { id: number; name: string; url: string; is_active: boolean; last_fetched_at: string | null }
|
||||
|
||||
export default function EPG() {
|
||||
const [sources, setSources] = useState<EpgSource[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', url: '' })
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/epg/')
|
||||
setSources(data)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
await api.post('/epg/', form)
|
||||
setForm({ name: '', url: '' })
|
||||
setShowForm(false)
|
||||
load()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm('¿Eliminar fuente EPG?')) return
|
||||
await api.delete(`/epg/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await api.post('/epg/refresh')
|
||||
alert('EPG actualización iniciada en segundo plano')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Gestión EPG</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
|
||||
Actualizar EPG
|
||||
</button>
|
||||
<button onClick={() => setShowForm(v => !v)} className="flex items-center gap-2 bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">
|
||||
<Plus size={16} /> Añadir fuente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-800 border border-primary-500/20 rounded-xl p-4 text-sm text-gray-400">
|
||||
<strong className="text-gray-200">Endpoint EPG para tus apps IPTV:</strong>
|
||||
<div className="font-mono text-xs mt-1 bg-dark-700 rounded p-2 text-green-400">
|
||||
http://10.10.10.234/xmltv.php?username=USUARIO&password=CONTRASEÑA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={submit} className="bg-dark-800 border border-dark-700 rounded-xl p-5 space-y-3">
|
||||
<h3 className="font-semibold text-sm">Nueva fuente XMLTV</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Nombre</label>
|
||||
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">URL XMLTV (o del proveedor)</label>
|
||||
<input value={form.url} onChange={e => setForm(f => ({ ...f, url: e.target.value }))} required
|
||||
placeholder="http://proveedor.com/xmltv.php?username=X&password=Y"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} className="bg-dark-700 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{sources.map(s => (
|
||||
<div key={s.id} className="bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-semibold">{s.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate max-w-xl">{s.url}</div>
|
||||
{s.last_fetched_at && (
|
||||
<div className="text-xs text-gray-600 mt-0.5">
|
||||
Última actualización: {new Date(s.last_fetched_at).toLocaleString('es-ES')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => del(s.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>
|
||||
))}
|
||||
{sources.length === 0 && (
|
||||
<div className="bg-dark-800 border border-dark-700 border-dashed rounded-xl p-8 text-center text-gray-500 text-sm">
|
||||
No hay fuentes EPG configuradas
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Film, Tv, RefreshCw, Trash2, Plus, Check, X, ChevronDown, ChevronUp, Loader, Pencil } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface JellyfinConfig {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
api_key: string
|
||||
is_active: boolean
|
||||
movies_library_id: string | null
|
||||
series_library_id: string | null
|
||||
movies_category_name: string
|
||||
series_category_name: string
|
||||
last_sync_at: string | null
|
||||
item_count: number
|
||||
}
|
||||
|
||||
interface Library {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface SyncStatus {
|
||||
status: 'idle' | 'running' | 'done' | 'error'
|
||||
message: string
|
||||
synced: number
|
||||
}
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '',
|
||||
url: 'http://10.10.10.15:8096',
|
||||
api_key: 'c339ccd3b7234c9a874bcfb70e92364a',
|
||||
movies_category_name: 'Películas KiraTV',
|
||||
series_category_name: 'Series KiraTV',
|
||||
movies_library_id: '',
|
||||
series_library_id: '',
|
||||
}
|
||||
|
||||
export default function Jellyfin() {
|
||||
const [configs, setConfigs] = useState<JellyfinConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editId, setEditId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState({ ...EMPTY_FORM })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; server_name?: string; error?: string } | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libsLoading, setLibsLoading] = useState(false)
|
||||
const [syncStatus, setSyncStatus] = useState<Record<number, SyncStatus>>({})
|
||||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/jellyfin')
|
||||
setConfigs(data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditId(null)
|
||||
setForm({ ...EMPTY_FORM })
|
||||
setTestResult(null)
|
||||
setLibraries([])
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function openEdit(cfg: JellyfinConfig) {
|
||||
setEditId(cfg.id)
|
||||
setForm({
|
||||
name: cfg.name,
|
||||
url: cfg.url,
|
||||
api_key: cfg.api_key,
|
||||
movies_category_name: cfg.movies_category_name,
|
||||
series_category_name: cfg.series_category_name,
|
||||
movies_library_id: cfg.movies_library_id ?? '',
|
||||
series_library_id: cfg.series_library_id ?? '',
|
||||
})
|
||||
setTestResult(null)
|
||||
setLibraries([])
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (!form.url || !form.api_key) return
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
// Test via temporary save-less call using an existing config if editId, else test inline
|
||||
if (editId) {
|
||||
const { data } = await api.post(`/jellyfin/${editId}/test`)
|
||||
setTestResult(data)
|
||||
} else {
|
||||
// Create temp config, test, then delete
|
||||
const { data: created } = await api.post('/jellyfin', {
|
||||
name: '__test__',
|
||||
url: form.url,
|
||||
api_key: form.api_key,
|
||||
movies_category_name: form.movies_category_name,
|
||||
series_category_name: form.series_category_name,
|
||||
})
|
||||
const { data: result } = await api.post(`/jellyfin/${created.id}/test`)
|
||||
await api.delete(`/jellyfin/${created.id}`)
|
||||
setTestResult(result)
|
||||
}
|
||||
} catch {
|
||||
setTestResult({ ok: false, error: 'Error de red' })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLibraries() {
|
||||
if (!editId) {
|
||||
// Need to save first
|
||||
await save()
|
||||
return
|
||||
}
|
||||
setLibsLoading(true)
|
||||
try {
|
||||
const { data } = await api.get(`/jellyfin/${editId}/libraries`)
|
||||
setLibraries(data)
|
||||
} catch {
|
||||
setLibraries([])
|
||||
} finally {
|
||||
setLibsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
movies_library_id: form.movies_library_id || null,
|
||||
series_library_id: form.series_library_id || null,
|
||||
}
|
||||
if (editId) {
|
||||
await api.put(`/jellyfin/${editId}`, payload)
|
||||
} else {
|
||||
const { data } = await api.post('/jellyfin', payload)
|
||||
setEditId(data.id)
|
||||
}
|
||||
await load()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCfg(id: number) {
|
||||
if (!confirm('¿Eliminar esta configuración de Jellyfin? Se borrarán todos los canales sincronizados.')) return
|
||||
await api.delete(`/jellyfin/${id}`)
|
||||
setConfigs(prev => prev.filter(c => c.id !== id))
|
||||
if (editId === id) setShowForm(false)
|
||||
}
|
||||
|
||||
async function startSync(id: number) {
|
||||
setSyncStatus(prev => ({ ...prev, [id]: { status: 'running', message: 'Iniciando…', synced: 0 } }))
|
||||
await api.post(`/jellyfin/${id}/sync`)
|
||||
pollSync(id)
|
||||
}
|
||||
|
||||
function pollSync(id: number) {
|
||||
const iv = setInterval(async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/jellyfin/${id}/sync-status`)
|
||||
setSyncStatus(prev => ({ ...prev, [id]: data }))
|
||||
if (data.status !== 'running') {
|
||||
clearInterval(iv)
|
||||
if (data.status === 'done') load()
|
||||
}
|
||||
} catch {
|
||||
clearInterval(iv)
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const movieLibs = libraries.filter(l => l.type === 'movies' || l.type === '')
|
||||
const tvLibs = libraries.filter(l => l.type === 'tvshows' || l.type === '')
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Biblioteca Jellyfin</h1>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Integra tu catálogo local de Jellyfin como VOD y Series en KiraStream
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex items-center gap-2 bg-primary-600 hover:bg-primary-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Nueva conexión
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Config list */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader size={24} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="bg-dark-800 border border-dark-700 rounded-xl p-12 text-center text-gray-500">
|
||||
<Film size={36} className="mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No hay conexiones Jellyfin configuradas</p>
|
||||
<button onClick={openCreate} className="mt-4 text-primary-400 hover:text-primary-300 text-sm">
|
||||
+ Añadir primera conexión
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{configs.map(cfg => {
|
||||
const ss = syncStatus[cfg.id]
|
||||
return (
|
||||
<div key={cfg.id} className="bg-dark-800 border border-dark-700 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-900/30 flex items-center justify-center shrink-0">
|
||||
<Film size={20} className="text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{cfg.name}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${cfg.is_active ? 'bg-green-900/30 text-green-400' : 'bg-gray-700 text-gray-400'}`}>
|
||||
{cfg.is_active ? 'activo' : 'inactivo'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5 truncate">{cfg.url}</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">
|
||||
{cfg.item_count} elementos
|
||||
{cfg.last_sync_at && ` · Última sync: ${new Date(cfg.last_sync_at).toLocaleString('es-ES')}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{ss?.status === 'running' ? (
|
||||
<span className="flex items-center gap-1 text-xs text-yellow-400">
|
||||
<RefreshCw size={12} className="animate-spin" /> {ss.message}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startSync(cfg.id)}
|
||||
className="flex items-center gap-1.5 text-xs bg-dark-700 hover:bg-primary-600 text-gray-300 hover:text-white px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} /> Sincronizar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEdit(cfg)}
|
||||
className="text-gray-500 hover:text-primary-400 p-1.5 rounded transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteCfg(cfg.id)}
|
||||
className="text-gray-500 hover:text-red-400 p-1.5 rounded transition-colors"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
|
||||
className="text-gray-500 hover:text-gray-300 p-1.5 rounded transition-colors"
|
||||
>
|
||||
{expanded === cfg.id ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ss?.status === 'done' && (
|
||||
<div className="px-4 pb-3 text-xs text-green-400 flex items-center gap-1">
|
||||
<Check size={12} /> {ss.message} · {ss.synced} elementos importados
|
||||
</div>
|
||||
)}
|
||||
{ss?.status === 'error' && (
|
||||
<div className="px-4 pb-3 text-xs text-red-400 flex items-center gap-1">
|
||||
<X size={12} /> Error: {ss.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded === cfg.id && (
|
||||
<div className="px-4 pb-4 border-t border-dark-700 pt-3 grid grid-cols-2 gap-4 text-xs text-gray-400">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1 text-purple-400 font-medium">
|
||||
<Film size={12} /> Películas
|
||||
</div>
|
||||
<div>Categoría: <span className="text-gray-300">{cfg.movies_category_name}</span></div>
|
||||
<div>Biblioteca: <span className="text-gray-300">{cfg.movies_library_id || '—'}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1 text-blue-400 font-medium">
|
||||
<Tv size={12} /> Series
|
||||
</div>
|
||||
<div>Categoría: <span className="text-gray-300">{cfg.series_category_name}</span></div>
|
||||
<div>Biblioteca: <span className="text-gray-300">{cfg.series_library_id || '—'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 border border-dark-700 rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<h2 className="font-semibold">{editId ? 'Editar conexión' : 'Nueva conexión Jellyfin'}</h2>
|
||||
<button onClick={() => setShowForm(false)} className="text-gray-500 hover:text-white">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Nombre</label>
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Mi Jellyfin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">URL del servidor</label>
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono"
|
||||
value={form.url}
|
||||
onChange={e => setForm(f => ({ ...f, url: e.target.value }))}
|
||||
placeholder="http://192.168.1.x:8096"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">API Key</label>
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono"
|
||||
value={form.api_key}
|
||||
onChange={e => setForm(f => ({ ...f, api_key: e.target.value }))}
|
||||
placeholder="Tu API key de Jellyfin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test connection */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={testConnection}
|
||||
disabled={testing || !form.url || !form.api_key}
|
||||
className="flex items-center gap-1.5 bg-dark-700 hover:bg-dark-600 text-sm px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing ? <Loader size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Probar conexión
|
||||
</button>
|
||||
{testResult && (
|
||||
<span className={`text-xs flex items-center gap-1 ${testResult.ok ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{testResult.ok ? (
|
||||
<><Check size={12} /> {testResult.server_name}</>
|
||||
) : (
|
||||
<><X size={12} /> {testResult.error}</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="border-dark-600" />
|
||||
|
||||
{/* Library selectors */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400 font-medium uppercase tracking-wider">Bibliotecas</span>
|
||||
<button
|
||||
onClick={loadLibraries}
|
||||
disabled={libsLoading}
|
||||
className="flex items-center gap-1 text-xs text-primary-400 hover:text-primary-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{libsLoading ? <Loader size={11} className="animate-spin" /> : <RefreshCw size={11} />}
|
||||
{editId ? 'Cargar bibliotecas' : 'Guardar primero para cargar'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block flex items-center gap-1">
|
||||
<Film size={11} /> Biblioteca de Películas
|
||||
</label>
|
||||
{libraries.length > 0 ? (
|
||||
<select
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
value={form.movies_library_id}
|
||||
onChange={e => setForm(f => ({ ...f, movies_library_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— ninguna —</option>
|
||||
{libraries.map(l => (
|
||||
<option key={l.id} value={l.id}>{l.name} ({l.type || 'sin tipo'})</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
|
||||
value={form.movies_library_id}
|
||||
onChange={e => setForm(f => ({ ...f, movies_library_id: e.target.value }))}
|
||||
placeholder="ID de biblioteca"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block flex items-center gap-1">
|
||||
<Tv size={11} /> Biblioteca de Series
|
||||
</label>
|
||||
{libraries.length > 0 ? (
|
||||
<select
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
value={form.series_library_id}
|
||||
onChange={e => setForm(f => ({ ...f, series_library_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— ninguna —</option>
|
||||
{libraries.map(l => (
|
||||
<option key={l.id} value={l.id}>{l.name} ({l.type || 'sin tipo'})</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
|
||||
value={form.series_library_id}
|
||||
onChange={e => setForm(f => ({ ...f, series_library_id: e.target.value }))}
|
||||
placeholder="ID de biblioteca"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Nombre categoría Películas</label>
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
value={form.movies_category_name}
|
||||
onChange={e => setForm(f => ({ ...f, movies_category_name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Nombre categoría Series</label>
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
value={form.series_category_name}
|
||||
onChange={e => setForm(f => ({ ...f, series_category_name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-4 py-2 text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => { await save(); setShowForm(false) }}
|
||||
disabled={saving || !form.name || !form.url || !form.api_key}
|
||||
className="flex items-center gap-2 bg-primary-600 hover:bg-primary-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader size={14} className="animate-spin" />}
|
||||
{editId ? 'Guardar cambios' : 'Crear'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Wifi } from 'lucide-react'
|
||||
import { login } from '../lib/api'
|
||||
|
||||
export default function Login() {
|
||||
const [user, setUser] = useState('')
|
||||
const [pass, setPass] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const token = await login(user, pass)
|
||||
localStorage.setItem('token', token)
|
||||
navigate('/dashboard')
|
||||
} catch {
|
||||
setError('Credenciales incorrectas')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<Wifi className="text-primary-500" size={32} />
|
||||
<h1 className="text-3xl font-bold">KiraStream</h1>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="bg-dark-800 rounded-xl p-6 space-y-4 border border-dark-700">
|
||||
<h2 className="text-center text-gray-400 text-sm">Panel de Administración</h2>
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-700 text-red-400 text-sm rounded p-2 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Usuario</label>
|
||||
<input
|
||||
value={user}
|
||||
onChange={e => setUser(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={e => setPass(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-500 hover:bg-primary-600 disabled:opacity-50 rounded-lg py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? 'Entrando...' : 'Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import api from '../lib/api'
|
||||
import { Search, Trash2, RefreshCw, Users, Tv, Database, Clock, AlertTriangle, CheckCircle } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ─── Connection log types ───────────────────────────────────────────────────
|
||||
|
||||
interface LogEntry {
|
||||
id: number
|
||||
user_id: number
|
||||
username: string
|
||||
channel_name: string | null
|
||||
content_type: string | null
|
||||
stream_id: string | null
|
||||
started_at: string | null
|
||||
ended_at: string | null
|
||||
duration_seconds: number | null
|
||||
bytes_transferred: number
|
||||
client_ip: string | null
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_sessions: number
|
||||
total_bytes: number
|
||||
avg_duration_seconds: number
|
||||
unique_users: number
|
||||
top_users: { username: string; sessions: number; bytes: number }[]
|
||||
top_channels: { channel_name: string; sessions: number }[]
|
||||
}
|
||||
|
||||
// ─── Stream event types ──────────────────────────────────────────────────────
|
||||
|
||||
interface StreamEvent {
|
||||
id: number
|
||||
channel_id: number | null
|
||||
channel_name: string | null
|
||||
provider_name: string | null
|
||||
event_type: 'caida' | 'recuperado' | string
|
||||
domain: string | null
|
||||
error_message: string | null
|
||||
attempt_number: number | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(b: number): string {
|
||||
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB'
|
||||
if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB'
|
||||
if (b >= 1e3) return (b / 1e3).toFixed(0) + ' KB'
|
||||
return b + ' B'
|
||||
}
|
||||
|
||||
function fmtDuration(s: number | null): string {
|
||||
if (!s) return '—'
|
||||
if (s < 60) return `${s}s`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
live: 'bg-blue-500/20 text-blue-400',
|
||||
movie: 'bg-purple-500/20 text-purple-400',
|
||||
series: 'bg-orange-500/20 text-orange-400',
|
||||
}
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color }: { icon: typeof Clock; label: string; value: string; color: string }) {
|
||||
return (
|
||||
<div className="bg-dark-800 rounded-xl p-4 flex items-center gap-3">
|
||||
<Icon size={20} className={color} />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-lg font-bold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Connection Logs tab ──────────────────────────────────────────────────────
|
||||
|
||||
function ConnectionsTab() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [filterUsername, setFilterUsername] = useState('')
|
||||
const [filterChannel, setFilterChannel] = useState('')
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [filterDateFrom, setFilterDateFrom] = useState('')
|
||||
const [filterDateTo, setFilterDateTo] = useState('')
|
||||
|
||||
const limit = 50
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/logs/stats')
|
||||
setStats(data)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const loadLogs = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: Record<string, string | number> = { page: p, limit }
|
||||
if (filterUsername) params.username = filterUsername
|
||||
if (filterChannel) params.channel = filterChannel
|
||||
if (filterType) params.content_type = filterType
|
||||
if (filterDateFrom) params.date_from = filterDateFrom
|
||||
if (filterDateTo) params.date_to = filterDateTo
|
||||
const { data } = await api.get('/logs', { params })
|
||||
setLogs(data.items)
|
||||
setTotal(data.total)
|
||||
setPage(p)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filterUsername, filterChannel, filterType, filterDateFrom, filterDateTo])
|
||||
|
||||
useEffect(() => { loadStats(); loadLogs(1) }, []) // eslint-disable-line
|
||||
|
||||
const doSearch = () => { loadStats(); loadLogs(1) }
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!confirm('¿Eliminar todos los registros de conexión?')) return
|
||||
await api.delete('/logs')
|
||||
setLogs([])
|
||||
setTotal(0)
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => { loadStats(); loadLogs(page) }} className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm transition-colors">
|
||||
<RefreshCw size={14} /> Actualizar
|
||||
</button>
|
||||
<button onClick={clearLogs} className="flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors">
|
||||
<Trash2 size={14} /> Limpiar todo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon={Clock} label="Total sesiones" value={stats.total_sessions.toLocaleString()} color="text-primary-400" />
|
||||
<StatCard icon={Database} label="Datos totales" value={fmtBytes(stats.total_bytes)} color="text-green-400" />
|
||||
<StatCard icon={Clock} label="Duración media" value={fmtDuration(stats.avg_duration_seconds)} color="text-yellow-400" />
|
||||
<StatCard icon={Users} label="Usuarios únicos" value={stats.unique_users.toString()} color="text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-dark-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold mb-3 text-gray-300 flex items-center gap-2"><Users size={14} /> Top usuarios</h3>
|
||||
<div className="space-y-2">
|
||||
{stats.top_users.map((u, i) => (
|
||||
<div key={u.username} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600 w-4">{i + 1}</span>
|
||||
<span className="flex-1 text-gray-200 font-medium">{u.username}</span>
|
||||
<span className="text-gray-400">{u.sessions} sesiones</span>
|
||||
<span className="text-green-400 text-xs">{fmtBytes(u.bytes)}</span>
|
||||
</div>
|
||||
))}
|
||||
{stats.top_users.length === 0 && <p className="text-xs text-gray-600">Sin datos</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold mb-3 text-gray-300 flex items-center gap-2"><Tv size={14} /> Top canales/contenido</h3>
|
||||
<div className="space-y-2">
|
||||
{stats.top_channels.map((c, i) => (
|
||||
<div key={c.channel_name} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600 w-4">{i + 1}</span>
|
||||
<span className="flex-1 text-gray-200 truncate">{c.channel_name}</span>
|
||||
<span className="text-gray-400">{c.sessions}</span>
|
||||
</div>
|
||||
))}
|
||||
{stats.top_channels.length === 0 && <p className="text-xs text-gray-600">Sin datos</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-dark-800 rounded-xl p-4">
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input value={filterUsername} onChange={e => setFilterUsername(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Usuario" className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-36 focus:outline-none focus:border-primary-500" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input value={filterChannel} onChange={e => setFilterChannel(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Canal / película" className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-48 focus:outline-none focus:border-primary-500" />
|
||||
</div>
|
||||
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none">
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="live">Directo</option>
|
||||
<option value="movie">Película</option>
|
||||
<option value="series">Serie</option>
|
||||
</select>
|
||||
<input type="date" value={filterDateFrom} onChange={e => setFilterDateFrom(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" />
|
||||
<span className="text-gray-500 text-sm">→</span>
|
||||
<input type="date" value={filterDateTo} onChange={e => setFilterDateTo(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" />
|
||||
<button onClick={doSearch} className="flex items-center gap-2 px-4 py-1.5 bg-primary-500 hover:bg-primary-600 rounded-lg text-sm transition-colors">
|
||||
<Search size={13} /> Buscar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-800 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<span className="text-sm text-gray-400">{total.toLocaleString()} registros</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button onClick={() => loadLogs(page - 1)} disabled={page === 1} className="px-2 py-1 bg-dark-700 rounded disabled:opacity-40">←</button>
|
||||
<span className="text-gray-400">{page} / {totalPages}</span>
|
||||
<button onClick={() => loadLogs(page + 1)} disabled={page === totalPages} className="px-2 py-1 bg-dark-700 rounded disabled:opacity-40">→</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-dark-700">
|
||||
<th className="text-left px-4 py-2">Usuario</th>
|
||||
<th className="text-left px-4 py-2">IP</th>
|
||||
<th className="text-left px-4 py-2">Canal / Contenido</th>
|
||||
<th className="text-left px-4 py-2">Tipo</th>
|
||||
<th className="text-left px-4 py-2">Inicio</th>
|
||||
<th className="text-left px-4 py-2">Duración</th>
|
||||
<th className="text-right px-4 py-2">Datos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && <tr><td colSpan={7} className="text-center py-8 text-gray-500">Cargando...</td></tr>}
|
||||
{!loading && logs.length === 0 && <tr><td colSpan={7} className="text-center py-8 text-gray-500">No hay registros</td></tr>}
|
||||
{logs.map(log => (
|
||||
<tr key={log.id} className="border-b border-dark-700/50 hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-4 py-2 font-medium text-gray-200">{log.username}</td>
|
||||
<td className="px-4 py-2 text-gray-400 font-mono text-xs">{log.client_ip || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-300 max-w-xs truncate">{log.channel_name || log.stream_id || '—'}</td>
|
||||
<td className="px-4 py-2">
|
||||
{log.content_type ? (
|
||||
<span className={clsx('text-xs px-2 py-0.5 rounded-full', TYPE_COLORS[log.content_type] || 'bg-gray-700 text-gray-400')}>
|
||||
{TYPE_LABELS[log.content_type] || log.content_type}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400 text-xs">{fmtDate(log.started_at)}</td>
|
||||
<td className="px-4 py-2 text-gray-400">{fmtDuration(log.duration_seconds)}</td>
|
||||
<td className="px-4 py-2 text-right text-green-400 text-xs">{fmtBytes(log.bytes_transferred)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Stream Events tab ────────────────────────────────────────────────────────
|
||||
|
||||
function EventsTab() {
|
||||
const [events, setEvents] = useState<StreamEvent[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [filterChannel, setFilterChannel] = useState('')
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [filterDateFrom, setFilterDateFrom] = useState('')
|
||||
const [filterDateTo, setFilterDateTo] = useState('')
|
||||
|
||||
const limit = 50
|
||||
|
||||
const loadEvents = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: Record<string, string | number> = { page: p, limit }
|
||||
if (filterChannel) params.channel = filterChannel
|
||||
if (filterType) params.event_type = filterType
|
||||
if (filterDateFrom) params.date_from = filterDateFrom
|
||||
if (filterDateTo) params.date_to = filterDateTo
|
||||
const { data } = await api.get('/logs/events', { params })
|
||||
setEvents(data.items)
|
||||
setTotal(data.total)
|
||||
setPage(p)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filterChannel, filterType, filterDateFrom, filterDateTo])
|
||||
|
||||
useEffect(() => { loadEvents(1) }, []) // eslint-disable-line
|
||||
|
||||
const doSearch = () => loadEvents(1)
|
||||
|
||||
const clearEvents = async () => {
|
||||
if (!confirm('¿Eliminar todos los registros de fallos?')) return
|
||||
await api.delete('/logs/events')
|
||||
setEvents([])
|
||||
setTotal(0)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
const caidas = events.filter(e => e.event_type === 'caida').length
|
||||
const recuperados = events.filter(e => e.event_type === 'recuperado').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => loadEvents(page)} className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm transition-colors">
|
||||
<RefreshCw size={14} /> Actualizar
|
||||
</button>
|
||||
<button onClick={clearEvents} className="flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors">
|
||||
<Trash2 size={14} /> Limpiar todo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<StatCard icon={AlertTriangle} label="Total fallos registrados" value={total.toLocaleString()} color="text-orange-400" />
|
||||
<StatCard icon={AlertTriangle} label="Caídas (en vista actual)" value={caidas.toString()} color="text-red-400" />
|
||||
<StatCard icon={CheckCircle} label="Recuperaciones (en vista actual)" value={recuperados.toString()} color="text-emerald-400" />
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-dark-800 rounded-xl p-4">
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input value={filterChannel} onChange={e => setFilterChannel(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Canal / proveedor" className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-48 focus:outline-none focus:border-primary-500" />
|
||||
</div>
|
||||
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none">
|
||||
<option value="">Todos los eventos</option>
|
||||
<option value="caida">Caídas</option>
|
||||
<option value="recuperado">Recuperaciones</option>
|
||||
</select>
|
||||
<input type="date" value={filterDateFrom} onChange={e => setFilterDateFrom(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" />
|
||||
<span className="text-gray-500 text-sm">→</span>
|
||||
<input type="date" value={filterDateTo} onChange={e => setFilterDateTo(e.target.value)} className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none" />
|
||||
<button onClick={doSearch} className="flex items-center gap-2 px-4 py-1.5 bg-primary-500 hover:bg-primary-600 rounded-lg text-sm transition-colors">
|
||||
<Search size={13} /> Buscar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events table */}
|
||||
<div className="bg-dark-800 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<span className="text-sm text-gray-400">{total.toLocaleString()} eventos</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button onClick={() => loadEvents(page - 1)} disabled={page === 1} className="px-2 py-1 bg-dark-700 rounded disabled:opacity-40">←</button>
|
||||
<span className="text-gray-400">{page} / {totalPages}</span>
|
||||
<button onClick={() => loadEvents(page + 1)} disabled={page === totalPages} className="px-2 py-1 bg-dark-700 rounded disabled:opacity-40">→</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-dark-700">
|
||||
<th className="text-left px-4 py-2">Fecha</th>
|
||||
<th className="text-left px-4 py-2">Tipo</th>
|
||||
<th className="text-left px-4 py-2">Canal</th>
|
||||
<th className="text-left px-4 py-2">Proveedor</th>
|
||||
<th className="text-left px-4 py-2">Dominio</th>
|
||||
<th className="text-left px-4 py-2">Detalle</th>
|
||||
<th className="text-right px-4 py-2">Intento</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && <tr><td colSpan={7} className="text-center py-8 text-gray-500">Cargando...</td></tr>}
|
||||
{!loading && events.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center py-8 text-gray-500">No hay eventos registrados — los fallos aparecerán aquí automáticamente</td></tr>
|
||||
)}
|
||||
{events.map(ev => (
|
||||
<tr key={ev.id} className="border-b border-dark-700/50 hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{fmtDate(ev.created_at)}</td>
|
||||
<td className="px-4 py-2">
|
||||
{ev.event_type === 'caida' ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-500/20 text-red-400">
|
||||
<AlertTriangle size={10} /> Caída
|
||||
</span>
|
||||
) : ev.event_type === 'recuperado' ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400">
|
||||
<CheckCircle size={10} /> Recuperado
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">{ev.event_type}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-200 max-w-[180px] truncate" title={ev.channel_name ?? ''}>
|
||||
{ev.channel_name || `#${ev.channel_id}` || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400 text-xs">{ev.provider_name || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-400 font-mono text-xs">{ev.domain || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-500 text-xs max-w-[240px] truncate" title={ev.error_message ?? ''}>
|
||||
{ev.error_message || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-gray-500 text-xs tabular-nums">
|
||||
{ev.attempt_number ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = 'connections' | 'events'
|
||||
|
||||
export default function Logs() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('connections')
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Registro</h1>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-dark-700 pb-0">
|
||||
<button
|
||||
onClick={() => setActiveTab('connections')}
|
||||
className={clsx(
|
||||
'px-4 py-2 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px',
|
||||
activeTab === 'connections'
|
||||
? 'border-primary-500 text-primary-400 bg-dark-800'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
)}
|
||||
>
|
||||
Conexiones de usuarios
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('events')}
|
||||
className={clsx(
|
||||
'px-4 py-2 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px flex items-center gap-1.5',
|
||||
activeTab === 'events'
|
||||
? 'border-red-500 text-red-400 bg-dark-800'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle size={13} /> Fallos de stream
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'connections' && <ConnectionsTab />}
|
||||
{activeTab === 'events' && <EventsTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
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
|
||||
base_url: string
|
||||
username: string
|
||||
max_connections: number
|
||||
is_active: boolean
|
||||
last_sync_at: string | null
|
||||
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 {
|
||||
if (!dateStr) return null
|
||||
const exp = new Date(dateStr)
|
||||
const daysLeft = Math.ceil((exp.getTime() - Date.now()) / 86400000)
|
||||
if (daysLeft < 0) return { label: 'Expirado', cls: 'text-red-400 bg-red-900/20 border border-red-800' }
|
||||
if (daysLeft <= 7) return { label: `Caduca en ${daysLeft}d`, cls: 'text-red-400 bg-red-900/20 border border-red-800' }
|
||||
if (daysLeft <= 30) return { label: `Caduca en ${daysLeft}d`, cls: 'text-yellow-400 bg-yellow-900/20 border border-yellow-800' }
|
||||
return { label: `Caduca: ${exp.toLocaleDateString('es-ES')}`, cls: 'text-green-400 bg-green-900/20 border border-green-800' }
|
||||
}
|
||||
|
||||
interface CatOption { id: string; name: string }
|
||||
interface AvailableCategories {
|
||||
live: CatOption[]
|
||||
movie: CatOption[]
|
||||
series: CatOption[]
|
||||
}
|
||||
|
||||
interface UpdateCategory { id: string; name: string; type: string }
|
||||
|
||||
interface FormData {
|
||||
name: string
|
||||
base_url: string
|
||||
username: string
|
||||
password: string
|
||||
max_connections: number
|
||||
auto_sync_hours: number
|
||||
}
|
||||
|
||||
const TYPES: Array<{ key: keyof AvailableCategories; label: string }> = [
|
||||
{ key: 'live', label: 'Canales en directo' },
|
||||
{ key: 'movie', label: 'Películas (VOD)' },
|
||||
{ key: 'series', label: 'Series' },
|
||||
]
|
||||
|
||||
const AUTO_SYNC_OPTIONS = [
|
||||
{ value: 0, label: 'Desactivado' },
|
||||
{ value: 6, label: 'Cada 6 horas' },
|
||||
{ value: 12, label: 'Cada 12 horas' },
|
||||
{ value: 24, label: 'Cada 24 horas' },
|
||||
]
|
||||
|
||||
const empty: FormData = { name: '', base_url: '', username: '', password: '', max_connections: 1, auto_sync_hours: 0 }
|
||||
|
||||
export default function Providers() {
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [form, setForm] = useState<FormData>(empty)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [syncing, setSyncing] = useState<Set<number>>(new Set())
|
||||
const [syncProgress, setSyncProgress] = useState<Record<number, string>>({})
|
||||
const [syncStats, setSyncStats] = useState<Record<number, Record<string, number>>>({})
|
||||
const [testing, setTesting] = useState<number | null>(null)
|
||||
const [testResult, setTestResult] = useState<Record<number, boolean>>({})
|
||||
const pollRefs = useRef<Record<number, boolean>>({})
|
||||
|
||||
// Category picker state
|
||||
const [pickerProvider, setPickerProvider] = useState<Provider | null>(null)
|
||||
const [pickerLoading, setPickerLoading] = useState(false)
|
||||
const [availableCats, setAvailableCats] = useState<AvailableCategories>({ live: [], movie: [], series: [] })
|
||||
const [selectedCats, setSelectedCats] = useState<Record<keyof AvailableCategories, Set<string>>>({ live: new Set(), movie: new Set(), series: new Set() })
|
||||
const [catSearch, setCatSearch] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Edit provider modal state
|
||||
const [editProvider, setEditProvider] = useState<Provider | null>(null)
|
||||
const [editForm, setEditForm] = useState({ name: '', base_url: '', username: '', password: '', max_connections: 1, auto_sync_hours: 0 })
|
||||
|
||||
// Updates modal state
|
||||
const [updatesProvider, setUpdatesProvider] = useState<Provider | null>(null)
|
||||
const [updatesLoading, setUpdatesLoading] = useState(false)
|
||||
const [updatesData, setUpdatesData] = useState<UpdateCategory[]>([])
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<Set<string>>(new Set())
|
||||
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)
|
||||
return data as Provider[]
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().then(async (provs: Provider[]) => {
|
||||
for (const p of provs) {
|
||||
try {
|
||||
const { data } = await api.get(`/providers/${p.id}/sync/status`)
|
||||
if (data.status === 'running') {
|
||||
setSyncing(s => new Set(s).add(p.id))
|
||||
setSyncProgress(prev => ({ ...prev, [p.id]: data.progress || 'Sincronizando...' }))
|
||||
startPolling(p.id)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
return () => { Object.keys(pollRefs.current).forEach(id => { pollRefs.current[+id] = false }) }
|
||||
}, [])
|
||||
|
||||
function startPolling(id: number) {
|
||||
pollRefs.current[id] = true
|
||||
const poll = async () => {
|
||||
if (!pollRefs.current[id]) return
|
||||
try {
|
||||
const { data } = await api.get(`/providers/${id}/sync/status`)
|
||||
setSyncProgress(p => ({ ...p, [id]: data.progress || '' }))
|
||||
setSyncStats(s => ({ ...s, [id]: data.stats || {} }))
|
||||
if (data.status === 'running') {
|
||||
setTimeout(poll, 1500)
|
||||
} else {
|
||||
pollRefs.current[id] = false
|
||||
setSyncing(s => { const n = new Set(s); n.delete(id); return n })
|
||||
setSyncProgress(p => ({ ...p, [id]: data.status === 'done' ? '✓ Sync completado' : `Error: ${data.progress}` }))
|
||||
load()
|
||||
}
|
||||
} catch {
|
||||
pollRefs.current[id] = false
|
||||
setSyncing(s => { const n = new Set(s); n.delete(id); return n })
|
||||
}
|
||||
}
|
||||
poll()
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
await api.post('/providers/', form)
|
||||
setForm(empty)
|
||||
setShowForm(false)
|
||||
load()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm('¿Eliminar este proveedor y todos sus canales?')) return
|
||||
await api.delete(`/providers/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function test(id: number) {
|
||||
setTesting(id)
|
||||
try {
|
||||
const { data } = await api.post(`/providers/${id}/test`)
|
||||
setTestResult(r => ({ ...r, [id]: data.connected }))
|
||||
if (data.connected) load() // refresh to show updated expiry_date
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(provider: Provider) {
|
||||
setEditProvider(provider)
|
||||
setEditForm({
|
||||
name: provider.name,
|
||||
base_url: provider.base_url,
|
||||
username: provider.username,
|
||||
password: '',
|
||||
max_connections: provider.max_connections,
|
||||
auto_sync_hours: provider.auto_sync_hours,
|
||||
})
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editProvider) return
|
||||
try {
|
||||
await api.put(`/providers/${editProvider.id}`, editForm)
|
||||
setEditProvider(null)
|
||||
load()
|
||||
} catch (e: any) {
|
||||
alert('Error al guardar: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function openCategoryPicker(provider: Provider) {
|
||||
setPickerProvider(provider)
|
||||
setPickerLoading(true)
|
||||
setCatSearch('')
|
||||
setCollapsed({})
|
||||
try {
|
||||
const [availRes, syncedRes] = await Promise.all([
|
||||
api.get(`/providers/${provider.id}/sync/categories`),
|
||||
api.get(`/providers/${provider.id}/synced-category-ids`),
|
||||
])
|
||||
setAvailableCats(availRes.data)
|
||||
|
||||
// Primary: last_sync_categories (explicit selection from previous sync — works for
|
||||
// all providers including those whose channels share another provider's categories)
|
||||
// Fallback: synced-category-ids (actual DB state for providers that own their categories)
|
||||
let live: string[] = syncedRes.data.live
|
||||
let movie: string[] = syncedRes.data.movie
|
||||
let series: string[] = syncedRes.data.series
|
||||
|
||||
if (provider.last_sync_categories) {
|
||||
try {
|
||||
const lsc = JSON.parse(provider.last_sync_categories)
|
||||
if ((lsc.live?.length ?? 0) + (lsc.movie?.length ?? 0) + (lsc.series?.length ?? 0) > 0) {
|
||||
live = lsc.live ?? []
|
||||
movie = lsc.movie ?? []
|
||||
series = lsc.series ?? []
|
||||
}
|
||||
} catch { /* ignore malformed JSON */ }
|
||||
}
|
||||
|
||||
setSelectedCats({
|
||||
live: new Set<string>(live),
|
||||
movie: new Set<string>(movie),
|
||||
series: new Set<string>(series),
|
||||
})
|
||||
} catch {
|
||||
setPickerProvider(null)
|
||||
} finally {
|
||||
setPickerLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function startSyncFromPicker() {
|
||||
if (!pickerProvider) return
|
||||
const id = pickerProvider.id
|
||||
setPickerProvider(null)
|
||||
setSyncing(s => new Set(s).add(id))
|
||||
setSyncProgress(p => ({ ...p, [id]: 'Iniciando...' }))
|
||||
try {
|
||||
await api.post(`/providers/${id}/sync`, {
|
||||
live_category_ids: Array.from(selectedCats.live),
|
||||
movie_category_ids: Array.from(selectedCats.movie),
|
||||
series_category_ids: Array.from(selectedCats.series),
|
||||
})
|
||||
startPolling(id)
|
||||
} catch {
|
||||
setSyncing(s => { const n = new Set(s); n.delete(id); return n })
|
||||
setSyncProgress(p => ({ ...p, [id]: 'Error al iniciar sync' }))
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCat(type: keyof AvailableCategories, id: string) {
|
||||
setSelectedCats(prev => {
|
||||
const n = new Set(prev[type])
|
||||
n.has(id) ? n.delete(id) : n.add(id)
|
||||
return { ...prev, [type]: n }
|
||||
})
|
||||
}
|
||||
|
||||
function selectAll(type: keyof AvailableCategories, cats: CatOption[]) {
|
||||
setSelectedCats(prev => ({ ...prev, [type]: new Set(cats.map(c => c.id)) }))
|
||||
}
|
||||
|
||||
function deselectAll(type: keyof AvailableCategories) {
|
||||
setSelectedCats(prev => ({ ...prev, [type]: new Set() }))
|
||||
}
|
||||
|
||||
async function openUpdates(provider: Provider) {
|
||||
setUpdatesProvider(provider)
|
||||
setUpdatesLoading(true)
|
||||
setUpdatesSearch('')
|
||||
setSelectedUpdates(new Set())
|
||||
try {
|
||||
const { data } = await api.get(`/providers/${provider.id}/available-updates`)
|
||||
setUpdatesData(data.new_categories || [])
|
||||
} catch {
|
||||
setUpdatesData([])
|
||||
} finally {
|
||||
setUpdatesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdatesCount(providerId: number) {
|
||||
try {
|
||||
const { data } = await api.get(`/providers/${providerId}/available-updates`)
|
||||
setUpdatesCount(prev => ({ ...prev, [providerId]: data.total || 0 }))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Check update counts for all providers after load
|
||||
providers.forEach(p => {
|
||||
if (p.last_sync_at) checkUpdatesCount(p.id)
|
||||
})
|
||||
}, [providers.length])
|
||||
|
||||
async function syncSelectedUpdates() {
|
||||
if (!updatesProvider || selectedUpdates.size === 0) return
|
||||
const id = updatesProvider.id
|
||||
|
||||
// Group selected updates by type
|
||||
const live: string[] = []
|
||||
const movie: string[] = []
|
||||
const series: string[] = []
|
||||
updatesData.filter(c => selectedUpdates.has(c.id + '_' + c.type)).forEach(c => {
|
||||
if (c.type === 'live') live.push(c.id)
|
||||
else if (c.type === 'movie') movie.push(c.id)
|
||||
else if (c.type === 'series') series.push(c.id)
|
||||
})
|
||||
|
||||
setUpdatesProvider(null)
|
||||
setSyncing(s => new Set(s).add(id))
|
||||
setSyncProgress(p => ({ ...p, [id]: 'Iniciando...' }))
|
||||
try {
|
||||
await api.post(`/providers/${id}/sync`, {
|
||||
live_category_ids: live.length > 0 ? live : undefined,
|
||||
movie_category_ids: movie.length > 0 ? movie : undefined,
|
||||
series_category_ids: series.length > 0 ? series : undefined,
|
||||
})
|
||||
startPolling(id)
|
||||
} catch {
|
||||
setSyncing(s => { const n = new Set(s); n.delete(id); return n })
|
||||
setSyncProgress(p => ({ ...p, [id]: 'Error al iniciar sync' }))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
live: 'bg-blue-900/30 text-blue-300 border-blue-800',
|
||||
movie: 'bg-purple-900/30 text-purple-300 border-purple-800',
|
||||
series: 'bg-green-900/30 text-green-300 border-green-800',
|
||||
}
|
||||
|
||||
const filteredUpdates = updatesData.filter(c =>
|
||||
!updatesSearch || c.name.toLowerCase().includes(updatesSearch.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Proveedores IPTV</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(v => !v)}
|
||||
className="flex items-center gap-2 bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Añadir proveedor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={submit} className="bg-dark-800 border border-dark-700 rounded-xl p-5 space-y-3">
|
||||
<h3 className="font-semibold text-sm mb-2">Nueva cuenta de proveedor</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Nombre" value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} required />
|
||||
<Field label="URL base (ej: http://provider.com:8080)" value={form.base_url} onChange={v => setForm(f => ({ ...f, base_url: v }))} required />
|
||||
<Field label="Usuario" value={form.username} onChange={v => setForm(f => ({ ...f, username: v }))} required />
|
||||
<Field label="Contraseña" value={form.password} onChange={v => setForm(f => ({ ...f, password: v }))} required />
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Conexiones máx.</label>
|
||||
<input
|
||||
type="number" min={1} max={100}
|
||||
value={form.max_connections}
|
||||
onChange={e => setForm(f => ({ ...f, max_connections: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Auto-sincronización</label>
|
||||
<select
|
||||
value={form.auto_sync_hours}
|
||||
onChange={e => setForm(f => ({ ...f, auto_sync_hours: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
{AUTO_SYNC_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} className="bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Category picker modal */}
|
||||
{pickerProvider && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-2xl flex flex-col max-h-[90vh]">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 className="font-semibold">Seleccionar categorías — <span className="text-primary-400">{pickerProvider.name}</span></h2>
|
||||
{!pickerLoading && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{totalSelected} de {totalAvailable} categorías seleccionadas</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setPickerProvider(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
{pickerLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center p-12">
|
||||
<div className="text-center text-gray-400">
|
||||
<RefreshCw size={24} className="animate-spin mx-auto mb-3" />
|
||||
<p className="text-sm">Obteniendo categorías del proveedor...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 border-b border-dark-700 shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={catSearch}
|
||||
onChange={e => setCatSearch(e.target.value)}
|
||||
placeholder="Buscar categoría..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-3 space-y-2">
|
||||
{TYPES.map(({ key, label }) => {
|
||||
const cats = availableCats[key].filter(c =>
|
||||
!catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase())
|
||||
)
|
||||
if (!cats.length && !catSearch) return null
|
||||
const selCount = cats.filter(c => selectedCats[key].has(c.id)).length
|
||||
const isCollapsed = collapsed[key]
|
||||
return (
|
||||
<div key={key} className="bg-dark-900/50 rounded-lg border border-dark-700 overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-dark-700/50 select-none"
|
||||
onClick={() => setCollapsed(c => ({ ...c, [key]: !c[key] }))}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCollapsed ? <ChevronRight size={14} className="text-gray-500" /> : <ChevronDown size={14} className="text-gray-500" />}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-xs text-gray-500">({selCount}/{cats.length} sel.)</span>
|
||||
</div>
|
||||
<div className="flex gap-2" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => selectAll(key, cats)} className="text-xs text-primary-400 hover:text-primary-300 px-2 py-0.5 rounded hover:bg-dark-600">Todo</button>
|
||||
<button onClick={() => deselectAll(key)} className="text-xs text-gray-500 hover:text-gray-300 px-2 py-0.5 rounded hover:bg-dark-600">Nada</button>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-2 gap-0.5 p-2 border-t border-dark-700 max-h-64 overflow-y-auto">
|
||||
{cats.length === 0 ? (
|
||||
<div className="col-span-2 text-center text-gray-500 text-xs py-3">Sin resultados</div>
|
||||
) : cats.map(cat => (
|
||||
<label key={cat.id} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-dark-700 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCats[key].has(cat.id)}
|
||||
onChange={() => toggleCat(key, cat.id)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate text-xs">{cat.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-dark-700 flex gap-2 justify-end shrink-0">
|
||||
<button onClick={() => setPickerProvider(null)} className="bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
<button
|
||||
onClick={startSyncFromPicker}
|
||||
disabled={totalSelected === 0}
|
||||
className="bg-primary-500 hover:bg-primary-600 disabled:opacity-40 px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Importar {totalSelected} {totalSelected === 1 ? 'categoría' : 'categorías'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updates modal */}
|
||||
{updatesProvider && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-lg flex flex-col max-h-[80vh]">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 className="font-semibold">Novedades — <span className="text-primary-400">{updatesProvider.name}</span></h2>
|
||||
{!updatesLoading && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{updatesData.length === 0
|
||||
? 'No hay categorías nuevas'
|
||||
: `${updatesData.length} categorías nuevas sin sincronizar`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setUpdatesProvider(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
{updatesLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center p-10">
|
||||
<RefreshCw size={22} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : updatesData.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center p-10 text-gray-500 text-sm">
|
||||
Todo está actualizado ✓
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 border-b border-dark-700 shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={updatesSearch}
|
||||
onChange={e => setUpdatesSearch(e.target.value)}
|
||||
placeholder="Buscar..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setSelectedUpdates(new Set(filteredUpdates.map(c => c.id + '_' + c.type)))}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 px-2 py-0.5 rounded hover:bg-dark-600"
|
||||
>
|
||||
Seleccionar todo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedUpdates(new Set())}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 px-2 py-0.5 rounded hover:bg-dark-600"
|
||||
>
|
||||
Ninguna
|
||||
</button>
|
||||
<span className="text-xs text-gray-600 ml-auto self-center">{selectedUpdates.size} seleccionadas</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-3 space-y-1">
|
||||
{filteredUpdates.map(cat => {
|
||||
const key = cat.id + '_' + cat.type
|
||||
return (
|
||||
<label key={key} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-dark-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUpdates.has(key)}
|
||||
onChange={() => {
|
||||
setSelectedUpdates(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(key) ? n.delete(key) : n.add(key)
|
||||
return n
|
||||
})
|
||||
}}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="text-sm flex-1 truncate">{cat.name}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded border ${TYPE_COLORS[cat.type]}`}>
|
||||
{TYPE_LABELS[cat.type]}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="p-4 border-t border-dark-700 flex gap-2 justify-end shrink-0">
|
||||
<button onClick={() => setUpdatesProvider(null)} className="bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
<button
|
||||
onClick={syncSelectedUpdates}
|
||||
disabled={selectedUpdates.size === 0}
|
||||
className="bg-primary-500 hover:bg-primary-600 disabled:opacity-40 px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Importar {selectedUpdates.size} {selectedUpdates.size === 1 ? 'categoría' : 'categorías'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit provider modal */}
|
||||
{editProvider && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-lg">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
|
||||
<h2 className="font-semibold">Editar proveedor — <span className="text-primary-400">{editProvider.name}</span></h2>
|
||||
<button onClick={() => setEditProvider(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Nombre" value={editForm.name} onChange={v => setEditForm(f => ({ ...f, name: v }))} required />
|
||||
<Field label="URL base" value={editForm.base_url} onChange={v => setEditForm(f => ({ ...f, base_url: v }))} required />
|
||||
<Field label="Usuario" value={editForm.username} onChange={v => setEditForm(f => ({ ...f, username: v }))} required />
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Contraseña (vacío = no cambiar)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Conexiones máx.</label>
|
||||
<input
|
||||
type="number" min={1} max={100}
|
||||
value={editForm.max_connections}
|
||||
onChange={e => setEditForm(f => ({ ...f, max_connections: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Auto-sincronización</label>
|
||||
<select
|
||||
value={editForm.auto_sync_hours}
|
||||
onChange={e => setEditForm(f => ({ ...f, auto_sync_hours: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
{AUTO_SYNC_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-dark-700 flex gap-2 justify-end">
|
||||
<button onClick={() => setEditProvider(null)} className="bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
<button onClick={saveEdit} className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{providers.map(p => (
|
||||
<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 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 && (
|
||||
<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 && (
|
||||
<div className="bg-dark-800 border border-dark-700 border-dashed rounded-xl p-10 text-center text-gray-500 text-sm">
|
||||
No hay proveedores. Añade uno para empezar.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange, required }: { label: string; value: string; onChange: (v: string) => void; required?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">{label}</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
required={required}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import api from '../lib/api'
|
||||
import { Upload, Trash2, ImageIcon, Info } from 'lucide-react'
|
||||
|
||||
export default function Settings() {
|
||||
const [hasImage, setHasImage] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const checkImage = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch('/api/admin/settings/blocked-image', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
setHasImage(true)
|
||||
// Use the static file URL (no auth needed) for the <img> tag
|
||||
setImageUrl('/panel/blocked_image.jpg?t=' + Date.now())
|
||||
} else {
|
||||
setHasImage(false)
|
||||
setImageUrl('')
|
||||
}
|
||||
} catch {
|
||||
setHasImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { checkImage() }, [])
|
||||
|
||||
const upload = async (file: File) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Solo se permiten imágenes (JPEG, PNG, WebP, GIF)')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
await api.post('/settings/blocked-image', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
await checkImage()
|
||||
} catch (e: any) {
|
||||
alert('Error al subir la imagen: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) upload(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) upload(file)
|
||||
}
|
||||
|
||||
const deleteImage = async () => {
|
||||
if (!confirm('¿Eliminar la imagen de bloqueo?')) return
|
||||
await api.delete('/settings/blocked-image')
|
||||
setHasImage(false)
|
||||
setImageUrl('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl space-y-6">
|
||||
<h1 className="text-xl font-bold">Ajustes</h1>
|
||||
|
||||
{/* Blocked image section */}
|
||||
<div className="bg-dark-800 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base flex items-center gap-2">
|
||||
<ImageIcon size={18} className="text-primary-400" />
|
||||
Imagen de conexión bloqueada
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Esta imagen se mostrará como canal de vídeo cuando un usuario intente reproducir
|
||||
en un segundo dispositivo habiendo alcanzado su límite de conexiones simultáneas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 text-xs text-blue-400 bg-blue-500/10 rounded-lg px-3 py-2">
|
||||
<Info size={13} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
El zapping normal (cambiar de canal en el mismo dispositivo) sigue funcionando
|
||||
sin interrupciones. La imagen solo aparece cuando se conecta un segundo dispositivo
|
||||
diferente con la misma cuenta.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Upload area */}
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !uploading && fileRef.current?.click()}
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-xl cursor-pointer transition-colors
|
||||
${dragging ? 'border-primary-400 bg-primary-500/10' : 'border-dark-600 hover:border-dark-500'}
|
||||
`}
|
||||
>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
|
||||
|
||||
{hasImage && imageUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Blocked image"
|
||||
className="w-full rounded-xl object-contain max-h-72"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center">
|
||||
<span className="text-white text-sm font-medium">Haz clic para cambiar</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 flex flex-col items-center gap-3 text-gray-500">
|
||||
<Upload size={32} className={dragging ? 'text-primary-400' : ''} />
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-sm">Arrastra una imagen o haz clic para subir</p>
|
||||
<p className="text-xs mt-1">JPEG, PNG, WebP o GIF · máx. 10 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 bg-dark-900/80 rounded-xl flex items-center justify-center">
|
||||
<div className="text-sm text-primary-400">Subiendo...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasImage && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={deleteImage}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<Trash2 size={14} /> Eliminar imagen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Trash2, Edit2, List, Star, Server, Search } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface User { id: number; username: string; max_connections: number; expiry_date: string | null; is_active: boolean; is_priority: boolean; created_at: string }
|
||||
interface GroupedCategory { name: string; type: string; category_ids: number[]; provider_count: number }
|
||||
interface CatalogEntry { id: number; category_id: number; category_name: string; type: string }
|
||||
interface ProviderPref { id: number; provider_account_id: number; provider_name: string; priority: number }
|
||||
interface Provider { id: number; name: string }
|
||||
interface CustomCat { id: number; name: string; type: string }
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Canales en directo', movie: 'Películas', series: 'Series' }
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editUser, setEditUser] = useState<User | null>(null)
|
||||
const [catalogUser, setCatalogUser] = useState<User | null>(null)
|
||||
const [providerUser, setProviderUser] = useState<User | null>(null)
|
||||
const [allCategories, setAllCategories] = useState<GroupedCategory[]>([])
|
||||
const [selectedCats, setSelectedCats] = useState<Set<number>>(new Set())
|
||||
const [allCustomCats, setAllCustomCats] = useState<CustomCat[]>([])
|
||||
const [selectedCustomCats, setSelectedCustomCats] = useState<Set<number>>(new Set())
|
||||
const [catSearch, setCatSearch] = useState('')
|
||||
const [allProviders, setAllProviders] = useState<Provider[]>([])
|
||||
const [userProviderPrefs, setUserProviderPrefs] = useState<ProviderPref[]>([])
|
||||
|
||||
const [form, setForm] = useState({ username: '', password: '', max_connections: 1, expiry_date: '', is_active: true, is_priority: false })
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/users/')
|
||||
setUsers(data)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const payload = { ...form, expiry_date: form.expiry_date || null }
|
||||
if (editUser) {
|
||||
await api.put(`/users/${editUser.id}`, payload)
|
||||
} else {
|
||||
await api.post('/users/', payload)
|
||||
}
|
||||
resetForm()
|
||||
load()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setForm({ username: '', password: '', max_connections: 1, expiry_date: '', is_active: true, is_priority: false })
|
||||
setShowForm(false)
|
||||
setEditUser(null)
|
||||
}
|
||||
|
||||
function startEdit(u: User) {
|
||||
setForm({ username: u.username, password: '', max_connections: u.max_connections, expiry_date: u.expiry_date?.split('T')[0] || '', is_active: u.is_active, is_priority: u.is_priority })
|
||||
setEditUser(u)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm('¿Eliminar usuario?')) return
|
||||
await api.delete(`/users/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function togglePriority(u: User) {
|
||||
await api.put(`/users/${u.id}`, { is_priority: !u.is_priority })
|
||||
load()
|
||||
}
|
||||
|
||||
async function openCatalog(u: User) {
|
||||
setCatalogUser(u)
|
||||
setCatSearch('')
|
||||
const [grouped, catalog, customCats, userCustomCatalog] = await Promise.all([
|
||||
api.get('/catalogs/grouped').then(r => r.data as GroupedCategory[]),
|
||||
api.get(`/users/${u.id}/catalog`).then(r => r.data as CatalogEntry[]),
|
||||
api.get('/custom-categories/').then(r => r.data as CustomCat[]),
|
||||
api.get(`/users/${u.id}/custom-catalog`).then(r => r.data as number[]),
|
||||
])
|
||||
setAllCategories(grouped)
|
||||
setSelectedCats(new Set(catalog.map((c: CatalogEntry) => c.category_id)))
|
||||
setAllCustomCats(customCats)
|
||||
setSelectedCustomCats(new Set(userCustomCatalog))
|
||||
}
|
||||
|
||||
function isGroupSelected(g: GroupedCategory): boolean {
|
||||
return g.category_ids.some(id => selectedCats.has(id))
|
||||
}
|
||||
|
||||
function toggleGroup(g: GroupedCategory, checked: boolean) {
|
||||
setSelectedCats(prev => {
|
||||
const n = new Set(prev)
|
||||
if (checked) {
|
||||
g.category_ids.forEach(id => n.add(id))
|
||||
} else {
|
||||
g.category_ids.forEach(id => n.delete(id))
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function selectAllFiltered(cats: GroupedCategory[]) {
|
||||
setSelectedCats(prev => {
|
||||
const n = new Set(prev)
|
||||
cats.forEach((g: GroupedCategory) => g.category_ids.forEach((id: number) => n.add(id)))
|
||||
return n
|
||||
})
|
||||
// Also select all filtered custom cats
|
||||
const filteredCC = allCustomCats.filter(c => !catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase()))
|
||||
setSelectedCustomCats(prev => {
|
||||
const n = new Set(prev)
|
||||
filteredCC.forEach(c => n.add(c.id))
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function selectNoneFiltered(cats: GroupedCategory[]) {
|
||||
setSelectedCats(prev => {
|
||||
const n = new Set(prev)
|
||||
cats.forEach((g: GroupedCategory) => g.category_ids.forEach((id: number) => n.delete(id)))
|
||||
return n
|
||||
})
|
||||
const filteredCC = allCustomCats.filter(c => !catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase()))
|
||||
setSelectedCustomCats(prev => {
|
||||
const n = new Set(prev)
|
||||
filteredCC.forEach(c => n.delete(c.id))
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
async function saveCatalog() {
|
||||
if (!catalogUser) return
|
||||
await Promise.all([
|
||||
api.post(`/users/${catalogUser.id}/catalog`, Array.from(selectedCats)),
|
||||
api.post(`/users/${catalogUser.id}/custom-catalog`, Array.from(selectedCustomCats)),
|
||||
])
|
||||
setCatalogUser(null)
|
||||
}
|
||||
|
||||
async function openProviderPrefs(u: User) {
|
||||
setProviderUser(u)
|
||||
const [providers, prefs] = await Promise.all([
|
||||
api.get('/providers/').then(r => r.data as Provider[]),
|
||||
api.get(`/users/${u.id}/providers`).then(r => r.data as ProviderPref[]),
|
||||
])
|
||||
setAllProviders(providers)
|
||||
setUserProviderPrefs(prefs)
|
||||
}
|
||||
|
||||
async function addProviderPref(providerId: number) {
|
||||
if (!providerUser) return
|
||||
await api.post(`/users/${providerUser.id}/providers`, { provider_account_id: providerId, priority: userProviderPrefs.length })
|
||||
const { data } = await api.get(`/users/${providerUser.id}/providers`)
|
||||
setUserProviderPrefs(data)
|
||||
}
|
||||
|
||||
async function removeProviderPref(providerId: number) {
|
||||
if (!providerUser) return
|
||||
await api.delete(`/users/${providerUser.id}/providers/${providerId}`)
|
||||
const { data } = await api.get(`/users/${providerUser.id}/providers`)
|
||||
setUserProviderPrefs(data)
|
||||
}
|
||||
|
||||
const prefProviderIds = new Set(userProviderPrefs.map(p => p.provider_account_id))
|
||||
|
||||
// Catalog modal filtered + grouped by type
|
||||
const filteredCats = allCategories.filter(c =>
|
||||
!catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase())
|
||||
)
|
||||
const filteredCustomCats = allCustomCats.filter(c =>
|
||||
!catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase())
|
||||
)
|
||||
const selectedGroupCount = allCategories.filter(g => isGroupSelected(g)).length + selectedCustomCats.size
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Usuarios</h1>
|
||||
<button onClick={() => setShowForm(v => !v)} className="flex items-center gap-2 bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||
<Plus size={16} /> Nuevo usuario
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={submit} className="bg-dark-800 border border-dark-700 rounded-xl p-5 space-y-3">
|
||||
<h3 className="font-semibold text-sm">{editUser ? 'Editar usuario' : 'Nuevo usuario'}</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Usuario</label>
|
||||
<input value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))} required disabled={!!editUser}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Contraseña {editUser && '(dejar vacío para no cambiar)'}</label>
|
||||
<input type="password" value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))} required={!editUser}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Max. conexiones simultáneas</label>
|
||||
<input type="number" min={1} value={form.max_connections} onChange={e => setForm(f => ({ ...f, max_connections: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Fecha expiración</label>
|
||||
<input type="date" value={form.expiry_date} onChange={e => setForm(f => ({ ...f, expiry_date: e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 col-span-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={form.is_active} onChange={e => setForm(f => ({ ...f, is_active: e.target.checked }))} />
|
||||
Activo
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={form.is_priority} onChange={e => setForm(f => ({ ...f, is_priority: e.target.checked }))} />
|
||||
<Star size={13} className="text-yellow-400" />
|
||||
Usuario prioritario
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar</button>
|
||||
<button type="button" onClick={resetForm} className="bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Catalog assignment modal */}
|
||||
{catalogUser && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 className="font-semibold">Catálogo de <span className="text-primary-400">{catalogUser.username}</span></h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{selectedGroupCount} de {allCategories.length + allCustomCats.length} categorías seleccionadas</p>
|
||||
</div>
|
||||
<button onClick={() => setCatalogUser(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Search + bulk actions */}
|
||||
<div className="p-3 border-b border-dark-700 shrink-0 space-y-2">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={catSearch}
|
||||
onChange={e => setCatSearch(e.target.value)}
|
||||
placeholder="Buscar categoría..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => selectAllFiltered(filteredCats)}
|
||||
className="text-xs px-3 py-1 rounded-lg bg-primary-900/40 hover:bg-primary-900/70 text-primary-300 border border-primary-800 transition-colors"
|
||||
>
|
||||
Seleccionar todo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectNoneFiltered(filteredCats)}
|
||||
className="text-xs px-3 py-1 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 border border-dark-600 transition-colors"
|
||||
>
|
||||
Ninguna
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-4">
|
||||
{(['live', 'movie', 'series'] as const).map(type => {
|
||||
const cats = filteredCats.filter(c => c.type === type)
|
||||
if (!cats.length) return null
|
||||
return (
|
||||
<div key={type}>
|
||||
<h3 className="text-xs text-gray-500 uppercase tracking-wider mb-2 flex items-center justify-between">
|
||||
<span>{TYPE_LABELS[type]}</span>
|
||||
<span className="normal-case text-gray-600">{cats.filter(g => isGroupSelected(g)).length}/{cats.length}</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{cats.map(cat => (
|
||||
<label
|
||||
key={cat.name}
|
||||
className={`flex items-center gap-2 text-sm p-2 rounded-lg cursor-pointer transition-colors ${isGroupSelected(cat) ? 'bg-primary-900/20 hover:bg-primary-900/30' : 'hover:bg-dark-700'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isGroupSelected(cat)}
|
||||
onChange={e => toggleGroup(cat, e.target.checked)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate">{cat.name}</span>
|
||||
{cat.provider_count > 0 && (
|
||||
<span className={`shrink-0 text-xs px-1.5 py-0.5 rounded ml-auto font-medium ${cat.provider_count > 1 ? 'bg-green-900/50 text-green-400' : 'bg-dark-600 text-gray-500'}`}>
|
||||
{cat.provider_count}p
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredCats.length === 0 && filteredCustomCats.length === 0 && (
|
||||
<div className="text-center text-gray-500 text-sm py-8">Sin resultados</div>
|
||||
)}
|
||||
|
||||
{/* Custom categories section */}
|
||||
{filteredCustomCats.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||
<Star size={11} className="text-yellow-400" />
|
||||
<span>Categorías personalizadas</span>
|
||||
<span className="normal-case text-gray-600">{filteredCustomCats.filter(c => selectedCustomCats.has(c.id)).length}/{filteredCustomCats.length}</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{filteredCustomCats.map(cat => (
|
||||
<label
|
||||
key={cat.id}
|
||||
className={`flex items-center gap-2 text-sm p-2 rounded-lg cursor-pointer transition-colors ${selectedCustomCats.has(cat.id) ? 'bg-yellow-900/20 hover:bg-yellow-900/30' : 'hover:bg-dark-700'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCustomCats.has(cat.id)}
|
||||
onChange={e => {
|
||||
setSelectedCustomCats(prev => {
|
||||
const n = new Set(prev)
|
||||
e.target.checked ? n.add(cat.id) : n.delete(cat.id)
|
||||
return n
|
||||
})
|
||||
}}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate">{cat.name}</span>
|
||||
<Star size={10} className="shrink-0 text-yellow-500 ml-auto" />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-dark-700 flex gap-2 justify-end shrink-0">
|
||||
<button onClick={() => setCatalogUser(null)} className="bg-dark-700 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
<button onClick={saveCatalog} className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar catálogo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider preferences modal */}
|
||||
{providerUser && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-md flex flex-col">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
|
||||
<h2 className="font-semibold">Proveedores preferidos — <span className="text-primary-400">{providerUser.username}</span></h2>
|
||||
<button onClick={() => setProviderUser(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<p className="text-xs text-gray-500 mb-3">Los proveedores marcados se intentan primero al asignar un slot. Si ninguno está disponible, se usa cualquiera con slot libre.</p>
|
||||
{allProviders.map(p => {
|
||||
const isPref = prefProviderIds.has(p.id)
|
||||
return (
|
||||
<div key={p.id} className={`flex items-center justify-between p-3 rounded-lg border ${isPref ? 'border-primary-700 bg-primary-900/20' : 'border-dark-600 bg-dark-700'}`}>
|
||||
<span className="text-sm">{p.name}</span>
|
||||
<button
|
||||
onClick={() => isPref ? removeProviderPref(p.id) : addProviderPref(p.id)}
|
||||
className={`text-xs px-3 py-1 rounded-lg font-medium transition-colors ${isPref ? 'bg-primary-600 hover:bg-red-700 text-white' : 'bg-dark-600 hover:bg-primary-700 text-gray-300 hover:text-white'}`}
|
||||
>
|
||||
{isPref ? 'Quitar' : 'Preferido'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{allProviders.length === 0 && (
|
||||
<div className="text-center text-gray-500 text-sm py-4">No hay proveedores configurados</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-dark-700 flex justify-end">
|
||||
<button onClick={() => setProviderUser(null)} className="bg-dark-700 px-4 py-2 rounded-lg text-sm">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-dark-800 border border-dark-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-dark-700 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Usuario</th>
|
||||
<th className="text-left px-4 py-3">Conexiones</th>
|
||||
<th className="text-left px-4 py-3">Expiración</th>
|
||||
<th className="text-left px-4 py-3">Estado</th>
|
||||
<th className="text-left px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="border-b border-dark-700 hover:bg-dark-700/30">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{u.is_priority && <Star size={12} className="text-yellow-400 shrink-0" />}
|
||||
<span className="font-mono">{u.username}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{u.max_connections}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{u.expiry_date ? new Date(u.expiry_date).toLocaleDateString('es-ES') : '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${u.is_active ? 'bg-green-900/40 text-green-400' : 'bg-red-900/40 text-red-400'}`}>
|
||||
{u.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => openCatalog(u)} title="Asignar catálogo" className="p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-blue-400">
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button onClick={() => openProviderPrefs(u)} title="Proveedores preferidos" className="p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-purple-400">
|
||||
<Server size={14} />
|
||||
</button>
|
||||
<button onClick={() => togglePriority(u)} title={u.is_priority ? 'Quitar prioridad' : 'Hacer prioritario'} className={`p-1.5 rounded hover:bg-dark-600 transition-colors ${u.is_priority ? 'text-yellow-400' : 'text-gray-400 hover:text-yellow-400'}`}>
|
||||
<Star size={14} />
|
||||
</button>
|
||||
<button onClick={() => startEdit(u)} title="Editar" className="p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-yellow-400">
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button onClick={() => del(u.id)} title="Eliminar" className="p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-red-400">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">No hay usuarios</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f4ff',
|
||||
500: '#3b5bdb',
|
||||
600: '#3451c7',
|
||||
700: '#2c44a8',
|
||||
900: '#1a2a6b',
|
||||
},
|
||||
dark: {
|
||||
900: '#0f172a',
|
||||
800: '#1e293b',
|
||||
700: '#334155',
|
||||
600: '#475569',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/panel/',
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user