Feat : Log recorder
Some checks failed
release / build (push) Successful in 29s
release / verify-windows (push) Failing after 1m0s

This commit is contained in:
jeanotx32
2026-08-11 22:34:45 +02:00
parent 03b1963150
commit 1dd3959f29
16 changed files with 631 additions and 53 deletions

View File

@@ -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 <jeton de session>`.
@@ -226,7 +249,8 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
| `DELETE` | `/api/agents/:id` | Supprime l'agent |
| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent |
| `POST` | `/api/commands/bulk` | Même action sur plusieurs agents, résultat par agent |
| `GET` | `/api/logs?limit=` | Journal récent |
| `GET` | `/api/logs?limit=` | Journal récent, toutes VM confondues |
| `GET` | `/api/agents/:id/logs?limit=` | Historique d'une VM, ordre chronologique |
| `GET` | `/healthz` | Sonde de vie (non authentifiée) |
Actions disponibles : `obs.connect`, `obs.disconnect`, `obs.refresh`, `record.start`,

View File

@@ -1,11 +1,11 @@
import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import type { BrowserSettings } from '@stream-control/shared';
import type { AgentEvent, BrowserSettings } from '@stream-control/shared';
const run = promisify(execFile);
export interface BrowserLog {
(level: 'info' | 'warn' | 'error', message: string): void;
(level: 'info' | 'warn' | 'error', message: string, event?: AgentEvent): void;
}
/**
@@ -42,7 +42,7 @@ export async function openUrl(
const target = assertWebUrl(url);
const args = [...settings.args, target];
log('info', `Ouverture de ${target} (${settings.command})`);
log('info', `Ouverture de ${target} (${settings.command})`, 'browser.opened');
const child = spawn(settings.command, args, {
detached: true,
@@ -103,7 +103,7 @@ export async function closeWindow(
await run('xdotool', ['windowclose', id], { env, timeout: 5000 }).catch(() => 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 };
}

View File

@@ -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<void> {
} 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<string, unknown>): Promise<unknown> {
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<unknown> {
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<string, unknown>):
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<void> {
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<string, unknown>): Promise<PresetAp
encoder: isRecordingEncoder(params.encoder) ? params.encoder : recordingSettings.encoder,
});
presetPending = false;
report('info', describePreset(result));
report('info', describePreset(result), 'preset.applied');
return result;
}

View File

@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events';
import OBSWebSocket from 'obs-websocket-js';
import type {
AgentAction,
AgentEvent,
AgentStatus,
ObsSettings,
PresetApplyResult,
@@ -10,6 +11,19 @@ import type {
} from '@stream-control/shared';
import { RECORDING_ENCODERS, findRecordingPreset } from '@stream-control/shared';
/** Traduit l'état d'une sortie OBS en évènement d'historique et en libellé. */
const RECORD_STATES: Record<string, { event: AgentEvent; label: string }> = {
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<string, { event: AgentEvent; label: string }> = {
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<void> {

View File

@@ -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.

View File

@@ -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<StreamState, AgentEvent | undefined> = {
public: 'watch.public',
private: 'watch.private',
offline: 'watch.offline',
unknown: undefined,
};
function label(state: StreamState): string {
switch (state) {
case 'public':

View File

@@ -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');
}
});

View File

@@ -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) });

View File

@@ -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();
},
};

View File

@@ -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}`);

View File

@@ -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;
}
/**

View File

@@ -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<Set<string>>(new Set());
const [settingsFor, setSettingsFor] = useState<AgentView | null>(null);
const [historyFor, setHistoryFor] = useState<AgentView | null>(null);
const [toast, setToast] = useState<Toast | null>(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}
/>
))}
</main>
@@ -258,6 +265,14 @@ export function App() {
/>
)}
{historyAgent && (
<AgentHistory
agent={historyAgent}
liveLogs={logs}
onClose={() => setHistoryFor(null)}
/>
)}
{toast && <div className={`toast ${toast.tone}`}>{toast.message}</div>}
</div>
);

View File

@@ -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',

View File

@@ -9,9 +9,17 @@ interface Props {
onToggleSelect: (id: string) => void;
onCommand: (id: string, action: AgentAction, params?: Record<string, unknown>) => Promise<void>;
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<AgentAction | null>(null);
const { status } = agent;
@@ -67,6 +75,9 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
</span>
)}
<span className={`badge ${state.tone}`}>{state.label}</span>
<button className="icon" onClick={() => onOpenHistory(agent)} title="Historique de cette VM">
🕘
</button>
<button className="icon" onClick={() => onOpenSettings(agent)} title="Configuration">
</button>

View File

@@ -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<AgentEvent, EventStyle> = {
'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<Exclude<FilterId, 'all' | 'issues'>, 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<LogEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<FilterId>('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<number, LogEntry>();
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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal modal-tall" onClick={(event) => event.stopPropagation()}>
<header className="modal-head">
<h2>Historique · {agent.name}</h2>
<button className="icon" onClick={onClose}>
</button>
</header>
<div className="history-filters">
{FILTERS.map((entry) => (
<button
key={entry.id}
className={filter === entry.id ? 'chip active' : 'chip'}
onClick={() => setFilter(entry.id)}
>
{entry.label}
</button>
))}
<div className="spacer" />
<span className="muted small">{visible.length} évènement(s)</span>
</div>
<div className="modal-body history-body">
{error && <p className="error small">{error}</p>}
{!error && fetched === null && <p className="muted small">Chargement</p>}
{!error && fetched !== null && visible.length === 0 && (
<p className="muted small">
{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.'}
</p>
)}
{days.map(([day, dayEntries]) => (
<section key={day} className="history-day">
<h3 className="history-day-label">{day}</h3>
{dayEntries.map((entry) => {
const style = entry.event ? EVENT_STYLES[entry.event] : null;
return (
<div key={entry.id} className={`history-line level-${entry.level}`}>
<span className="mono small muted">{formatTime(entry.ts)}</span>
<span
className={`history-icon ${style?.tone ?? 'info'}`}
title={entry.event ?? 'évènement non typé'}
>
{style?.icon ?? '·'}
</span>
<span className="history-message">{entry.message}</span>
</div>
);
})}
</section>
))}
</div>
<footer className="modal-foot">
<span className="muted small">
Les entrées les plus anciennes sont purgées avec le journal global.
</span>
<div className="spacer" />
<button className="ghost" onClick={onClose}>
Fermer
</button>
</footer>
</div>
</div>
);
}
/** Regroupe par jour en conservant l'ordre décroissant reçu. */
function groupByDay(entries: LogEntry[]): Array<[string, LogEntry[]]> {
const groups = new Map<string, LogEntry[]>();
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()
);
}

View File

@@ -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;