feat: alertas de picos, grafica de menciones por dia y estabilidad del traductor

- Backend: endpoint /api/entities/mentions (series por dia para N conceptos)
- Backend: tabla alertas + escaner de picos automatico + endpoints (list/read/scan)
- Frontend: pagina Evolucion con grafica SVG multi-concepto configurable
- Frontend: pagina Alertas con badge de notificaciones en la barra
- Traductor: memoria acotada (sub-lotes, batch 32, cuerpo max 12000 chars)
- Traductor: prioridad baja en CPU (cpu_shares 512) y tope de 2 nucleos x worker
- Escalado a 3 workers de traduccion (2x translator + translator-gpu)
This commit is contained in:
jlimolina 2026-08-11 04:18:37 +02:00
parent eff656adea
commit 3991237685
11 changed files with 988 additions and 28 deletions

View file

@ -11,6 +11,8 @@ import { Stats } from './pages/Stats'
import { Favorites } from './pages/Favorites'
import { Account } from './pages/Account'
import { Populares } from './pages/Populares'
import { Analisis } from './pages/Analisis'
import { Alertas } from './pages/Alertas'
import { AdminAliases } from './pages/AdminAliases'
import { AdminUsers } from './pages/AdminUsers'
import { AdminSettings } from './pages/AdminSettings'
@ -62,6 +64,8 @@ function App() {
<Route path="search" element={<Search />} />
<Route path="populares" element={<Populares />} />
<Route path="stats" element={<Stats />} />
<Route path="analisis" element={<Analisis />} />
<Route path="alertas" element={<Alertas />} />
<Route path="favorites" element={<Favorites />} />
<Route path="account" element={<Account />} />
<Route path="login" element={<Login />} />

View file

@ -0,0 +1,146 @@
import { useMemo, useState } from 'react'
const COLORS = ['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#0891b2', '#db2777', '#65a30d']
export interface ChartSeries {
valor: string
tipo?: string
count?: number
data: { fecha: string; count: number }[]
}
interface Props {
series: ChartSeries[]
height?: number
}
export function LineChart({ series, height = 320 }: Props) {
const [hover, setHover] = useState<number | null>(null)
const width = 1000
const padL = 46
const padR = 14
const padT = 16
const padB = 34
const plotW = width - padL - padR
const plotH = height - padT - padB
const days = series[0]?.data?.length ?? 0
const max = useMemo(() => {
let m = 0
for (const s of series) for (const p of s.data) if (p.count > m) m = p.count
return Math.max(1, m)
}, [series])
const xAt = (i: number) => padL + (days <= 1 ? 0 : (i / (days - 1)) * plotW)
const yAt = (v: number) => padT + plotH - (v / max) * plotH
const paths = series.map((s, si) => {
const d = s.data
.map((p, i) => `${i === 0 ? 'M' : 'L'}${xAt(i).toFixed(1)},${yAt(p.count).toFixed(1)}`)
.join(' ')
return { d, color: COLORS[si % COLORS.length], s }
})
const yTicks = useMemo(() => {
const ticks = max <= 4 ? [0, max] : []
if (max <= 4) return ticks
const step = Math.pow(10, Math.floor(Math.log10(max / 4)))
const norm = Math.ceil(max / step / 4)
const nice = step * norm * 4
const out: number[] = []
for (let v = 0; v <= nice; v += step * norm) out.push(v)
return out
}, [max])
const xLabelStep = days > 12 ? Math.ceil(days / 8) : 1
const xLabels: { i: number; label: string }[] = []
for (let i = 0; i < days; i += xLabelStep) {
if (days > 0 && i % xLabelStep === 0) {
xLabels.push({ i, label: series[0]?.data[i]?.fecha?.slice(5) || '' })
}
}
if (days > 0 && xLabels[xLabels.length - 1]?.i !== days - 1) {
xLabels.push({ i: days - 1, label: series[0]?.data[days - 1]?.fecha?.slice(5) || '' })
}
const handleMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (days === 0) return
const rect = e.currentTarget.getBoundingClientRect()
const x = ((e.clientX - rect.left) / rect.width) * width
const idx = Math.max(0, Math.min(days - 1, Math.round(((x - padL) / plotW) * (days - 1))))
setHover(idx)
}
const hoverX = hover !== null ? xAt(hover) : 0
return (
<div className="w-full overflow-x-auto">
<div className="flex flex-wrap gap-4 mb-3">
{paths.map(({ color, s }) => (
<span key={s.valor + color} className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span className="h-2.5 w-2.5 rounded-full inline-block" style={{ backgroundColor: color }} />
<span className="font-medium">{s.valor}</span>
<span className="text-gray-400">({s.count ?? 0})</span>
</span>
))}
</div>
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full min-w-[560px]"
onMouseMove={handleMove}
onMouseLeave={() => setHover(null)}
>
{yTicks.map((t) => (
<g key={t}>
<line x1={padL} x2={width - padR} y1={yAt(t)} y2={yAt(t)} stroke="#e5e7eb" strokeWidth={1} />
<text x={padL - 8} y={yAt(t) + 4} textAnchor="end" fontSize={11} fill="#9ca3af">
{t}
</text>
</g>
))}
{xLabels.map(({ i, label }) => (
<text key={i} x={xAt(i)} y={height - 12} textAnchor="middle" fontSize={11} fill="#9ca3af">
{label}
</text>
))}
{paths.map(({ d, color }) => (
<path key={color} d={d} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
))}
{paths.map(({ color, s }, si) =>
s.data.map((p, i) => (
<circle
key={`${si}-${i}`}
cx={xAt(i)}
cy={yAt(p.count)}
r={hover === i ? 4.5 : 2.5}
fill={color}
/>
)),
)}
{hover !== null && (
<g>
<line x1={hoverX} x2={hoverX} y1={padT} y2={height - padB} stroke="#cbd5e1" strokeWidth={1} strokeDasharray="3 3" />
{paths.map(({ color, s }) => (
<circle key={color + hover} cx={hoverX} cy={yAt(s.data[hover]?.count ?? 0)} r={5} fill={color} stroke="#fff" strokeWidth={2} />
))}
<g transform={`translate(${Math.min(hoverX + 12, width - 220)}, ${padT})`}>
<rect width={208} height={20 + paths.length * 18} rx={6} fill="#111827" opacity={0.92} />
<text x={10} y={16} fontSize={11} fill="#e5e7eb" fontWeight={600}>
{series[0]?.data[hover]?.fecha || ''}
</text>
{paths.map(({ color, s }, si) => (
<g key={color}>
<rect x={10} y={26 + si * 18} width={10} height={10} rx={2} fill={color} />
<text x={26} y={35 + si * 18} fontSize={11} fill="#d1d5db">
{s.valor}: {s.data[hover]?.count ?? 0}
</text>
</g>
))}
</g>
</g>
)}
</svg>
</div>
)
}

