import { useEffect, useMemo, useState } from 'react'; import type { AgentEvent, AgentView, LogEntry } from '@stream-control/shared'; import { api } from '../api'; import { formatTime } from '../format'; interface Props { agent: AgentView; /** Entrées reçues en direct depuis l'ouverture du dashboard, toutes VM confondues. */ liveLogs: LogEntry[]; onClose: () => void; } interface EventStyle { icon: string; tone: 'ok' | 'warn' | 'rec' | 'offline' | 'error' | 'info'; } /** * Pictogramme et couleur par type d'évènement. * * On s'en tient aux glyphes typographiques déjà employés sur les fiches (⏺ ⏸ ⛶ ⚙) * plutôt qu'à des émojis : même graisse, même alignement, lisible à 12 px. */ const EVENT_STYLES: Record = { 'agent.connected': { icon: '⇢', tone: 'ok' }, 'agent.disconnected': { icon: '⇠', tone: 'offline' }, 'agent.enrolled': { icon: '+', tone: 'ok' }, 'agent.updated': { icon: '⬆', tone: 'ok' }, 'obs.connected': { icon: '◉', tone: 'ok' }, 'obs.disconnected': { icon: '◌', tone: 'warn' }, 'record.started': { icon: '⏺', tone: 'rec' }, 'record.stopped': { icon: '■', tone: 'offline' }, 'record.paused': { icon: '⏸', tone: 'warn' }, 'record.resumed': { icon: '▶', tone: 'ok' }, 'record.split': { icon: '✂', tone: 'info' }, 'stream.started': { icon: '⇡', tone: 'rec' }, 'stream.stopped': { icon: '⇣', tone: 'offline' }, 'capture.started': { icon: '▣', tone: 'rec' }, 'capture.stopped': { icon: '□', tone: 'offline' }, 'watch.started': { icon: '⟳', tone: 'info' }, 'watch.private': { icon: '⊘', tone: 'warn' }, 'watch.public': { icon: '◉', tone: 'ok' }, 'watch.offline': { icon: '○', tone: 'offline' }, 'watch.failed': { icon: '!', tone: 'warn' }, 'fullscreen.restored': { icon: '⛶', tone: 'ok' }, 'fullscreen.failed': { icon: '⛶', tone: 'warn' }, 'browser.opened': { icon: '⊞', tone: 'info' }, 'browser.closed': { icon: '⊟', tone: 'offline' }, 'browser.imported': { icon: '⇄', tone: 'ok' }, 'browser.importFailed': { icon: '⇄', tone: 'warn' }, 'preset.applied': { icon: '≡', tone: 'ok' }, 'preset.failed': { icon: '≡', tone: 'warn' }, 'config.changed': { icon: '⚙', tone: 'info' }, 'command.failed': { icon: '✖', tone: 'error' }, }; const FILTERS = [ { id: 'all', label: 'Tout' }, { id: 'record', label: 'Enregistrement' }, { id: 'obs', label: 'OBS' }, { id: 'watch', label: 'Surveillance' }, { id: 'issues', label: 'Problèmes' }, ] as const; type FilterId = (typeof FILTERS)[number]['id']; /** Le préfixe de l'évènement porte la catégorie ; les entrées sans évènement restent dans « Tout ». */ const FILTER_PREFIXES: Record, string[]> = { record: ['record.', 'capture.', 'stream.'], obs: ['obs.', 'preset.', 'config.'], watch: ['watch.', 'fullscreen.', 'browser.'], }; function matches(entry: LogEntry, filter: FilterId): boolean { if (filter === 'all') return true; if (filter === 'issues') return entry.level === 'warn' || entry.level === 'error'; return FILTER_PREFIXES[filter].some((prefix) => entry.event?.startsWith(prefix)); } export function AgentHistory({ agent, liveLogs, onClose }: Props) { const [fetched, setFetched] = useState(null); const [error, setError] = useState(null); const [filter, setFilter] = useState('all'); useEffect(() => { let cancelled = false; setFetched(null); setError(null); api .agentLogs(agent.id) .then((result) => { if (!cancelled) setFetched(result.logs); }) .catch((err: unknown) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Historique indisponible'); }); return () => { cancelled = true; }; }, [agent.id]); /** * L'historique persistant et le flux temps réel se recouvrent : les entrées * arrivées pendant le chargement figurent dans les deux. L'identifiant de ligne * tranche, et l'ordre décroissant place le plus récent en tête. */ const entries = useMemo(() => { const byId = new Map(); for (const entry of fetched ?? []) byId.set(entry.id, entry); for (const entry of liveLogs) { if (entry.agentId === agent.id) byId.set(entry.id, entry); } return [...byId.values()].sort((a, b) => b.id - a.id); }, [agent.id, fetched, liveLogs]); const visible = entries.filter((entry) => matches(entry, filter)); const days = groupByDay(visible); return (
event.stopPropagation()}>

Historique · {agent.name}

{FILTERS.map((entry) => ( ))}
{visible.length} évènement(s)
{error &&

{error}

} {!error && fetched === null &&

Chargement…

} {!error && fetched !== null && visible.length === 0 && (

{entries.length === 0 ? "Aucun évènement pour cette VM. L'historique se remplit dès que l'agent se connecte." : 'Aucun évènement pour ce filtre.'}

)} {days.map(([day, dayEntries]) => (

{day}

{dayEntries.map((entry) => { const style = entry.event ? EVENT_STYLES[entry.event] : null; return (
{formatTime(entry.ts)} {style?.icon ?? '·'} {entry.message}
); })}
))}
Les entrées les plus anciennes sont purgées avec le journal global.
); } /** Regroupe par jour en conservant l'ordre décroissant reçu. */ function groupByDay(entries: LogEntry[]): Array<[string, LogEntry[]]> { const groups = new Map(); for (const entry of entries) { const day = dayLabel(entry.ts); const bucket = groups.get(day); if (bucket) bucket.push(entry); else groups.set(day, [entry]); } return [...groups.entries()]; } function dayLabel(ts: number): string { const date = new Date(ts); const today = new Date(); const yesterday = new Date(today.getTime() - 86_400_000); if (isSameDay(date, today)) return "Aujourd'hui"; if (isSameDay(date, yesterday)) return 'Hier'; return date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }); } function isSameDay(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); }