Feat : Log recorder
This commit is contained in:
28
README.md
28
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 <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`,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
216
packages/web/src/components/AgentHistory.tsx
Normal file
216
packages/web/src/components/AgentHistory.tsx
Normal 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()
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user