View file

@ -1,6 +1,7 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server } from 'lucide-react'
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server, TrendingUp, Bell } from 'lucide-react'
import { useEffect, useState } from 'react'
import { apiService } from '../../services/api'
export function Layout() {
const navigate = useNavigate()
@ -8,6 +9,7 @@ export function Layout() {
const [username, setUsername] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
const [showAdminMenu, setShowAdminMenu] = useState(false)
const [nuevasAlertas, setNuevasAlertas] = useState(0)
const checkAuth = () => {
const token = localStorage.getItem('token')
@ -37,6 +39,20 @@ export function Layout() {
return () => window.removeEventListener('storage', checkAuth)
}, [])
useEffect(() => {
const loadAlertas = async () => {
try {
const r = await apiService.getAlertas({ status: 'nueva', limit: 1 })
setNuevasAlertas(r.nuevas)
} catch {
setNuevasAlertas(0)
}
}
loadAlertas()
const id = setInterval(loadAlertas, 60000)
return () => clearInterval(id)
}, [])
const handleLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('user')
@ -72,6 +88,19 @@ export function Layout() {
<Flame className="h-4 w-4" />
Popular
</Link>
<Link to="/analisis" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
<TrendingUp className="h-4 w-4" />
Evolución
</Link>
<Link to="/alertas" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors relative">
<Bell className="h-4 w-4" />
Alertas
{nuevasAlertas > 0 && (
<span className="absolute -top-1.5 -right-3 bg-red-600 text-white text-[10px] font-bold min-w-[16px] h-4 px-1 rounded-full flex items-center justify-center">
{nuevasAlertas > 99 ? '99+' : nuevasAlertas}
</span>
)}
</Link>
<Link to="/stats" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
<BarChart3 className="h-4 w-4" />
Stats

View file

@ -0,0 +1,137 @@
import { useEffect, useState } from 'react'
import { apiService, Alerta } from '../services/api'
import { Bell, CheckCheck, ChevronDown, ChevronUp } from 'lucide-react'
const TIPO_LABEL: Record<string, string> = {
persona: 'Persona',
lugar: 'Lugar',
organizacion: 'Organización',
tema: 'Tema',
}
export function Alertas() {
const [alertas, setAlertas] = useState<Alerta[]>([])
const [loading, setLoading] = useState(true)
const [nuevas, setNuevas] = useState(0)
const [limit, setLimit] = useState(50)
const load = async () => {
setLoading(true)
try {
const r = await apiService.getAlertas({ limit })
setAlertas(r.alertas)
setNuevas(r.nuevas)
} catch {
setAlertas([])
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [limit])
const markRead = async (id: number) => {
await apiService.markAlertaRead(id)
load()
}
const markAll = async () => {
await apiService.markAllAlertasRead()
load()
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Bell className="h-7 w-7 text-primary-600" />
Alertas de actividad
</h1>
<button onClick={markAll} className="btn-secondary flex items-center gap-2" disabled={nuevas === 0}>
<CheckCheck className="h-4 w-4" />
Marcar todas como leídas
</button>
</div>
<p className="text-gray-500 dark:text-gray-400 -mt-4 mb-6">
Se genera un aviso cuando un concepto supera con claridad su actividad media de días anteriores.
{nuevas > 0 && (
<span className="ml-1 text-primary-600 font-semibold">Tienes {nuevas} sin leer.</span>
)}
</p>
{loading ? (
<div className="card p-16 flex justify-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary-600" />
</div>
) : alertas.length === 0 ? (
<div className="card p-16 text-center text-gray-500 dark:text-gray-400">
No hay alertas todavía. El sistema revisa cada dos horas los picos de actividad.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800 border-b">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Concepto</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Tipo</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Fecha</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900 dark:text-white">Noticias</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900 dark:text-white">Media previa</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900 dark:text-white">Ratio</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Estado</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{alertas.map((a) => (
<tr key={a.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{a.valor}</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
{TIPO_LABEL[a.tipo] || a.tipo}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{a.periodo}</td>
<td className="px-4 py-3 text-right text-sm text-gray-900 dark:text-white">{a.hits}</td>
<td className="px-4 py-3 text-right text-sm text-gray-500 dark:text-gray-400">{a.baseline.toFixed(1)}</td>
<td className="px-4 py-3 text-right">
<span className={`text-sm font-semibold ${a.ratio >= 10 ? 'text-red-600' : 'text-orange-600'}`}>
×{a.ratio.toFixed(1)}
</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-1 rounded ${a.status === 'nueva' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'}`}>
{a.status === 'nueva' ? 'Nueva' : 'Leída'}
</span>
</td>
<td className="px-4 py-3">
{a.status === 'nueva' ? (
<button onClick={() => markRead(a.id)} className="btn-secondary text-xs py-1 px-3">
Marcar leída
</button>
) : (
<span className="text-sm text-gray-300 dark:text-gray-600">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mt-6 flex items-center justify-end gap-2">
<button
onClick={() => setLimit(limit > 50 ? 50 : 200)}
className="btn-secondary flex items-center gap-2 text-sm"
>
{limit > 50 ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
Mostrar {limit > 50 ? 'menos' : 'más'} ({limit})
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,177 @@
import { useEffect, useState } from 'react'
import { apiService, EntitySuggestion } from '../services/api'
import { LineChart } from '../components/LineChart'
import { X, Search, TrendingUp } from 'lucide-react'
const TIPOS = [
{ value: 'persona', label: 'Personas' },
{ value: 'lugar', label: 'Lugares' },
{ value: 'organizacion', label: 'Organizaciones' },
{ value: 'tema', label: 'Temas' },
]
const DIAS = [7, 15, 30, 60, 90]
interface Selected {
valor: string
tipo: string
}
export function Analisis() {
const [tipo, setTipo] = useState('persona')
const [query, setQuery] = useState('')
const [results, setResults] = useState<EntitySuggestion[]>([])
const [selected, setSelected] = useState<Selected[]>([])
const [days, setDays] = useState(30)
const [series, setSeries] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [showResults, setShowResults] = useState(false)
useEffect(() => {
if (!query.trim()) {
setResults([])
return
}
const timer = setTimeout(() => {
apiService
.searchEntities({ tipo, q: query.trim(), per_page: 8 })
.then((r) => setResults(r.entities || []))
.catch(() => setResults([]))
}, 250)
return () => clearTimeout(timer)
}, [query, tipo])
useEffect(() => {
if (selected.length === 0) {
setSeries([])
return
}
setLoading(true)
apiService
.getEntityMentions({ values: selected.map((s) => s.valor), days })
.then((r) => setSeries(r.series))
.catch(() => setSeries([]))
.finally(() => setLoading(false))
}, [selected, days])
const add = (valor: string, t: string) => {
if (selected.some((s) => s.valor.toLowerCase() === valor.toLowerCase())) return
setSelected([...selected, { valor, tipo: t }])
setQuery('')
setResults([])
setShowResults(false)
}
const remove = (valor: string) => {
setSelected(selected.filter((s) => s.valor !== valor))
}
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<TrendingUp className="h-7 w-7 text-primary-600" />
Evolución de menciones
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1 mb-6">
Selecciona personas, lugares, organizaciones o temas y compara su actividad día a día.
</p>
<div className="card p-4 mb-6">
<div className="flex flex-col md:flex-row md:items-end gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tipo</label>
<select value={tipo} onChange={(e) => setTipo(e.target.value)} className="input w-auto">
{TIPOS.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="relative flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Buscar y añadir</label>
<div className="relative">
<Search className="h-4 w-4 absolute left-3 top-3 text-gray-400" />
<input
value={query}
onChange={(e) => {
setQuery(e.target.value)
setShowResults(true)
}}
onFocus={() => setShowResults(true)}
placeholder="Busca un concepto…"
className="input pl-9"
/>
</div>
{showResults && query.trim() && (
<ul className="absolute z-20 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-72 overflow-auto">
{results.length === 0 && (
<li className="px-4 py-2 text-sm text-gray-500">Sin resultados</li>
)}
{results.map((r) => (
<li key={r.valor + r.tipo}>
<button
onClick={() => add(r.valor, r.tipo)}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center justify-between gap-2"
>
<span className="text-gray-800 dark:text-gray-200">{r.valor}</span>
<span className="text-xs text-gray-400">{r.cnt} noticias</span>
</button>
</li>
))}
</ul>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ventana</label>
<div className="flex gap-1">
{DIAS.map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-3 py-2 rounded-md text-sm border transition-colors ${
days === d
? 'bg-primary-600 text-white border-primary-600'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{d}d
</button>
))}
</div>
</div>
</div>
{selected.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
{selected.map((s) => (
<span
key={s.valor}
className="inline-flex items-center gap-1.5 bg-primary-50 dark:bg-gray-700 text-primary-800 dark:text-gray-200 text-sm px-3 py-1 rounded-full"
>
{s.valor}
<button onClick={() => remove(s.valor)} className="hover:text-red-600" title="Quitar">
<X className="h-3.5 w-3.5" />
</button>
</span>
))}
</div>
)}
</div>
{selected.length === 0 ? (
<div className="card p-16 text-center text-gray-500 dark:text-gray-400">
Añade al menos un concepto para ver su evolución por día.
</div>
) : loading ? (
<div className="card p-16 flex justify-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary-600" />
</div>
) : (
<div className="card p-4">
<LineChart series={series} />
</div>
)}
</div>
)
}

View file

@ -95,6 +95,41 @@ export interface Country {
continente: string
}
export interface MentionPoint {
fecha: string
count: number
}
export interface MentionSeries {
valor: string
tipo: string
count: number
data: MentionPoint[]
}
export interface MentionsResponse {
days: number
series: MentionSeries[]
}
export interface EntitySuggestion {
valor: string
tipo: string
cnt: number
}
export interface Alerta {
id: number
valor: string
tipo: string
periodo: string
hits: number
baseline: number
ratio: number
status: string
created_at: string
}
export interface Stats {
total_news: number
total_feeds: number
@ -175,6 +210,31 @@ export const apiService = {
return data
},
getEntityMentions: async (params: { values: string[]; days?: number }): Promise<MentionsResponse> => {
const { data } = await api.get('/entities/mentions', {
params: { values: params.values.join(','), days: params.days || 30 },
})
return data
},
searchEntities: async (params: { tipo: string; q?: string; page?: number; per_page?: number }): Promise<{ entities: EntitySuggestion[]; total: number }> => {
const { data } = await api.get('/entities', { params })
return data
},
getAlertas: async (params?: { status?: string; limit?: number }): Promise<{ alertas: Alerta[]; total: number; nuevas: number }> => {
const { data } = await api.get('/alerts', { params })
return data
},
markAlertaRead: async (id: number): Promise<void> => {
await api.post(`/alerts/${id}/read`)
},
markAllAlertasRead: async (): Promise<void> => {
await api.post('/alerts/read-all')
},
login: async (email: string, password: string): Promise<{ token: string; user: any }> => {
const { data } = await api.post('/auth/login', { email, password })
return data