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

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