diff --git a/README.md b/README.md index 64579e3..24349ed 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`. - Commandes unitaires ou groupées : démarrer/arrêter/mettre en pause/découper un enregistrement, démarrer/arrêter un stream, changer de scène, de profil, de collection, changer le dossier d'enregistrement. -- Journal d'évènements horodaté, persistant et diffusé en direct. +- Journal d'évènements horodaté, persistant et diffusé en direct, plus un + [historique par VM](#historique-par-vm) filtrable (🕘 sur la fiche de l'agent). - Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur). - [Pause automatique pendant les shows privés](#pause-automatique-pendant-les-shows-privés) (Stripchat), avec reprise et rappel du plein écran au retour du flux public. @@ -212,6 +213,28 @@ Un preset trop lourd pour la VM fait chuter les images par seconde : la qualité baisse alors malgré un meilleur CRF. Après un changement, surveille « FPS » et « Frames perdues » sur la fiche de l'agent. +## Historique par VM + +Le journal en bas de page mélange toutes les machines. Le bouton 🕘 de la fiche d'un agent +ouvre son historique à lui : mêmes entrées, filtrées sur cette VM, groupées par jour et du +plus récent au plus ancien. + +Chaque entrée porte un **type d'évènement** — `record.paused`, `obs.disconnected`, +`fullscreen.restored`, `preset.applied`… — qui lui donne son pictogramme et sa couleur, et +qui alimente les filtres : Enregistrement, OBS, Surveillance, Problèmes. + +Ce champ est facultatif dans le protocole. Une entrée écrite avant cette version, ou +envoyée par un agent qui n'a pas encore été mis à jour, s'affiche sans pictogramme plutôt +que de disparaître. + +Les pauses détectées valent d'être soulignées : elles proviennent de l'évènement +`RecordStateChanged` d'OBS, pas de la commande envoyée. L'historique montre donc aussi +les pauses déclenchées depuis l'interface d'OBS sur la VM, que le dashboard n'aurait +aucun autre moyen de connaître. + +La rétention est celle du journal global (`LOG_RETENTION`) : les entrées les plus +anciennes sont purgées, toutes VM confondues. + ## API HTTP Toutes les routes hors `/api/login` exigent `Authorization: Bearer `. @@ -226,7 +249,8 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer undefined); } - log('info', `${ids.length} fenêtre(s) « ${match} » fermée(s)`); + log('info', `${ids.length} fenêtre(s) « ${match} » fermée(s)`, 'browser.closed'); return { closed: ids.length }; } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 31ea019..0d72684 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import { WebSocket } from 'ws'; import type { AgentAction, + AgentEvent, AgentStatus, AgentToServer, LogLevel, @@ -80,14 +81,23 @@ function send(message: AgentToServer): void { } } -function report(level: LogLevel, message: string): void { +/** + * Journalise localement et vers le serveur. `event` classe l'entrée dans + * l'historique de la VM ; l'omettre reste correct pour ce qui n'est que du + * bavardage de progression. + */ +function report(level: LogLevel, message: string, event?: AgentEvent): void { const prefix = level === 'error' ? '✖' : level === 'warn' ? '!' : '·'; console.log(`${prefix} ${message}`); - send({ type: 'log', level, message, ts: Date.now() }); + send({ type: 'log', level, message, ts: Date.now(), event }); } -obs.on('log', (level: LogLevel, message: string) => report(level, message)); -watcher.on('log', (level: LogLevel, message: string) => report(level, message)); +obs.on('log', (level: LogLevel, message: string, event?: AgentEvent) => + report(level, message, event), +); +watcher.on('log', (level: LogLevel, message: string, event?: AgentEvent) => + report(level, message, event), +); function connect(): void { if (shuttingDown) return; @@ -194,7 +204,7 @@ async function handleServerMessage(message: ServerToAgent): Promise { } catch (err) { const text = err instanceof Error ? err.message : String(err); send({ type: 'result', requestId: message.requestId, ok: false, error: text }); - report('error', `Échec de « ${message.action} » : ${text}`); + report('error', `Échec de « ${message.action} » : ${text}`, 'command.failed'); } break; } @@ -238,13 +248,13 @@ async function startCapture(params: Record): Promise { key: fullscreen.key, windowMatch: fullscreen.windowMatch, }).catch((err: Error) => { - report('warn', `Plein écran impossible : ${err.message}`); + report('warn', `Plein écran impossible : ${err.message}`, 'fullscreen.failed'); return `échec : ${err.message}`; }); } await obs.execute('record.start'); - report('info', `Capture démarrée pour ${url}`); + report('info', `Capture démarrée pour ${url}`, 'capture.started'); return { opened, fullscreen: fullscreenResult, recording: true }; } @@ -256,12 +266,13 @@ async function stopCapture(): Promise { if (browserSettings.enabled && browserSettings.closeOnStop) { closed = await closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report).catch( (err: Error) => { - report('warn', `Fermeture de la fenêtre impossible : ${err.message}`); + report('warn', `Fermeture de la fenêtre impossible : ${err.message}`, 'command.failed'); return `échec : ${err.message}`; }, ); } + report('info', 'Capture arrêtée', 'capture.stopped'); return { ...(result as object), window: closed }; } @@ -290,7 +301,7 @@ async function runAction(action: AgentAction, params: Record): typeof params.url === 'string' && params.url ? params.url : config.packageUrl, { recordState: () => obs.recordState(), - log: (level, message) => report(level, message), + log: (level, message, event) => report(level, message, event), }, ); default: @@ -338,9 +349,17 @@ async function drainPresetApply(): Promise { presetPending = false; try { - report('info', describePreset(await obs.applyRecordingPreset(recordingSettings))); + report( + 'info', + describePreset(await obs.applyRecordingPreset(recordingSettings)), + 'preset.applied', + ); } catch (err) { - report('warn', `Preset non appliqué : ${err instanceof Error ? err.message : String(err)}`); + report( + 'warn', + `Preset non appliqué : ${err instanceof Error ? err.message : String(err)}`, + 'preset.failed', + ); } } @@ -355,7 +374,7 @@ async function applyPresetNow(params: Record): Promise = { + STARTED: { event: 'record.started', label: 'Enregistrement démarré' }, + STOPPED: { event: 'record.stopped', label: 'Enregistrement arrêté' }, + PAUSED: { event: 'record.paused', label: 'Enregistrement mis en pause' }, + RESUMED: { event: 'record.resumed', label: 'Enregistrement repris' }, +}; + +const STREAM_STATES: Record = { + STARTED: { event: 'stream.started', label: 'Diffusion démarrée' }, + STOPPED: { event: 'stream.stopped', label: 'Diffusion arrêtée' }, +}; + /** * Actions relevant d'OBS. `watch.*`, `hotkey.*`, `preset.apply` et * `agent.update` sont traitées en amont par l'agent : elles ne se réduisent pas @@ -115,7 +129,12 @@ export class ObsController extends EventEmitter { this.cachedVersion = undefined; if (wasConnected) { this.lastError = err instanceof Error ? err.message : undefined; - this.emit('log', 'warn', `Session OBS fermée${this.lastError ? ` : ${this.lastError}` : ''}`); + this.emit( + 'log', + 'warn', + `Session OBS fermée${this.lastError ? ` : ${this.lastError}` : ''}`, + 'obs.disconnected', + ); } this.scheduleReconnect(); }); @@ -124,11 +143,17 @@ export class ObsController extends EventEmitter { if (typeof event.outputPath === 'string' && event.outputPath) { this.lastRecordingPath = event.outputPath; } - this.emit('log', 'info', `Enregistrement : ${String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '')}`); + // La source fait autorité : elle capte aussi les pauses déclenchées depuis + // l'interface d'OBS, que le dashboard ne verrait pas autrement. + const raw = String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', ''); + const known = RECORD_STATES[raw]; + this.emit('log', 'info', known?.label ?? `Enregistrement : ${raw}`, known?.event); }); this.obs.on('StreamStateChanged', (event) => { - this.emit('log', 'info', `Diffusion : ${String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '')}`); + const raw = String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', ''); + const known = STREAM_STATES[raw]; + this.emit('log', 'info', known?.label ?? `Diffusion : ${raw}`, known?.event); }); } @@ -143,7 +168,7 @@ export class ObsController extends EventEmitter { settings.password !== this.settings.password; this.settings = settings; if (changed && this.connected) { - this.emit('log', 'info', 'Paramètres OBS modifiés, reconnexion'); + this.emit('log', 'info', 'Paramètres OBS modifiés, reconnexion', 'config.changed'); void this.reconnect(); } } @@ -169,7 +194,12 @@ export class ObsController extends EventEmitter { this.connected = true; this.lastError = undefined; this.cachedVersion = info.obsWebSocketVersion; - this.emit('log', 'info', `Connecté à OBS ${url} (obs-websocket ${info.obsWebSocketVersion})`); + this.emit( + 'log', + 'info', + `Connecté à OBS ${url} (obs-websocket ${info.obsWebSocketVersion})`, + 'obs.connected', + ); } catch (err) { this.connected = false; this.lastError = describeConnectionError(err); @@ -189,7 +219,7 @@ export class ObsController extends EventEmitter { this.clearReconnect(); if (this.connected) await this.obs.disconnect(); this.connected = false; - this.emit('log', 'info', 'Déconnecté d\'OBS'); + this.emit('log', 'info', 'Déconnecté d\'OBS', 'obs.disconnected'); } private async reconnect(): Promise { diff --git a/packages/agent/src/updater.ts b/packages/agent/src/updater.ts index 60685bb..f37160e 100644 --- a/packages/agent/src/updater.ts +++ b/packages/agent/src/updater.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { promisify } from 'node:util'; +import type { AgentEvent } from '@stream-control/shared'; const run = promisify(execFile); @@ -40,7 +41,7 @@ export function currentBuildId(): string | undefined { export interface UpdateDeps { /** État d'enregistrement : on ne coupe jamais une capture en cours. */ recordState(): Promise<{ active: boolean; paused: boolean }>; - log(level: 'info' | 'warn' | 'error', message: string): void; + log(level: 'info' | 'warn' | 'error', message: string, event?: AgentEvent): void; } /** @@ -127,6 +128,7 @@ export async function selfUpdate( deps.log( 'info', `Binaire remplacé : ${previousBuildId} → ${newBuildId}. Redémarrage par le superviseur…`, + 'agent.updated', ); // 6. Sortie différée : laisse le temps au résultat de partir vers le serveur. diff --git a/packages/agent/src/watcher.ts b/packages/agent/src/watcher.ts index c8342c1..3c28c74 100644 --- a/packages/agent/src/watcher.ts +++ b/packages/agent/src/watcher.ts @@ -1,5 +1,11 @@ import { EventEmitter } from 'node:events'; -import type { LogLevel, StreamState, WatchSettings, WatchState } from '@stream-control/shared'; +import type { + AgentEvent, + LogLevel, + StreamState, + WatchSettings, + WatchState, +} from '@stream-control/shared'; import { emptyWatchState, fetchStripchatStatus } from '@stream-control/shared'; import { sendHotkey } from './hotkey.ts'; @@ -96,6 +102,7 @@ export class StreamWatcher extends EventEmitter { `Surveillance de « ${this.settings.username} » (${this.settings.provider}) toutes les ${ this.settings.pollIntervalMs / 1000 } s`, + 'watch.started', ); void this.tick(); this.timer = setInterval(() => void this.tick(), this.settings.pollIntervalMs); @@ -131,7 +138,12 @@ export class StreamWatcher extends EventEmitter { this.state.lastCheckedAt = Date.now(); // Sonde en échec : on ne met surtout pas l'enregistrement en pause. if (this.state.lastError !== message) { - this.emit('log', 'warn', `Sonde « ${this.settings.username} » en échec : ${message}`); + this.emit( + 'log', + 'warn', + `Sonde « ${this.settings.username} » en échec : ${message}`, + 'watch.failed', + ); } this.state.lastError = message; } finally { @@ -149,6 +161,7 @@ export class StreamWatcher extends EventEmitter { 'log', 'info', `Stream « ${this.settings.username} » : ${label(previous)} → ${label(next)} (${this.state.rawStatus})`, + TRANSITIONS[next], ); } @@ -174,12 +187,18 @@ export class StreamWatcher extends EventEmitter { if (!record.active || record.paused) return; await this.actions.pauseRecording(); this.state.autoPaused = true; - this.emit('log', 'info', 'Show privé détecté : enregistrement mis en pause'); + this.emit( + 'log', + 'info', + 'Show privé détecté : enregistrement mis en pause', + 'record.paused', + ); } catch (err) { this.emit( 'log', 'error', `Mise en pause automatique impossible : ${err instanceof Error ? err.message : String(err)}`, + 'command.failed', ); } } @@ -190,13 +209,14 @@ export class StreamWatcher extends EventEmitter { const record = await this.actions.recordState(); if (record.active && record.paused) { await this.actions.resumeRecording(); - this.emit('log', 'info', 'Flux public rétabli : enregistrement repris'); + this.emit('log', 'info', 'Flux public rétabli : enregistrement repris', 'record.resumed'); } } catch (err) { this.emit( 'log', 'error', `Reprise automatique impossible : ${err instanceof Error ? err.message : String(err)}`, + 'command.failed', ); } finally { this.state.autoPaused = false; @@ -229,16 +249,25 @@ export class StreamWatcher extends EventEmitter { 'log', 'info', `Plein écran rappelé : touche « ${key} » envoyée à « ${result.window ?? windowMatch} »`, + 'fullscreen.restored', ); return result; } catch (err) { const message = err instanceof Error ? err.message : String(err); - this.emit('log', 'warn', `Rappel du plein écran impossible : ${message}`); + this.emit('log', 'warn', `Rappel du plein écran impossible : ${message}`, 'fullscreen.failed'); throw err; } } } +/** Évènement correspondant à l'état atteint ; `unknown` n'en produit aucun. */ +const TRANSITIONS: Record = { + public: 'watch.public', + private: 'watch.private', + offline: 'watch.offline', + unknown: undefined, +}; + function label(state: StreamState): string { switch (state) { case 'public': diff --git a/packages/server/src/agentGateway.ts b/packages/server/src/agentGateway.ts index d2d321f..89ea150 100644 --- a/packages/server/src/agentGateway.ts +++ b/packages/server/src/agentGateway.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { IncomingMessage } from 'node:http'; import type { RawData, WebSocket } from 'ws'; import type { AgentToServer, ServerToAgent } from '@stream-control/shared'; -import { PROTOCOL_VERSION, detectPlatform, safeJsonParse } from '@stream-control/shared'; +import { PROTOCOL_VERSION, detectPlatform, isAgentEvent, safeJsonParse } from '@stream-control/shared'; import { config } from './config.ts'; import { extractBearer, generateToken, hashToken, safeEqual } from './auth.ts'; import { agentsRepo, type AgentRecord } from './db.ts'; @@ -88,7 +88,13 @@ export function handleAgentConnection( platform, agentVersion: message.agentVersion, }); - hub.log(record.id, 'info', `Nouvel agent enrôlé depuis ${remoteAddress}`); + hub.log( + record.id, + 'info', + `Nouvel agent enrôlé depuis ${remoteAddress}`, + Date.now(), + 'agent.enrolled', + ); } else { record = auth.record; agentsRepo.updateIdentity(record.id, { @@ -101,7 +107,13 @@ export function handleAgentConnection( agentId = record.id; hub.attachAgent(record.id, socket); - hub.log(record.id, 'info', `Agent connecté (${platform}, ${remoteAddress})`); + hub.log( + record.id, + 'info', + `Agent connecté (${platform}, ${remoteAddress})`, + Date.now(), + 'agent.connected', + ); send(socket, { type: 'welcome', @@ -132,7 +144,13 @@ export function handleAgentConnection( } case 'log': { - hub.log(agentId, message.level, message.message, message.ts || Date.now()); + hub.log( + agentId, + message.level, + message.message, + message.ts || Date.now(), + isAgentEvent(message.event) ? message.event : null, + ); break; } @@ -147,7 +165,7 @@ export function handleAgentConnection( clearTimeout(helloTimer); if (agentId) { hub.detachAgent(agentId, socket); - hub.log(agentId, 'info', 'Agent déconnecté'); + hub.log(agentId, 'info', 'Agent déconnecté', Date.now(), 'agent.disconnected'); } }); diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 507c253..26bb80e 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -100,6 +100,7 @@ api.patch('/agents/:id', (req, res) => { if (updated) { hub.pushConfig(updated); hub.publishAgent(updated.id); + hub.log(updated.id, 'info', 'Configuration modifiée depuis le dashboard', Date.now(), 'config.changed'); res.json({ agent: hub.view(updated) }); } else { res.status(500).json({ error: 'Mise à jour impossible' }); @@ -155,7 +156,13 @@ api.post('/agents/:id/command', async (req, res) => { res.json({ ok: true, data }); } catch (err) { const messageText = err instanceof Error ? err.message : String(err); - hub.log(record.id, 'error', `Commande « ${action} » en échec : ${messageText}`); + hub.log( + record.id, + 'error', + `Commande « ${action} » en échec : ${messageText}`, + Date.now(), + 'command.failed', + ); res.status(502).json({ ok: false, error: messageText }); } }); @@ -369,6 +376,17 @@ api.post('/watchlist/:id/stop', async (req, res) => { // --- Divers ----------------------------------------------------------------- +/** Historique d'une VM : les mêmes entrées que le journal, filtrées et bornées. */ +api.get('/agents/:id/logs', (req, res) => { + const record = agentsRepo.get(req.params.id); + if (!record) { + res.status(404).json({ error: 'Agent introuvable' }); + return; + } + const limit = Math.min(Number.parseInt(String(req.query.limit ?? '300'), 10) || 300, 1000); + res.json({ logs: logsRepo.forAgent(record.id, limit) }); +}); + api.get('/logs', (req, res) => { const limit = Math.min(Number.parseInt(String(req.query.limit ?? '200'), 10) || 200, 1000); res.json({ logs: logsRepo.recent(limit) }); diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 4413931..ec5e259 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import type { + AgentEvent, LogEntry, LogLevel, ObsSettings, @@ -17,6 +18,7 @@ import { DEFAULT_OBS_SETTINGS, DEFAULT_RECORDING_SETTINGS, DEFAULT_WATCH_SETTINGS, + isAgentEvent, normalizeBrowserSettings, normalizeRecordingSettings, normalizeWatchSettings, @@ -96,6 +98,10 @@ addColumnIfMissing('agents', 'browser_json', 'TEXT'); // Preset d'enregistrement : réglable VM par VM, absent = aucune intervention // de l'agent sur les réglages d'OBS. addColumnIfMissing('agents', 'recording_json', 'TEXT'); +// Nature de l'évènement journalisé : classe l'entrée dans l'historique d'une VM. +// Nul sur les entrées écrites avant cette colonne, et sur celles des agents non +// mis à jour — l'interface s'en accommode. +addColumnIfMissing('logs', 'event', 'TEXT'); // Enrichissement des profils surveillés : photo et historique de diffusion. addColumnIfMissing('watch_targets', 'avatar_url', 'TEXT'); @@ -194,12 +200,20 @@ const stmts = { rotateToken: db.prepare('UPDATE agents SET token_hash = ? WHERE id = ?'), deleteAgent: db.prepare('DELETE FROM agents WHERE id = ?'), - insertLog: db.prepare('INSERT INTO logs (agent_id, level, message, ts) VALUES (?, ?, ?, ?)'), + insertLog: db.prepare( + 'INSERT INTO logs (agent_id, level, message, ts, event) VALUES (?, ?, ?, ?, ?)', + ), recentLogs: db.prepare(` - SELECT l.id, l.agent_id, l.level, l.message, l.ts, a.name AS agent_name + SELECT l.id, l.agent_id, l.level, l.message, l.ts, l.event, a.name AS agent_name FROM logs l LEFT JOIN agents a ON a.id = l.agent_id ORDER BY l.id DESC LIMIT ? `), + agentLogs: db.prepare(` + SELECT l.id, l.agent_id, l.level, l.message, l.ts, l.event, a.name AS agent_name + FROM logs l LEFT JOIN agents a ON a.id = l.agent_id + WHERE l.agent_id = ? + ORDER BY l.id DESC LIMIT ? + `), pruneLogs: db.prepare(` DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT ?) `), @@ -466,11 +480,30 @@ interface LogRow { level: string; message: string; ts: number; + event: string | null; +} + +function toLogEntry(row: LogRow): LogEntry { + return { + id: Number(row.id), + agentId: row.agent_id, + agentName: row.agent_name, + level: row.level as LogLevel, + message: row.message, + ts: Number(row.ts), + event: isAgentEvent(row.event) ? row.event : null, + }; } export const logsRepo = { - append(agentId: string | null, level: LogLevel, message: string, ts = Date.now()): LogEntry { - const info = stmts.insertLog.run(agentId, level, message, ts); + append( + agentId: string | null, + level: LogLevel, + message: string, + ts = Date.now(), + event: AgentEvent | null = null, + ): LogEntry { + const info = stmts.insertLog.run(agentId, level, message, ts, event); if (Math.random() < 0.02) stmts.pruneLogs.run(config.logRetention); const agent = agentId ? agentsRepo.get(agentId) : null; return { @@ -480,20 +513,18 @@ export const logsRepo = { level, message, ts, + event, }; }, recent(limit = 200): LogEntry[] { const rows = stmts.recentLogs.all(limit) as unknown as LogRow[]; - return rows - .map((row) => ({ - id: Number(row.id), - agentId: row.agent_id, - agentName: row.agent_name, - level: row.level as LogLevel, - message: row.message, - ts: Number(row.ts), - })) - .reverse(); + return rows.map(toLogEntry).reverse(); + }, + + /** Historique d'une VM, du plus ancien au plus récent. */ + forAgent(agentId: string, limit = 300): LogEntry[] { + const rows = stmts.agentLogs.all(agentId, limit) as unknown as LogRow[]; + return rows.map(toLogEntry).reverse(); }, }; diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts index 804488a..0e09401 100644 --- a/packages/server/src/hub.ts +++ b/packages/server/src/hub.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { WebSocket } from 'ws'; import type { AgentAction, + AgentEvent, AgentStatus, AgentView, LogEntry, @@ -234,8 +235,14 @@ class Hub { } /** Journalise un évènement : persistance + diffusion temps réel. */ - log(agentId: string | null, level: LogLevel, message: string, ts = Date.now()): LogEntry { - const entry = logsRepo.append(agentId, level, message, ts); + log( + agentId: string | null, + level: LogLevel, + message: string, + ts = Date.now(), + event: AgentEvent | null = null, + ): LogEntry { + const entry = logsRepo.append(agentId, level, message, ts, event); this.broadcast({ type: 'log', entry }); if (level === 'error' || level === 'warn') { console.warn(`[${level}] ${entry.agentName ?? 'serveur'} — ${message}`); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e48585a..e4fdaed 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -521,6 +521,54 @@ export function emptyStatus(): AgentStatus { export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +/** + * Nature d'un évènement journalisé. + * + * Le message reste du texte libre, destiné à être lu ; ce champ le classe, pour + * que l'historique d'une VM puisse être filtré et illustré sans avoir à faire + * de l'analyse de chaîne. Il est facultatif : un agent d'une version antérieure + * n'en envoie pas, et son entrée s'affiche simplement sans pictogramme. + * + * Le préfixe porte la catégorie — l'interface s'en sert pour ses filtres, donc + * un nouvel évènement doit réutiliser un préfixe existant quand c'est possible. + */ +export const AGENT_EVENTS = [ + 'agent.connected', + 'agent.disconnected', + 'agent.enrolled', + 'agent.updated', + 'obs.connected', + 'obs.disconnected', + 'record.started', + 'record.stopped', + 'record.paused', + 'record.resumed', + 'record.split', + 'stream.started', + 'stream.stopped', + 'capture.started', + 'capture.stopped', + 'watch.started', + 'watch.private', + 'watch.public', + 'watch.offline', + 'watch.failed', + 'fullscreen.restored', + 'fullscreen.failed', + 'browser.opened', + 'browser.closed', + 'preset.applied', + 'preset.failed', + 'config.changed', + 'command.failed', +] as const; + +export type AgentEvent = (typeof AGENT_EVENTS)[number]; + +export function isAgentEvent(value: unknown): value is AgentEvent { + return typeof value === 'string' && (AGENT_EVENTS as readonly string[]).includes(value); +} + export interface HelloMessage { type: 'hello'; protocol: number; @@ -550,6 +598,7 @@ export interface LogMessage { level: LogLevel; message: string; ts: number; + event?: AgentEvent; } export interface PongMessage { @@ -635,6 +684,8 @@ export interface LogEntry { level: LogLevel; message: string; ts: number; + /** Absent sur les entrées anciennes et sur celles des agents non mis à jour. */ + event: AgentEvent | null; } /** diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 5d76a5f..d653372 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -5,6 +5,7 @@ import { useRealtime } from './useRealtime'; import { Login } from './components/Login'; import { AgentCard } from './components/AgentCard'; import { AgentSettings } from './components/AgentSettings'; +import { AgentHistory } from './components/AgentHistory'; import { LogPanel } from './components/LogPanel'; import { StreamersPage } from './components/StreamersPage'; import { useHashRoute } from './useHashRoute'; @@ -18,6 +19,7 @@ export function App() { const [authenticated, setAuthenticated] = useState(() => Boolean(getToken())); const [selection, setSelection] = useState>(new Set()); const [settingsFor, setSettingsFor] = useState(null); + const [historyFor, setHistoryFor] = useState(null); const [toast, setToast] = useState(null); const [newAgentToken, setNewAgentToken] = useState<{ name: string; token: string } | null>(null); @@ -78,6 +80,10 @@ export function App() { () => (settingsFor ? (agents.find((agent) => agent.id === settingsFor.id) ?? null) : null), [agents, settingsFor], ); + const historyAgent = useMemo( + () => (historyFor ? (agents.find((agent) => agent.id === historyFor.id) ?? null) : null), + [agents, historyFor], + ); const online = agents.filter((agent) => agent.online); const recording = agents.filter((agent) => agent.status.recording); @@ -241,6 +247,7 @@ export function App() { onToggleSelect={toggleSelect} onCommand={runCommand} onOpenSettings={setSettingsFor} + onOpenHistory={setHistoryFor} /> ))} @@ -258,6 +265,14 @@ export function App() { /> )} + {historyAgent && ( + setHistoryFor(null)} + /> + )} + {toast &&
{toast.message}
} ); diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index 3e6ba9f..c583261 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -66,6 +66,10 @@ export const api = { logs: (limit = 200) => request<{ logs: LogEntry[] }>(`/logs?limit=${limit}`), + /** Historique d'une VM, ordre chronologique. */ + agentLogs: (id: string, limit = 300) => + request<{ logs: LogEntry[] }>(`/agents/${id}/logs?limit=${limit}`), + createAgent: (body: { name: string; notes?: string }) => request<{ agent: AgentView; token: string }>('/agents', { method: 'POST', diff --git a/packages/web/src/components/AgentCard.tsx b/packages/web/src/components/AgentCard.tsx index 7ede8fb..db9f08f 100644 --- a/packages/web/src/components/AgentCard.tsx +++ b/packages/web/src/components/AgentCard.tsx @@ -9,9 +9,17 @@ interface Props { onToggleSelect: (id: string) => void; onCommand: (id: string, action: AgentAction, params?: Record) => Promise; onOpenSettings: (agent: AgentView) => void; + onOpenHistory: (agent: AgentView) => void; } -export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSettings }: Props) { +export function AgentCard({ + agent, + selected, + onToggleSelect, + onCommand, + onOpenSettings, + onOpenHistory, +}: Props) { const [pending, setPending] = useState(null); const { status } = agent; @@ -67,6 +75,9 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe )} {state.label} + diff --git a/packages/web/src/components/AgentHistory.tsx b/packages/web/src/components/AgentHistory.tsx new file mode 100644 index 0000000..25191ad --- /dev/null +++ b/packages/web/src/components/AgentHistory.tsx @@ -0,0 +1,216 @@ +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' }, + '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() + ); +} diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 913d6b0..2790a44 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -393,6 +393,109 @@ fieldset.group { text-decoration: underline; } +/* --- Historique par VM --- */ + +/* + * La fiche standard défile d'un bloc ; ici seul le corps doit défiler, pour que + * les filtres et les intitulés de jour restent visibles sur un long historique. + */ +.modal.modal-tall { + display: flex; + flex-direction: column; + width: min(680px, 100%); + height: min(760px, 90vh); + overflow: hidden; +} +.modal.modal-tall .modal-body { + flex: 1; + overflow-y: auto; +} + +.history-filters { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 10px 16px; + border-bottom: 1px solid var(--border); +} + +.chip { + padding: 4px 10px; + font-size: 12px; + border-radius: 999px; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + cursor: pointer; +} +.chip:hover { + color: var(--text); +} +.chip.active { + background: var(--panel-2); + border-color: var(--accent); + color: var(--text); +} + +.history-body { + gap: 16px; +} + +.history-day-label { + position: sticky; + top: 0; + margin: 0 0 4px; + padding: 4px 0; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + background: var(--panel); + border-bottom: 1px solid var(--border); +} + +.history-line { + display: grid; + grid-template-columns: 64px 20px 1fr; + align-items: baseline; + gap: 8px; + padding: 3px 0; + font-size: 13px; +} +.history-line:hover { + background: var(--panel-2); +} + +.history-icon { + text-align: center; + font-size: 12px; +} +.history-icon.ok { + color: #86efac; +} +.history-icon.warn { + color: #fcd34d; +} +.history-icon.rec { + color: #fca5a5; +} +.history-icon.error { + color: var(--rec); +} +.history-icon.offline, +.history-icon.info { + color: var(--muted); +} + +.history-message { + word-break: break-word; +} +.history-line.level-error .history-message { + color: #fca5a5; +} + .preset-report { display: flex; flex-direction: column;