This commit is contained in:
jeanotx32
2026-08-11 00:26:56 +02:00
commit 6f11b72cbb
43 changed files with 7175 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"serverUrl": "ws://127.0.0.1:8080/ws/agent",
"token": "colle-ici-le-jeton-d-enrolement-ou-le-jeton-de-l-agent",
"name": "vm-rec-01",
"obs": {
"host": "127.0.0.1",
"port": 4455,
"password": "mot-de-passe-obs-websocket"
},
"insecureTls": false
}

View File

@@ -0,0 +1,25 @@
{
"name": "@stream-control/agent",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"bin": {
"stream-control-agent": "./dist/index.js"
},
"scripts": {
"build": "tsc -b",
"dev": "npm run build -w @stream-control/shared && node --experimental-strip-types --disable-warning=ExperimentalWarning --watch src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"@stream-control/shared": "*",
"obs-websocket-js": "^5.0.6",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/ws": "^8.5.13",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,79 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { ObsSettings } from '@stream-control/shared';
import { DEFAULT_OBS_SETTINGS } from '@stream-control/shared';
export interface AgentConfig {
/** URL WebSocket du serveur de contrôle, ex. wss://control.exemple.com/ws/agent */
serverUrl: string;
/** Jeton d'enrôlement au premier démarrage, puis jeton propre à l'agent. */
token: string;
/** Attribué par le serveur lors de l'enrôlement. */
agentId?: string;
/** Nom affiché dans le dashboard. */
name: string;
/** Repli local si le serveur n'a pas encore poussé de configuration OBS. */
obs: ObsSettings;
/** Ignorer les erreurs de certificat TLS (utile en auto-signé). */
insecureTls?: boolean;
}
const CONFIG_PATH = path.resolve(
process.env.AGENT_CONFIG ?? path.join(process.cwd(), 'agent.config.json'),
);
function readFile(): Partial<AgentConfig> {
if (!fs.existsSync(CONFIG_PATH)) return {};
try {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) as Partial<AgentConfig>;
} catch (err) {
console.error(`Configuration illisible (${CONFIG_PATH}) :`, err);
return {};
}
}
/** Le fichier fournit les valeurs par défaut, l'environnement a priorité. */
export function loadConfig(): AgentConfig {
const file = readFile();
const config: AgentConfig = {
serverUrl: process.env.SERVER_URL ?? file.serverUrl ?? 'ws://127.0.0.1:8080/ws/agent',
token: process.env.AGENT_TOKEN ?? file.token ?? '',
agentId: process.env.AGENT_ID ?? file.agentId,
name: process.env.AGENT_NAME ?? file.name ?? os.hostname(),
obs: {
host: process.env.OBS_HOST ?? file.obs?.host ?? DEFAULT_OBS_SETTINGS.host,
port: Number(process.env.OBS_PORT ?? file.obs?.port ?? DEFAULT_OBS_SETTINGS.port),
password: process.env.OBS_PASSWORD ?? file.obs?.password ?? DEFAULT_OBS_SETTINGS.password,
},
insecureTls: process.env.INSECURE_TLS === '1' || file.insecureTls === true,
};
if (!config.token) {
throw new Error(
`Aucun jeton. Renseigne "token" dans ${CONFIG_PATH} (jeton d'enrôlement ou jeton d'agent) ou la variable AGENT_TOKEN.`,
);
}
return config;
}
/**
* Persiste l'identité reçue du serveur pour que les redémarrages suivants
* réutilisent le jeton permanent au lieu du jeton d'enrôlement.
*/
export function persistIdentity(agentId: string, token: string): void {
const current = readFile();
const next = { ...current, agentId, token };
try {
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
console.log(`Identité d'agent enregistrée dans ${CONFIG_PATH}`);
} catch (err) {
console.error(
`Impossible d'écrire ${CONFIG_PATH} — l'agent devra se ré-enrôler au prochain démarrage :`,
err,
);
}
}
export { CONFIG_PATH };

215
packages/agent/src/index.ts Normal file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env node
import os from 'node:os';
import { WebSocket } from 'ws';
import type {
AgentStatus,
AgentToServer,
LogLevel,
ServerToAgent,
} from '@stream-control/shared';
import { PROTOCOL_VERSION, detectPlatform, emptyStatus, safeJsonParse } from '@stream-control/shared';
import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
import { ObsController } from './obs.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
const AGENT_VERSION = '0.1.0';
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
let config: AgentConfig;
try {
config = loadConfig();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
const obs = new ObsController(config.obs);
let socket: WebSocket | null = null;
let statusTimer: NodeJS.Timeout | null = null;
let reconnectDelay = RECONNECT_MIN_MS;
let statusIntervalMs = 2000;
let shuttingDown = false;
// --- Transport vers le serveur de contrôle ----------------------------------
function send(message: AgentToServer): void {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
}
function report(level: LogLevel, message: string): void {
const prefix = level === 'error' ? '✖' : level === 'warn' ? '!' : '·';
console.log(`${prefix} ${message}`);
send({ type: 'log', level, message, ts: Date.now() });
}
obs.on('log', (level: LogLevel, message: string) => report(level, message));
function connect(): void {
if (shuttingDown) return;
const url = new URL(config.serverUrl);
console.log(`Connexion au serveur ${url.origin}${url.pathname}`);
socket = new WebSocket(url, {
headers: { authorization: `Bearer ${config.token}` },
rejectUnauthorized: !config.insecureTls,
handshakeTimeout: 10_000,
});
socket.on('open', () => {
reconnectDelay = RECONNECT_MIN_MS;
send({
type: 'hello',
protocol: PROTOCOL_VERSION,
agentId: config.agentId,
name: config.name,
hostname: os.hostname(),
platform: detectPlatform(process.platform),
agentVersion: AGENT_VERSION,
});
});
socket.on('message', (raw) => {
const message = safeJsonParse<ServerToAgent>(raw.toString());
if (message) void handleServerMessage(message);
});
socket.on('close', (code, reason) => {
stopStatusLoop();
socket = null;
if (shuttingDown) return;
const why = reason.toString() || `code ${code}`;
console.warn(`Session serveur fermée (${why}), nouvelle tentative dans ${reconnectDelay / 1000}s`);
scheduleReconnect();
});
socket.on('error', (err: Error) => {
console.error(`Erreur de connexion : ${err.message}`);
});
}
function scheduleReconnect(): void {
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
setTimeout(connect, delay).unref?.();
}
// --- Traitement des messages serveur ----------------------------------------
async function handleServerMessage(message: ServerToAgent): Promise<void> {
switch (message.type) {
case 'welcome': {
statusIntervalMs = message.statusIntervalMs || statusIntervalMs;
if (message.token && message.token !== config.token) {
config.token = message.token;
config.agentId = message.agentId;
persistIdentity(message.agentId, message.token);
} else if (!config.agentId) {
config.agentId = message.agentId;
}
obs.applySettings(message.obs);
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
if (message.autoConnectObs && !obs.isConnected) {
obs.connect().catch((err: Error) => report('warn', err.message));
}
startStatusLoop();
break;
}
case 'config': {
obs.applySettings(message.obs);
if (message.autoConnectObs && !obs.isConnected) {
obs.connect().catch((err: Error) => report('warn', err.message));
}
break;
}
case 'command': {
try {
const data = await obs.execute(message.action, message.params ?? {});
send({ type: 'result', requestId: message.requestId, ok: true, data });
void pushStatus(); // état rafraîchi immédiatement après l'action
} 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}`);
}
break;
}
case 'ping': {
send({ type: 'pong', ts: Date.now() });
break;
}
}
}
// --- Boucle de statut --------------------------------------------------------
async function buildStatus(): Promise<AgentStatus> {
const snapshot = await obs.snapshot();
const memory = memoryUsage();
const disk = await diskUsage(snapshot.recordDirectory);
return {
...emptyStatus(),
...snapshot,
lastRecordingPath: obs.recordingPath,
systemCpu: cpuUsagePercent(),
systemMemoryUsed: memory.used,
systemMemoryTotal: memory.total,
diskFreeBytes: disk?.freeBytes,
diskTotalBytes: disk?.totalBytes,
updatedAt: Date.now(),
};
}
async function pushStatus(): Promise<void> {
if (socket?.readyState !== WebSocket.OPEN) return;
try {
send({ type: 'status', status: await buildStatus() });
} catch (err) {
console.error('Collecte de statut en échec :', err);
}
}
function startStatusLoop(): void {
stopStatusLoop();
void pushStatus();
statusTimer = setInterval(() => void pushStatus(), statusIntervalMs);
statusTimer.unref?.();
}
function stopStatusLoop(): void {
if (statusTimer) clearInterval(statusTimer);
statusTimer = null;
}
// --- Cycle de vie ------------------------------------------------------------
async function shutdown(signal: string): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
console.log(`\n${signal} reçu, arrêt de l'agent…`);
stopStatusLoop();
// L'enregistrement OBS en cours n'est volontairement pas interrompu.
await obs.disconnect().catch(() => undefined);
socket?.close(1000, 'Arrêt de l\'agent');
setTimeout(() => process.exit(0), 500).unref();
}
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('unhandledRejection', (reason) => {
console.error('Rejet non géré :', reason);
});
console.log(`stream-control agent v${AGENT_VERSION}${config.name} (${process.platform})`);
connect();

313
packages/agent/src/obs.ts Normal file
View File

@@ -0,0 +1,313 @@
import { EventEmitter } from 'node:events';
import OBSWebSocket from 'obs-websocket-js';
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
type ObsSnapshot = Pick<
AgentStatus,
| 'obsConnected'
| 'obsVersion'
| 'obsError'
| 'recording'
| 'recordPaused'
| 'recordTimecode'
| 'recordBytes'
| 'recordDirectory'
| 'streaming'
| 'streamTimecode'
| 'currentScene'
| 'scenes'
| 'currentProfile'
| 'profiles'
| 'currentCollection'
| 'collections'
| 'cpuUsage'
| 'fps'
| 'droppedFrames'
| 'renderSkippedFrames'
>;
const RECONNECT_DELAY_MS = 5000;
/**
* Enveloppe obs-websocket : maintient la session, expose les actions du
* protocole et produit un instantané d'état à chaque cycle de statut.
*/
export class ObsController extends EventEmitter {
private readonly obs = new OBSWebSocket();
private settings: ObsSettings;
private connected = false;
private connecting: Promise<void> | null = null;
private autoReconnect = false;
private reconnectTimer: NodeJS.Timeout | null = null;
private lastError: string | undefined;
private lastRecordingPath: string | undefined;
private cachedVersion: string | undefined;
constructor(settings: ObsSettings) {
super();
this.settings = settings;
this.obs.on('ConnectionClosed', (err: unknown) => {
const wasConnected = this.connected;
this.connected = false;
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.scheduleReconnect();
});
this.obs.on('RecordStateChanged', (event) => {
if (typeof event.outputPath === 'string' && event.outputPath) {
this.lastRecordingPath = event.outputPath;
}
this.emit('log', 'info', `Enregistrement : ${String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '')}`);
});
this.obs.on('StreamStateChanged', (event) => {
this.emit('log', 'info', `Diffusion : ${String(event.outputState).replace('OBS_WEBSOCKET_OUTPUT_', '')}`);
});
}
get isConnected(): boolean {
return this.connected;
}
applySettings(settings: ObsSettings): void {
const changed =
settings.host !== this.settings.host ||
settings.port !== this.settings.port ||
settings.password !== this.settings.password;
this.settings = settings;
if (changed && this.connected) {
this.emit('log', 'info', 'Paramètres OBS modifiés, reconnexion');
void this.reconnect();
}
}
async connect(): Promise<void> {
if (this.connected) return;
if (this.connecting) return this.connecting;
this.autoReconnect = true;
const url = `ws://${this.settings.host}:${this.settings.port}`;
this.connecting = (async () => {
try {
const info = await this.obs.connect(url, this.settings.password || undefined, {
rpcVersion: 1,
});
this.connected = true;
this.lastError = undefined;
this.cachedVersion = info.obsWebSocketVersion;
this.emit('log', 'info', `Connecté à OBS ${url} (obs-websocket ${info.obsWebSocketVersion})`);
} catch (err) {
this.connected = false;
this.lastError = err instanceof Error ? err.message : String(err);
throw new Error(`Connexion à OBS impossible (${url}) : ${this.lastError}`);
} finally {
this.connecting = null;
}
})();
return this.connecting;
}
async disconnect(): Promise<void> {
this.autoReconnect = false;
this.clearReconnect();
if (this.connected) await this.obs.disconnect();
this.connected = false;
this.emit('log', 'info', 'Déconnecté d\'OBS');
}
private async reconnect(): Promise<void> {
try {
await this.obs.disconnect();
} catch {
/* ignore */
}
this.connected = false;
await this.connect().catch((err: Error) => this.emit('log', 'warn', err.message));
}
private scheduleReconnect(): void {
if (!this.autoReconnect || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.autoReconnect || this.connected) return;
this.connect().catch(() => {
/* la prochaine fermeture reprogrammera un essai */
});
}, RECONNECT_DELAY_MS);
this.reconnectTimer.unref?.();
}
private clearReconnect(): void {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// --- Exécution des actions du protocole ---------------------------------
async execute(action: AgentAction, params: Record<string, unknown> = {}): Promise<unknown> {
switch (action) {
case 'obs.connect':
await this.connect();
return { connected: true };
case 'obs.disconnect':
await this.disconnect();
return { connected: false };
case 'obs.refresh':
this.requireConnection();
return this.snapshot();
case 'record.start': {
this.requireConnection();
await this.obs.call('StartRecord');
return { recording: true };
}
case 'record.stop': {
this.requireConnection();
const result = await this.obs.call('StopRecord');
this.lastRecordingPath = result.outputPath;
return { recording: false, outputPath: result.outputPath };
}
case 'record.pause':
this.requireConnection();
await this.obs.call('PauseRecord');
return { paused: true };
case 'record.resume':
this.requireConnection();
await this.obs.call('ResumeRecord');
return { paused: false };
case 'record.split':
this.requireConnection();
await this.obs.call('SplitRecordFile');
return { split: true };
case 'stream.start':
this.requireConnection();
await this.obs.call('StartStream');
return { streaming: true };
case 'stream.stop':
this.requireConnection();
await this.obs.call('StopStream');
return { streaming: false };
case 'scene.set': {
this.requireConnection();
const sceneName = requireString(params.scene, 'scene');
await this.obs.call('SetCurrentProgramScene', { sceneName });
return { scene: sceneName };
}
case 'profile.set': {
this.requireConnection();
const profileName = requireString(params.profile, 'profile');
await this.obs.call('SetCurrentProfile', { profileName });
return { profile: profileName };
}
case 'collection.set': {
this.requireConnection();
const sceneCollectionName = requireString(params.collection, 'collection');
await this.obs.call('SetCurrentSceneCollection', { sceneCollectionName });
return { collection: sceneCollectionName };
}
case 'recordDirectory.set': {
this.requireConnection();
const recordDirectory = requireString(params.directory, 'directory');
await this.obs.call('SetRecordDirectory', { recordDirectory });
return { recordDirectory };
}
case 'agent.ping':
return { pong: Date.now() };
default: {
const exhaustive: never = action;
throw new Error(`Action non gérée : ${String(exhaustive)}`);
}
}
}
/** Interroge OBS pour construire l'état courant ; ne lève jamais. */
async snapshot(): Promise<ObsSnapshot> {
const base: ObsSnapshot = {
obsConnected: this.connected,
obsVersion: this.cachedVersion,
obsError: this.connected ? undefined : this.lastError,
recording: false,
recordPaused: false,
streaming: false,
scenes: [],
profiles: [],
collections: [],
};
if (!this.connected) return base;
try {
const [record, stream, sceneList, profileList, collectionList, stats, directory] =
await Promise.all([
this.obs.call('GetRecordStatus'),
this.obs.call('GetStreamStatus'),
this.obs.call('GetSceneList'),
this.obs.call('GetProfileList'),
this.obs.call('GetSceneCollectionList'),
this.obs.call('GetStats'),
this.obs.call('GetRecordDirectory').catch(() => null),
]);
return {
...base,
recording: record.outputActive,
recordPaused: record.outputPaused,
recordTimecode: record.outputTimecode,
recordBytes: record.outputBytes,
recordDirectory: directory?.recordDirectory,
streaming: stream.outputActive,
streamTimecode: stream.outputTimecode,
currentScene: sceneList.currentProgramSceneName,
scenes: (sceneList.scenes as Array<{ sceneName?: string }>)
.map((scene) => scene.sceneName)
.filter((name): name is string => typeof name === 'string'),
currentProfile: profileList.currentProfileName,
profiles: profileList.profiles,
currentCollection: collectionList.currentSceneCollectionName,
collections: collectionList.sceneCollections,
cpuUsage: stats.cpuUsage,
fps: stats.activeFps,
droppedFrames: stats.outputSkippedFrames,
renderSkippedFrames: stats.renderSkippedFrames,
};
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
return { ...base, obsError: this.lastError };
}
}
get recordingPath(): string | undefined {
return this.lastRecordingPath;
}
private requireConnection(): void {
if (!this.connected) throw new Error('OBS n\'est pas connecté sur cet agent');
}
}
function requireString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`Paramètre « ${field} » manquant`);
}
return value;
}

View File

@@ -0,0 +1,56 @@
import fs from 'node:fs/promises';
import os from 'node:os';
interface CpuSample {
idle: number;
total: number;
}
function sampleCpu(): CpuSample {
let idle = 0;
let total = 0;
for (const cpu of os.cpus()) {
for (const [kind, value] of Object.entries(cpu.times)) {
total += value;
if (kind === 'idle') idle += value;
}
}
return { idle, total };
}
let previous = sampleCpu();
/** Charge CPU moyenne (0-100) depuis le dernier appel. */
export function cpuUsagePercent(): number {
const current = sampleCpu();
const idleDelta = current.idle - previous.idle;
const totalDelta = current.total - previous.total;
previous = current;
if (totalDelta <= 0) return 0;
return Math.round((1 - idleDelta / totalDelta) * 1000) / 10;
}
export interface DiskUsage {
freeBytes: number;
totalBytes: number;
}
/** Espace disque du volume contenant `directory` (répertoire d'enregistrement). */
export async function diskUsage(directory: string | undefined): Promise<DiskUsage | null> {
const target = directory || os.homedir();
try {
const stats = await fs.statfs(target);
const blockSize = Number(stats.bsize);
return {
freeBytes: Number(stats.bavail) * blockSize,
totalBytes: Number(stats.blocks) * blockSize,
};
} catch {
return null;
}
}
export function memoryUsage(): { used: number; total: number } {
const total = os.totalmem();
return { used: total - os.freemem(), total };
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"],
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../shared" }]
}

View File

@@ -0,0 +1,24 @@
{
"name": "@stream-control/server",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"build": "tsc -b",
"dev": "node --experimental-strip-types --disable-warning=ExperimentalWarning --watch src/index.ts",
"start": "node --disable-warning=ExperimentalWarning dist/index.js"
},
"dependencies": {
"@stream-control/shared": "*",
"dotenv": "^16.4.7",
"express": "^5.0.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.5",
"@types/ws": "^8.5.13",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,154 @@
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 { config } from './config.ts';
import { extractBearer, generateToken, hashToken, safeEqual } from './auth.ts';
import { agentsRepo, type AgentRecord } from './db.ts';
import { hub } from './hub.ts';
const HELLO_TIMEOUT_MS = 10_000;
export type AgentAuth =
| { mode: 'known'; record: AgentRecord }
| { mode: 'enroll' };
/**
* Authentifie une tentative de connexion agent à partir du jeton porté par la
* requête d'upgrade. Renvoie null si le jeton est inconnu.
*/
export function authenticateAgent(req: IncomingMessage): AgentAuth | null {
const url = new URL(req.url ?? '/', 'http://localhost');
const token =
extractBearer(req.headers.authorization) ??
url.searchParams.get('token') ??
(typeof req.headers['x-agent-token'] === 'string' ? req.headers['x-agent-token'] : null);
if (!token) return null;
const record = agentsRepo.findByTokenHash(hashToken(token));
if (record) return { mode: 'known', record };
if (config.enrollmentToken && safeEqual(token, config.enrollmentToken)) {
return { mode: 'enroll' };
}
return null;
}
function send(socket: WebSocket, message: ServerToAgent): void {
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
}
export function handleAgentConnection(
socket: WebSocket,
auth: AgentAuth,
remoteAddress: string,
): void {
let agentId: string | null = null;
const helloTimer = setTimeout(() => {
if (!agentId) socket.close(4002, 'Message hello absent');
}, HELLO_TIMEOUT_MS);
socket.on('message', (raw: RawData) => {
const message = safeJsonParse<AgentToServer>(raw.toString());
if (!message || typeof message.type !== 'string') {
hub.log(agentId, 'warn', 'Message agent illisible, ignoré');
return;
}
// Tant que l'agent ne s'est pas présenté, seul `hello` est accepté.
if (!agentId && message.type !== 'hello') return;
switch (message.type) {
case 'hello': {
if (agentId) return; // hello dupliqué
clearTimeout(helloTimer);
if (message.protocol !== PROTOCOL_VERSION) {
hub.log(
null,
'warn',
`Agent « ${message.name} » en protocole v${message.protocol}, serveur en v${PROTOCOL_VERSION}`,
);
}
const platform = detectPlatform(message.platform);
let record: AgentRecord;
let issuedToken: string | undefined;
if (auth.mode === 'enroll') {
issuedToken = generateToken();
record = agentsRepo.create({
id: message.agentId?.trim() || randomUUID(),
name: message.name || message.hostname || 'agent',
tokenHash: hashToken(issuedToken),
hostname: message.hostname,
platform,
agentVersion: message.agentVersion,
});
hub.log(record.id, 'info', `Nouvel agent enrôlé depuis ${remoteAddress}`);
} else {
record = auth.record;
agentsRepo.updateIdentity(record.id, {
hostname: message.hostname,
platform,
agentVersion: message.agentVersion,
});
record = agentsRepo.get(record.id) ?? record;
}
agentId = record.id;
hub.attachAgent(record.id, socket);
hub.log(record.id, 'info', `Agent connecté (${platform}, ${remoteAddress})`);
send(socket, {
type: 'welcome',
agentId: record.id,
token: issuedToken,
obs: record.obs,
statusIntervalMs: config.statusIntervalMs,
autoConnectObs: record.autoConnectObs,
});
break;
}
case 'status': {
if (!agentId) return;
hub.updateStatus(agentId, { ...message.status, updatedAt: Date.now() });
agentsRepo.touch(agentId);
break;
}
case 'result': {
if (!agentId) return;
hub.markSeen(agentId);
hub.resolveCommand(agentId, message.requestId, message.ok, message.data, message.error);
break;
}
case 'log': {
hub.log(agentId, message.level, message.message, message.ts || Date.now());
break;
}
case 'pong': {
if (agentId) hub.markSeen(agentId);
break;
}
}
});
socket.on('close', () => {
clearTimeout(helloTimer);
if (agentId) {
hub.detachAgent(agentId, socket);
hub.log(agentId, 'info', 'Agent déconnecté');
}
});
socket.on('error', (err: Error) => {
hub.log(agentId, 'error', `Erreur socket agent : ${err.message}`);
});
}

209
packages/server/src/api.ts Normal file
View File

@@ -0,0 +1,209 @@
import { randomUUID } from 'node:crypto';
import { Router } from 'express';
import type { ObsSettings } from '@stream-control/shared';
import { AGENT_ACTIONS, DEFAULT_OBS_SETTINGS, isAgentAction } from '@stream-control/shared';
import { config } from './config.ts';
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
import { agentsRepo, logsRepo } from './db.ts';
import { hub } from './hub.ts';
export const api: Router = Router();
// --- Authentification -------------------------------------------------------
api.post('/login', (req, res) => {
const password = typeof req.body?.password === 'string' ? req.body.password : '';
if (!safeEqual(password, config.adminPassword)) {
hub.log(null, 'warn', `Échec de connexion au dashboard depuis ${req.ip}`);
res.status(401).json({ error: 'Mot de passe invalide' });
return;
}
res.json(issueSession());
});
api.use(requireSession);
api.get('/session', (_req, res) => {
res.json({ ok: true, actions: AGENT_ACTIONS });
});
// --- Agents -----------------------------------------------------------------
api.get('/agents', (_req, res) => {
res.json({ agents: hub.views() });
});
api.get('/agents/:id', (req, res) => {
const record = agentsRepo.get(req.params.id);
if (!record) {
res.status(404).json({ error: 'Agent introuvable' });
return;
}
res.json({ agent: hub.view(record) });
});
/** Provisionnement manuel : renvoie le jeton en clair une seule fois. */
api.post('/agents', (req, res) => {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!name) {
res.status(400).json({ error: 'Le nom est obligatoire' });
return;
}
const token = generateToken();
const record = agentsRepo.create({
id: randomUUID(),
name,
tokenHash: hashToken(token),
obs: parseObs(req.body?.obs),
autoConnectObs: req.body?.autoConnectObs !== false,
notes: typeof req.body?.notes === 'string' ? req.body.notes : null,
});
hub.log(record.id, 'info', `Agent « ${name} » créé depuis le dashboard`);
hub.publishAgent(record.id);
res.status(201).json({ agent: hub.view(record), token });
});
api.patch('/agents/:id', (req, res) => {
const record = agentsRepo.get(req.params.id);
if (!record) {
res.status(404).json({ error: 'Agent introuvable' });
return;
}
const obs = parseObs(req.body?.obs, record.obs);
// Le dashboard reçoit le mot de passe masqué : on ne l'écrase pas s'il revient tel quel.
if (obs.password === '********') obs.password = record.obs.password;
agentsRepo.updateSettings(record.id, {
name: typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : record.name,
obs,
autoConnectObs:
typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.autoConnectObs,
notes: typeof req.body?.notes === 'string' ? req.body.notes : record.notes,
});
const updated = agentsRepo.get(record.id);
if (updated) {
hub.pushConfig(updated);
hub.publishAgent(updated.id);
res.json({ agent: hub.view(updated) });
} else {
res.status(500).json({ error: 'Mise à jour impossible' });
}
});
/** Régénère le jeton d'un agent : l'ancienne session est coupée. */
api.post('/agents/:id/token', (req, res) => {
const record = agentsRepo.get(req.params.id);
if (!record) {
res.status(404).json({ error: 'Agent introuvable' });
return;
}
const token = generateToken();
agentsRepo.rotateToken(record.id, hashToken(token));
hub.disconnectAgent(record.id, 'Jeton régénéré');
hub.log(record.id, 'warn', 'Jeton régénéré : reconfigure l\'agent');
res.json({ token });
});
api.delete('/agents/:id', (req, res) => {
const record = agentsRepo.get(req.params.id);
if (!record) {
res.status(404).json({ error: 'Agent introuvable' });
return;
}
hub.disconnectAgent(record.id, 'Agent supprimé');
agentsRepo.remove(record.id);
hub.publishRemoval(record.id);
hub.log(null, 'warn', `Agent « ${record.name} » supprimé`);
res.json({ ok: true });
});
// --- Commandes --------------------------------------------------------------
api.post('/agents/:id/command', async (req, res) => {
const action = req.body?.action;
if (!isAgentAction(action)) {
res.status(400).json({ error: `Action inconnue : ${String(action)}` });
return;
}
const record = agentsRepo.get(req.params.id);
if (!record) {
res.status(404).json({ error: 'Agent introuvable' });
return;
}
try {
const data = await hub.sendCommand(record.id, action, req.body?.params);
hub.log(record.id, 'info', `Commande « ${action} » exécutée`);
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}`);
res.status(502).json({ ok: false, error: messageText });
}
});
/** Commande groupée : même action sur plusieurs agents, résultats par agent. */
api.post('/commands/bulk', async (req, res) => {
const action = req.body?.action;
if (!isAgentAction(action)) {
res.status(400).json({ error: `Action inconnue : ${String(action)}` });
return;
}
const requested: string[] = Array.isArray(req.body?.agentIds)
? req.body.agentIds.filter((id: unknown): id is string => typeof id === 'string')
: agentsRepo.list().map((record) => record.id);
const results = await Promise.all(
requested.map(async (agentId) => {
try {
const data = await hub.sendCommand(agentId, action, req.body?.params);
return { agentId, ok: true as const, data };
} catch (err) {
return {
agentId,
ok: false as const,
error: err instanceof Error ? err.message : String(err),
};
}
}),
);
const failed = results.filter((result) => !result.ok).length;
hub.log(
null,
failed ? 'warn' : 'info',
`Commande groupée « ${action} » : ${results.length - failed}/${results.length} OK`,
);
res.json({ results });
});
// --- Divers -----------------------------------------------------------------
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) });
});
/** Infos nécessaires pour configurer un nouvel agent. */
api.get('/enrollment', (req, res) => {
res.json({
enabled: Boolean(config.enrollmentToken),
token: config.enrollmentToken || null,
serverUrl: `${req.protocol === 'https' ? 'wss' : 'ws'}://${req.get('host')}/ws/agent`,
});
});
function parseObs(raw: unknown, fallback: ObsSettings = DEFAULT_OBS_SETTINGS): ObsSettings {
const input = (raw ?? {}) as Partial<ObsSettings>;
const port = Number(input.port);
return {
host: typeof input.host === 'string' && input.host.trim() ? input.host.trim() : fallback.host,
port: Number.isFinite(port) && port > 0 && port < 65536 ? port : fallback.port,
password: typeof input.password === 'string' ? input.password : fallback.password,
};
}

View File

@@ -0,0 +1,63 @@
import { createHmac, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';
import { config } from './config.ts';
/** Comparaison à temps constant, tolérante aux longueurs différentes. */
export function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) {
// On compare quand même pour ne pas court-circuiter sur la longueur.
timingSafeEqual(bufA, bufA);
return false;
}
return timingSafeEqual(bufA, bufB);
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export function generateToken(): string {
return randomBytes(32).toString('base64url');
}
// --- Sessions du dashboard : jeton `<expiration>.<hmac>` sans stockage serveur ---
function sign(payload: string): string {
return createHmac('sha256', config.sessionSecret).update(payload).digest('base64url');
}
export function issueSession(): { token: string; expiresAt: number } {
const expiresAt = Date.now() + config.sessionTtlMs;
const payload = String(expiresAt);
return { token: `${payload}.${sign(payload)}`, expiresAt };
}
export function verifySession(token: string | undefined | null): boolean {
if (!token) return false;
const dot = token.lastIndexOf('.');
if (dot <= 0) return false;
const payload = token.slice(0, dot);
const signature = token.slice(dot + 1);
if (!safeEqual(signature, sign(payload))) return false;
const expiresAt = Number.parseInt(payload, 10);
return Number.isFinite(expiresAt) && expiresAt > Date.now();
}
export function extractBearer(header: string | undefined): string | null {
if (!header) return null;
const [scheme, value] = header.split(' ');
if (!value || scheme?.toLowerCase() !== 'bearer') return null;
return value.trim();
}
/** Middleware Express protégeant les routes /api (hors login). */
export function requireSession(req: Request, res: Response, next: NextFunction): void {
const token = extractBearer(req.headers.authorization) ?? (req.query.token as string | undefined);
if (!verifySession(token)) {
res.status(401).json({ error: 'Non authentifié' });
return;
}
next();
}

View File

@@ -0,0 +1,55 @@
import { randomBytes } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, '../../..');
dotenv.config({ path: path.join(repoRoot, '.env'), quiet: true });
function required(name: string, fallback?: string): string {
const value = process.env[name] ?? fallback;
if (!value) {
throw new Error(
`Variable d'environnement manquante : ${name}. Copie .env.example vers .env et complète-la.`,
);
}
return value;
}
function int(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
const isDev = process.env.NODE_ENV !== 'production';
export const config = {
port: int('PORT', 8080),
host: process.env.HOST ?? '0.0.0.0',
isDev,
adminPassword: required('ADMIN_PASSWORD', isDev ? 'admin' : undefined),
sessionSecret: required(
'SESSION_SECRET',
isDev ? randomBytes(32).toString('hex') : undefined,
),
sessionTtlMs: int('SESSION_TTL_MS', 12 * 60 * 60 * 1000),
/** Vide = enrôlement automatique désactivé. */
enrollmentToken: process.env.ENROLLMENT_TOKEN ?? '',
dbPath: path.resolve(repoRoot, process.env.DB_PATH ?? './data/stream-control.sqlite'),
statusIntervalMs: int('STATUS_INTERVAL_MS', 2000),
agentTimeoutMs: int('AGENT_TIMEOUT_MS', 15_000),
commandTimeoutMs: int('COMMAND_TIMEOUT_MS', 15_000),
/** Nombre d'entrées de journal conservées en base. */
logRetention: int('LOG_RETENTION', 2000),
webDist: path.resolve(repoRoot, 'packages/web/dist'),
} as const;

255
packages/server/src/db.ts Normal file
View File

@@ -0,0 +1,255 @@
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type { LogEntry, LogLevel, ObsSettings, Platform } from '@stream-control/shared';
import { DEFAULT_OBS_SETTINGS } from '@stream-control/shared';
import { config } from './config.ts';
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
export const db = new DatabaseSync(config.dbPath);
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
hostname TEXT,
platform TEXT NOT NULL DEFAULT 'unknown',
agent_version TEXT,
token_hash TEXT NOT NULL,
obs_host TEXT NOT NULL DEFAULT '127.0.0.1',
obs_port INTEGER NOT NULL DEFAULT 4455,
obs_password TEXT NOT NULL DEFAULT '',
auto_connect INTEGER NOT NULL DEFAULT 1,
notes TEXT,
created_at INTEGER NOT NULL,
last_seen_at INTEGER
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT,
level TEXT NOT NULL,
message TEXT NOT NULL,
ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs (ts DESC);
`);
export interface AgentRow {
id: string;
name: string;
hostname: string | null;
platform: string;
agent_version: string | null;
token_hash: string;
obs_host: string;
obs_port: number;
obs_password: string;
auto_connect: number;
notes: string | null;
created_at: number;
last_seen_at: number | null;
}
export interface AgentRecord {
id: string;
name: string;
hostname: string | null;
platform: Platform;
agentVersion: string | null;
tokenHash: string;
obs: ObsSettings;
autoConnectObs: boolean;
notes: string | null;
createdAt: number;
lastSeenAt: number | null;
}
function toRecord(row: AgentRow): AgentRecord {
return {
id: row.id,
name: row.name,
hostname: row.hostname,
platform: (row.platform as Platform) ?? 'unknown',
agentVersion: row.agent_version,
tokenHash: row.token_hash,
obs: {
host: row.obs_host || DEFAULT_OBS_SETTINGS.host,
port: Number(row.obs_port) || DEFAULT_OBS_SETTINGS.port,
password: row.obs_password ?? '',
},
autoConnectObs: Number(row.auto_connect) === 1,
notes: row.notes,
createdAt: Number(row.created_at),
lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_at),
};
}
const stmts = {
listAgents: db.prepare('SELECT * FROM agents ORDER BY name COLLATE NOCASE'),
getAgent: db.prepare('SELECT * FROM agents WHERE id = ?'),
getAgentByTokenHash: db.prepare('SELECT * FROM agents WHERE token_hash = ?'),
insertAgent: db.prepare(`
INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash,
obs_host, obs_port, obs_password, auto_connect, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
updateIdentity: db.prepare(`
UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ?
WHERE id = ?
`),
updateSettings: db.prepare(`
UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?,
auto_connect = ?, notes = ?
WHERE id = ?
`),
touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'),
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 (?, ?, ?, ?)'),
recentLogs: db.prepare(`
SELECT l.id, l.agent_id, l.level, l.message, l.ts, a.name AS agent_name
FROM logs l LEFT JOIN agents a ON a.id = 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 ?)
`),
};
export const agentsRepo = {
list(): AgentRecord[] {
return (stmts.listAgents.all() as unknown as AgentRow[]).map(toRecord);
},
get(id: string): AgentRecord | null {
const row = stmts.getAgent.get(id) as unknown as AgentRow | undefined;
return row ? toRecord(row) : null;
},
findByTokenHash(tokenHash: string): AgentRecord | null {
const row = stmts.getAgentByTokenHash.get(tokenHash) as unknown as AgentRow | undefined;
return row ? toRecord(row) : null;
},
create(input: {
id: string;
name: string;
tokenHash: string;
hostname?: string | null;
platform?: Platform;
agentVersion?: string | null;
obs?: Partial<ObsSettings>;
autoConnectObs?: boolean;
notes?: string | null;
}): AgentRecord {
const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs };
stmts.insertAgent.run(
input.id,
input.name,
input.hostname ?? null,
input.platform ?? 'unknown',
input.agentVersion ?? null,
input.tokenHash,
obs.host,
obs.port,
obs.password,
input.autoConnectObs === false ? 0 : 1,
input.notes ?? null,
Date.now(),
);
const created = agentsRepo.get(input.id);
if (!created) throw new Error(`Échec de création de l'agent ${input.id}`);
return created;
},
updateIdentity(
id: string,
identity: { hostname: string | null; platform: Platform; agentVersion: string | null },
): void {
stmts.updateIdentity.run(
identity.hostname,
identity.platform,
identity.agentVersion,
Date.now(),
id,
);
},
updateSettings(
id: string,
settings: {
name: string;
obs: ObsSettings;
autoConnectObs: boolean;
notes: string | null;
},
): void {
stmts.updateSettings.run(
settings.name,
settings.obs.host,
settings.obs.port,
settings.obs.password,
settings.autoConnectObs ? 1 : 0,
settings.notes,
id,
);
},
touch(id: string): void {
stmts.touchAgent.run(Date.now(), id);
},
rotateToken(id: string, tokenHash: string): void {
stmts.rotateToken.run(tokenHash, id);
},
remove(id: string): void {
stmts.deleteAgent.run(id);
},
};
interface LogRow {
id: number;
agent_id: string | null;
agent_name: string | null;
level: string;
message: string;
ts: number;
}
export const logsRepo = {
append(agentId: string | null, level: LogLevel, message: string, ts = Date.now()): LogEntry {
const info = stmts.insertLog.run(agentId, level, message, ts);
if (Math.random() < 0.02) stmts.pruneLogs.run(config.logRetention);
const agent = agentId ? agentsRepo.get(agentId) : null;
return {
id: Number(info.lastInsertRowid),
agentId,
agentName: agent?.name ?? null,
level,
message,
ts,
};
},
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();
},
};

267
packages/server/src/hub.ts Normal file
View File

@@ -0,0 +1,267 @@
import { randomUUID } from 'node:crypto';
import type { WebSocket } from 'ws';
import type {
AgentAction,
AgentStatus,
AgentView,
LogEntry,
LogLevel,
ServerToAgent,
ServerToDashboard,
} from '@stream-control/shared';
import { emptyStatus } from '@stream-control/shared';
import { config } from './config.ts';
import { agentsRepo, logsRepo, type AgentRecord } from './db.ts';
interface PendingCommand {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
timer: NodeJS.Timeout;
}
interface AgentConnection {
socket: WebSocket;
lastSeenAt: number;
pending: Map<string, PendingCommand>;
}
/**
* Point central : garde en mémoire les connexions agents + le dernier statut
* connu, dispatche les commandes et diffuse l'état aux dashboards ouverts.
*/
class Hub {
private readonly connections = new Map<string, AgentConnection>();
private readonly statuses = new Map<string, AgentStatus>();
private readonly dashboards = new Set<WebSocket>();
// --- Agents -------------------------------------------------------------
attachAgent(agentId: string, socket: WebSocket): void {
// Une seule session par agent : la nouvelle connexion évince l'ancienne.
const existing = this.connections.get(agentId);
if (existing && existing.socket !== socket) {
this.failPending(existing, new Error('Connexion agent remplacée'));
try {
existing.socket.close(4000, 'Remplacé par une nouvelle session');
} catch {
/* ignore */
}
}
this.connections.set(agentId, { socket, lastSeenAt: Date.now(), pending: new Map() });
this.statuses.set(agentId, this.statuses.get(agentId) ?? emptyStatus());
this.publishAgent(agentId);
}
detachAgent(agentId: string, socket: WebSocket): void {
const connection = this.connections.get(agentId);
if (!connection || connection.socket !== socket) return;
this.failPending(connection, new Error('Agent déconnecté'));
this.connections.delete(agentId);
// On garde le dernier statut connu mais on marque OBS comme injoignable.
const status = this.statuses.get(agentId);
if (status) {
this.statuses.set(agentId, {
...status,
obsConnected: false,
recording: false,
streaming: false,
updatedAt: Date.now(),
});
}
this.publishAgent(agentId);
}
isOnline(agentId: string): boolean {
return this.connections.has(agentId);
}
markSeen(agentId: string): void {
const connection = this.connections.get(agentId);
if (connection) connection.lastSeenAt = Date.now();
}
updateStatus(agentId: string, status: AgentStatus): void {
this.statuses.set(agentId, status);
this.markSeen(agentId);
this.publishAgent(agentId);
}
resolveCommand(agentId: string, requestId: string, ok: boolean, data: unknown, error?: string): void {
const pending = this.connections.get(agentId)?.pending.get(requestId);
if (!pending) return;
this.connections.get(agentId)?.pending.delete(requestId);
clearTimeout(pending.timer);
if (ok) pending.resolve(data);
else pending.reject(new Error(error ?? 'La commande a échoué'));
}
/** Envoie une commande à un agent et attend son accusé de résultat. */
async sendCommand(
agentId: string,
action: AgentAction,
params?: Record<string, unknown>,
): Promise<unknown> {
const connection = this.connections.get(agentId);
if (!connection) throw new Error('Agent hors-ligne');
const requestId = randomUUID();
const message: ServerToAgent = { type: 'command', requestId, action, params };
return new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
connection.pending.delete(requestId);
reject(new Error(`Timeout : l'agent n'a pas répondu à « ${action} »`));
}, config.commandTimeoutMs);
connection.pending.set(requestId, { resolve, reject, timer });
try {
connection.socket.send(JSON.stringify(message));
} catch (err) {
connection.pending.delete(requestId);
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
}
/** Pousse la configuration OBS à un agent connecté (sans attendre de réponse). */
pushConfig(record: AgentRecord): void {
const connection = this.connections.get(record.id);
if (!connection) return;
const message: ServerToAgent = {
type: 'config',
obs: record.obs,
autoConnectObs: record.autoConnectObs,
};
connection.socket.send(JSON.stringify(message));
}
disconnectAgent(agentId: string, reason: string): void {
const connection = this.connections.get(agentId);
if (!connection) return;
try {
connection.socket.close(4001, reason);
} catch {
/* ignore */
}
}
// --- Vues ---------------------------------------------------------------
statusOf(agentId: string): AgentStatus {
return this.statuses.get(agentId) ?? emptyStatus();
}
view(record: AgentRecord): AgentView {
const online = this.isOnline(record.id);
return {
id: record.id,
name: record.name,
hostname: record.hostname,
platform: record.platform,
agentVersion: record.agentVersion,
online,
lastSeenAt: online
? (this.connections.get(record.id)?.lastSeenAt ?? record.lastSeenAt)
: record.lastSeenAt,
createdAt: record.createdAt,
// Le mot de passe OBS n'est jamais renvoyé au navigateur.
obs: { ...record.obs, password: record.obs.password ? '********' : '' },
autoConnectObs: record.autoConnectObs,
notes: record.notes,
status: this.statusOf(record.id),
};
}
views(): AgentView[] {
return agentsRepo.list().map((record) => this.view(record));
}
// --- Dashboards ---------------------------------------------------------
attachDashboard(socket: WebSocket): void {
this.dashboards.add(socket);
this.sendTo(socket, {
type: 'snapshot',
agents: this.views(),
logs: logsRepo.recent(200),
});
}
detachDashboard(socket: WebSocket): void {
this.dashboards.delete(socket);
}
publishAgent(agentId: string): void {
const record = agentsRepo.get(agentId);
if (!record) {
this.broadcast({ type: 'agent.removed', agentId });
return;
}
this.broadcast({ type: 'agent', agent: this.view(record) });
}
publishRemoval(agentId: string): void {
this.statuses.delete(agentId);
this.broadcast({ type: 'agent.removed', agentId });
}
/** 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);
this.broadcast({ type: 'log', entry });
if (level === 'error' || level === 'warn') {
console.warn(`[${level}] ${entry.agentName ?? 'serveur'}${message}`);
}
return entry;
}
private broadcast(message: ServerToDashboard): void {
const payload = JSON.stringify(message);
for (const socket of this.dashboards) {
if (socket.readyState === socket.OPEN) socket.send(payload);
}
}
private sendTo(socket: WebSocket, message: ServerToDashboard): void {
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
}
private failPending(connection: AgentConnection, error: Error): void {
for (const pending of connection.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
connection.pending.clear();
}
/** Coupe les agents silencieux : le heartbeat n'arrive plus. */
reapStale(): void {
const deadline = Date.now() - config.agentTimeoutMs;
for (const [agentId, connection] of this.connections) {
if (connection.lastSeenAt < deadline) {
this.log(agentId, 'warn', 'Agent silencieux, fermeture de la session');
try {
connection.socket.terminate();
} catch {
/* ignore */
}
this.detachAgent(agentId, connection.socket);
}
}
}
pingAll(): void {
const payload = JSON.stringify({ type: 'ping', ts: Date.now() } satisfies ServerToAgent);
for (const connection of this.connections.values()) {
if (connection.socket.readyState === connection.socket.OPEN) {
connection.socket.send(payload);
}
}
}
}
export const hub = new Hub();

View File

@@ -0,0 +1,111 @@
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import express from 'express';
import { WebSocketServer } from 'ws';
import { config } from './config.ts';
import { verifySession } from './auth.ts';
import { api } from './api.ts';
import { hub } from './hub.ts';
import { authenticateAgent, handleAgentConnection } from './agentGateway.ts';
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', true);
app.use(express.json({ limit: '256kb' }));
app.get('/healthz', (_req, res) => {
res.json({ ok: true, agents: hub.views().filter((agent) => agent.online).length });
});
app.use('/api', api);
// Le dashboard compilé, s'il a été construit (npm run build -w @stream-control/web).
if (fs.existsSync(config.webDist)) {
app.use(express.static(config.webDist));
app.use((req, res, next) => {
if (req.method !== 'GET' || req.path.startsWith('/api')) return next();
res.sendFile(path.join(config.webDist, 'index.html'));
});
} else {
app.get('/', (_req, res) => {
res
.status(200)
.type('text/plain')
.send(
'Dashboard non compilé.\n' +
'Développement : npm run dev (Vite sur http://localhost:5173)\n' +
'Production : npm run build puis npm start',
);
});
}
const server = http.createServer(app);
// --- WebSockets : deux points d'entrée, authentifiés à l'upgrade -------------
const agentWss = new WebSocketServer({ noServer: true });
const dashboardWss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '/', 'http://localhost');
if (url.pathname === '/ws/agent') {
const auth = authenticateAgent(req);
if (!auth) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
agentWss.handleUpgrade(req, socket, head, (ws) => {
const remote = String(
req.headers['x-forwarded-for'] ?? req.socket.remoteAddress ?? 'inconnu',
).split(',')[0]!.trim();
handleAgentConnection(ws, auth, remote);
});
return;
}
if (url.pathname === '/ws/dashboard') {
if (!verifySession(url.searchParams.get('token'))) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
dashboardWss.handleUpgrade(req, socket, head, (ws) => {
hub.attachDashboard(ws);
ws.on('close', () => hub.detachDashboard(ws));
ws.on('error', () => hub.detachDashboard(ws));
});
return;
}
socket.destroy();
});
// --- Boucles de maintenance -------------------------------------------------
const heartbeat = setInterval(() => {
hub.pingAll();
hub.reapStale();
}, 5000);
heartbeat.unref();
server.listen(config.port, config.host, () => {
console.log(`stream-control · http://${config.host}:${config.port}`);
console.log(` agents → ws://${config.host}:${config.port}/ws/agent`);
console.log(` enrôlement → ${config.enrollmentToken ? 'activé' : 'désactivé'}`);
if (config.isDev && !process.env.ADMIN_PASSWORD) {
console.warn(' ⚠ ADMIN_PASSWORD non défini, mot de passe de développement : « admin »');
}
});
function shutdown(signal: string): void {
console.log(`\n${signal} reçu, arrêt…`);
clearInterval(heartbeat);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000).unref();
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"],
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../shared" }]
}

View File

@@ -0,0 +1,22 @@
{
"name": "@stream-control/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsc -b",
"watch": "tsc -b --watch"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,257 @@
/**
* Protocole partagé entre le serveur de contrôle, les agents et le dashboard.
*
* Transport : WebSocket, messages JSON, un champ `type` discriminant.
* - agent <-> serveur : /ws/agent (l'agent initie la connexion sortante)
* - browser <- serveur : /ws/dashboard (flux de statut temps réel)
*/
export const PROTOCOL_VERSION = 1;
export type Platform = 'windows' | 'linux' | 'darwin' | 'unknown';
/** Paramètres de connexion à obs-websocket (plugin intégré à OBS >= 28). */
export interface ObsSettings {
host: string;
port: number;
/** Mot de passe obs-websocket ; chaîne vide si l'authentification est désactivée. */
password: string;
}
export const DEFAULT_OBS_SETTINGS: ObsSettings = {
host: '127.0.0.1',
port: 4455,
password: '',
};
// ---------------------------------------------------------------------------
// Actions pilotables sur un agent
// ---------------------------------------------------------------------------
export const AGENT_ACTIONS = [
'obs.connect',
'obs.disconnect',
'obs.refresh',
'record.start',
'record.stop',
'record.pause',
'record.resume',
'record.split',
'stream.start',
'stream.stop',
'scene.set',
'profile.set',
'collection.set',
'recordDirectory.set',
'agent.ping',
] as const;
export type AgentAction = (typeof AGENT_ACTIONS)[number];
export function isAgentAction(value: unknown): value is AgentAction {
return typeof value === 'string' && (AGENT_ACTIONS as readonly string[]).includes(value);
}
/** Paramètres attendus par action (les autres actions n'en prennent aucun). */
export interface AgentActionParams {
'scene.set': { scene: string };
'profile.set': { profile: string };
'collection.set': { collection: string };
'recordDirectory.set': { directory: string };
}
// ---------------------------------------------------------------------------
// Statut remonté par un agent
// ---------------------------------------------------------------------------
export interface AgentStatus {
/** L'agent a-t-il une session obs-websocket établie ? */
obsConnected: boolean;
obsVersion?: string;
obsError?: string;
recording: boolean;
recordPaused: boolean;
/** Durée d'enregistrement au format HH:MM:SS.mmm renvoyé par OBS. */
recordTimecode?: string;
recordBytes?: number;
/** Chemin du dernier fichier écrit (renseigné à l'arrêt de l'enregistrement). */
lastRecordingPath?: string;
recordDirectory?: string;
streaming: boolean;
streamTimecode?: string;
currentScene?: string;
scenes: string[];
currentProfile?: string;
profiles: string[];
currentCollection?: string;
collections: string[];
/** Statistiques OBS. */
cpuUsage?: number;
fps?: number;
droppedFrames?: number;
renderSkippedFrames?: number;
/** Statistiques machine (collectées par l'agent, pas par OBS). */
systemCpu?: number;
systemMemoryUsed?: number;
systemMemoryTotal?: number;
diskFreeBytes?: number;
diskTotalBytes?: number;
updatedAt: number;
}
export function emptyStatus(): AgentStatus {
return {
obsConnected: false,
recording: false,
recordPaused: false,
streaming: false,
scenes: [],
profiles: [],
collections: [],
updatedAt: 0,
};
}
// ---------------------------------------------------------------------------
// Messages agent -> serveur
// ---------------------------------------------------------------------------
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface HelloMessage {
type: 'hello';
protocol: number;
/** Absent lors du tout premier enrôlement : le serveur en attribue un. */
agentId?: string;
name: string;
hostname: string;
platform: Platform;
agentVersion: string;
}
export interface StatusMessage {
type: 'status';
status: AgentStatus;
}
export interface ResultMessage {
type: 'result';
requestId: string;
ok: boolean;
data?: unknown;
error?: string;
}
export interface LogMessage {
type: 'log';
level: LogLevel;
message: string;
ts: number;
}
export interface PongMessage {
type: 'pong';
ts: number;
}
export type AgentToServer =
| HelloMessage
| StatusMessage
| ResultMessage
| LogMessage
| PongMessage;
// ---------------------------------------------------------------------------
// Messages serveur -> agent
// ---------------------------------------------------------------------------
export interface WelcomeMessage {
type: 'welcome';
agentId: string;
/** Fourni uniquement lors de l'enrôlement : l'agent doit le persister. */
token?: string;
obs: ObsSettings;
/** Fréquence de remontée de statut demandée. */
statusIntervalMs: number;
/** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */
autoConnectObs: boolean;
}
export interface CommandMessage {
type: 'command';
requestId: string;
action: AgentAction;
params?: Record<string, unknown>;
}
export interface ConfigMessage {
type: 'config';
obs: ObsSettings;
autoConnectObs: boolean;
}
export interface PingMessage {
type: 'ping';
ts: number;
}
export type ServerToAgent = WelcomeMessage | CommandMessage | ConfigMessage | PingMessage;
// ---------------------------------------------------------------------------
// Vue agrégée exposée au dashboard
// ---------------------------------------------------------------------------
export interface AgentView {
id: string;
name: string;
hostname: string | null;
platform: Platform;
agentVersion: string | null;
online: boolean;
lastSeenAt: number | null;
createdAt: number;
obs: ObsSettings;
autoConnectObs: boolean;
notes: string | null;
status: AgentStatus;
}
export interface LogEntry {
id: number;
agentId: string | null;
agentName: string | null;
level: LogLevel;
message: string;
ts: number;
}
export type ServerToDashboard =
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[] }
| { type: 'agent'; agent: AgentView }
| { type: 'agent.removed'; agentId: string }
| { type: 'log'; entry: LogEntry };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function detectPlatform(raw: string): Platform {
if (raw === 'win32') return 'windows';
if (raw === 'linux') return 'linux';
if (raw === 'darwin') return 'darwin';
return 'unknown';
}
export function safeJsonParse<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*.ts"]
}

13
packages/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>Stream Control</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
packages/web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@stream-control/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b --force && vite build",
"preview": "vite preview"
},
"dependencies": {
"@stream-control/shared": "*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}

189
packages/web/src/App.tsx Normal file
View File

@@ -0,0 +1,189 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { AgentAction, AgentView } from '@stream-control/shared';
import { api, getToken } from './api';
import { useRealtime } from './useRealtime';
import { Login } from './components/Login';
import { AgentCard } from './components/AgentCard';
import { AgentSettings } from './components/AgentSettings';
import { LogPanel } from './components/LogPanel';
interface Toast {
message: string;
tone: 'info' | 'error';
}
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 [toast, setToast] = useState<Toast | null>(null);
const [newAgentToken, setNewAgentToken] = useState<{ name: string; token: string } | null>(null);
const onUnauthorized = useCallback(() => setAuthenticated(false), []);
const { agents, logs, connected } = useRealtime(authenticated, onUnauthorized);
const notify = useCallback((message: string, tone: 'info' | 'error' = 'info') => {
setToast({ message, tone });
}, []);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(null), 4000);
return () => window.clearTimeout(timer);
}, [toast]);
// La fiche ouverte doit refléter les mises à jour temps réel.
const openAgent = useMemo(
() => (settingsFor ? (agents.find((agent) => agent.id === settingsFor.id) ?? null) : null),
[agents, settingsFor],
);
const online = agents.filter((agent) => agent.online);
const recording = agents.filter((agent) => agent.status.recording);
const runCommand = useCallback(
async (id: string, action: AgentAction, params?: Record<string, unknown>) => {
try {
await api.command(id, action, params);
} catch (err) {
notify(err instanceof Error ? err.message : 'Commande en échec', 'error');
}
},
[notify],
);
const runBulk = useCallback(
async (action: AgentAction) => {
const targets = selection.size > 0 ? [...selection] : online.map((agent) => agent.id);
if (targets.length === 0) {
notify('Aucun agent sélectionné', 'error');
return;
}
try {
const { results } = await api.bulk(targets, action);
const failures = results.filter((result) => !result.ok);
if (failures.length === 0) notify(`${results.length} agent(s) : commande envoyée`);
else
notify(
`${results.length - failures.length}/${results.length} OK — ${failures[0]?.error ?? ''}`,
'error',
);
} catch (err) {
notify(err instanceof Error ? err.message : 'Commande groupée en échec', 'error');
}
},
[notify, online, selection],
);
function toggleSelect(id: string) {
setSelection((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function addAgent() {
const name = prompt("Nom du nouvel agent (ex. vm-rec-01)")?.trim();
if (!name) return;
try {
const result = await api.createAgent({ name });
setNewAgentToken({ name, token: result.token });
} catch (err) {
notify(err instanceof Error ? err.message : 'Création impossible', 'error');
}
}
function logout() {
api.logout();
setAuthenticated(false);
}
if (!authenticated) {
return <Login onSuccess={() => setAuthenticated(true)} />;
}
return (
<div className="app">
<header className="topbar">
<div className="brand">
<span className={`dot ${connected ? 'ok' : 'offline'}`} />
<h1>Stream Control</h1>
<span className="muted small">
{online.length}/{agents.length} en ligne · {recording.length} en enregistrement
</span>
</div>
<div className="topbar-actions">
<button className="primary" onClick={() => void runBulk('record.start')}>
Enregistrer{selection.size > 0 ? ` (${selection.size})` : ' tout'}
</button>
<button className="danger" onClick={() => void runBulk('record.stop')}>
Arrêter{selection.size > 0 ? ` (${selection.size})` : ' tout'}
</button>
<button className="ghost" onClick={() => void runBulk('obs.connect')}>
Reconnecter OBS
</button>
<button className="ghost" onClick={() => void addAgent()}>
+ Agent
</button>
<button className="ghost" onClick={logout}>
Quitter
</button>
</div>
</header>
{!connected && (
<div className="banner warn">Flux temps réel interrompu reconnexion en cours</div>
)}
{newAgentToken && (
<div className="banner info">
<div>
Agent « {newAgentToken.name} » créé. Jeton (affiché une seule fois) :{' '}
<code className="token">{newAgentToken.token}</code>
</div>
<button className="ghost" onClick={() => setNewAgentToken(null)}>
Fermer
</button>
</div>
)}
<main className="grid">
{agents.length === 0 && (
<div className="empty">
<h2>Aucun agent enregistré</h2>
<p className="muted">
Crée un agent ici pour obtenir un jeton, ou démarre un agent avec le jeton
d'enrôlement : il apparaîtra automatiquement.
</p>
</div>
)}
{agents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
selected={selection.has(agent.id)}
onToggleSelect={toggleSelect}
onCommand={runCommand}
onOpenSettings={setSettingsFor}
/>
))}
</main>
<LogPanel logs={logs} />
{openAgent && (
<AgentSettings
agent={openAgent}
onClose={() => setSettingsFor(null)}
onCommand={runCommand}
notify={notify}
/>
)}
{toast && <div className={`toast ${toast.tone}`}>{toast.message}</div>}
</div>
);
}

106
packages/web/src/api.ts Normal file
View File

@@ -0,0 +1,106 @@
import type { AgentAction, AgentView, LogEntry } from '@stream-control/shared';
const TOKEN_KEY = 'stream-control.session';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null): void {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken();
const response = await fetch(`/api${path}`, {
...init,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
...init.headers,
},
});
if (response.status === 401) {
setToken(null);
throw new ApiError('Session expirée', 401);
}
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown> & T;
if (!response.ok) {
throw new ApiError(String(payload.error ?? response.statusText), response.status);
}
return payload;
}
export interface BulkResult {
agentId: string;
ok: boolean;
error?: string;
}
export const api = {
async login(password: string): Promise<void> {
const { token } = await request<{ token: string }>('/login', {
method: 'POST',
body: JSON.stringify({ password }),
});
setToken(token);
},
logout(): void {
setToken(null);
},
agents: () => request<{ agents: AgentView[] }>('/agents'),
logs: (limit = 200) => request<{ logs: LogEntry[] }>(`/logs?limit=${limit}`),
createAgent: (body: { name: string; notes?: string }) =>
request<{ agent: AgentView; token: string }>('/agents', {
method: 'POST',
body: JSON.stringify(body),
}),
updateAgent: (id: string, body: Partial<AgentView>) =>
request<{ agent: AgentView }>(`/agents/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
rotateToken: (id: string) =>
request<{ token: string }>(`/agents/${id}/token`, { method: 'POST' }),
deleteAgent: (id: string) => request<{ ok: true }>(`/agents/${id}`, { method: 'DELETE' }),
command: (id: string, action: AgentAction, params?: Record<string, unknown>) =>
request<{ ok: boolean; data?: unknown }>(`/agents/${id}/command`, {
method: 'POST',
body: JSON.stringify({ action, params }),
}),
bulk: (agentIds: string[], action: AgentAction, params?: Record<string, unknown>) =>
request<{ results: BulkResult[] }>('/commands/bulk', {
method: 'POST',
body: JSON.stringify({ agentIds, action, params }),
}),
enrollment: () =>
request<{ enabled: boolean; token: string | null; serverUrl: string }>('/enrollment'),
};
/** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */
export function dashboardSocketUrl(): string {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
return `${protocol}://${location.host}/ws/dashboard?token=${encodeURIComponent(getToken() ?? '')}`;
}

View File

@@ -0,0 +1,165 @@
import { useState } from 'react';
import type { AgentAction, AgentView } from '@stream-control/shared';
import { formatBytes, formatPercent, formatRelative, formatTimecode } from '../format';
interface Props {
agent: AgentView;
selected: boolean;
onToggleSelect: (id: string) => void;
onCommand: (id: string, action: AgentAction, params?: Record<string, unknown>) => Promise<void>;
onOpenSettings: (agent: AgentView) => void;
}
export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSettings }: Props) {
const [pending, setPending] = useState<AgentAction | null>(null);
const { status } = agent;
async function run(action: AgentAction, params?: Record<string, unknown>) {
setPending(action);
try {
await onCommand(agent.id, action, params);
} finally {
setPending(null);
}
}
const busy = pending !== null;
const obsReady = agent.online && status.obsConnected;
const state = !agent.online
? { label: 'Hors-ligne', tone: 'offline' as const }
: !status.obsConnected
? { label: 'OBS déconnecté', tone: 'warn' as const }
: status.recording
? {
label: status.recordPaused ? 'En pause' : 'Enregistre',
tone: status.recordPaused ? ('warn' as const) : ('rec' as const),
}
: { label: 'Prêt', tone: 'ok' as const };
return (
<article className={`card tone-${state.tone}${selected ? ' selected' : ''}`}>
<header className="card-head">
<label className="select">
<input
type="checkbox"
checked={selected}
onChange={() => onToggleSelect(agent.id)}
aria-label={`Sélectionner ${agent.name}`}
/>
</label>
<div className="identity">
<h2>{agent.name}</h2>
<span className="muted small">
{agent.hostname ?? '—'} · {agent.platform}
{agent.agentVersion ? ` · v${agent.agentVersion}` : ''}
</span>
</div>
<span className={`badge ${state.tone}`}>{state.label}</span>
<button className="icon" onClick={() => onOpenSettings(agent)} title="Configuration">
</button>
</header>
{status.obsError && !status.obsConnected && (
<p className="error small">{status.obsError}</p>
)}
<div className="metrics">
<Metric label="Durée" value={formatTimecode(status.recordTimecode)} mono />
<Metric label="Fichier" value={formatBytes(status.recordBytes)} />
<Metric label="Disque libre" value={formatBytes(status.diskFreeBytes)} />
<Metric label="CPU OBS" value={formatPercent(status.cpuUsage)} />
<Metric label="FPS" value={status.fps ? status.fps.toFixed(0) : '—'} />
<Metric label="Frames perdues" value={String(status.droppedFrames ?? '—')} />
</div>
<div className="row">
<label className="field grow">
<span>Scène</span>
<select
value={status.currentScene ?? ''}
disabled={!obsReady || busy || status.scenes.length === 0}
onChange={(event) => void run('scene.set', { scene: event.target.value })}
>
{status.scenes.length === 0 && <option value=""> aucune scène </option>}
{status.scenes.map((scene) => (
<option key={scene} value={scene}>
{scene}
</option>
))}
</select>
</label>
</div>
<div className="actions">
{status.recording ? (
<>
<button className="danger" disabled={busy} onClick={() => void run('record.stop')}>
Arrêter
</button>
{status.recordPaused ? (
<button disabled={busy} onClick={() => void run('record.resume')}>
Reprendre
</button>
) : (
<button disabled={busy} onClick={() => void run('record.pause')}>
Pause
</button>
)}
<button disabled={busy} onClick={() => void run('record.split')}>
Découper
</button>
</>
) : (
<button className="primary" disabled={!obsReady || busy} onClick={() => void run('record.start')}>
Enregistrer
</button>
)}
{status.streaming ? (
<button className="danger ghost" disabled={busy} onClick={() => void run('stream.stop')}>
Arrêter le stream
</button>
) : (
<button className="ghost" disabled={!obsReady || busy} onClick={() => void run('stream.start')}>
Lancer le stream
</button>
)}
{status.obsConnected ? (
<button className="ghost" disabled={!agent.online || busy} onClick={() => void run('obs.disconnect')}>
Détacher OBS
</button>
) : (
<button className="ghost" disabled={!agent.online || busy} onClick={() => void run('obs.connect')}>
Connecter OBS
</button>
)}
</div>
<footer className="card-foot muted small">
<span title={status.recordDirectory ?? ''}>
{status.lastRecordingPath
? `Dernier fichier : ${basename(status.lastRecordingPath)}`
: (status.recordDirectory ?? 'Dossier inconnu')}
</span>
<span>vu {formatRelative(agent.lastSeenAt)}</span>
</footer>
</article>
);
}
function Metric({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="metric">
<span className="metric-label">{label}</span>
<span className={`metric-value${mono ? ' mono' : ''}`}>{value}</span>
</div>
);
}
function basename(filePath: string): string {
const parts = filePath.split(/[\\/]/);
return parts[parts.length - 1] ?? filePath;
}

View File

@@ -0,0 +1,161 @@
import { useState } from 'react';
import type { AgentAction, AgentView } from '@stream-control/shared';
import { api } from '../api';
interface Props {
agent: AgentView;
onClose: () => void;
onCommand: (id: string, action: AgentAction, params?: Record<string, unknown>) => Promise<void>;
notify: (message: string, tone?: 'info' | 'error') => void;
}
export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
const [name, setName] = useState(agent.name);
const [host, setHost] = useState(agent.obs.host);
const [port, setPort] = useState(String(agent.obs.port));
const [password, setPassword] = useState(agent.obs.password);
const [autoConnect, setAutoConnect] = useState(agent.autoConnectObs);
const [notes, setNotes] = useState(agent.notes ?? '');
const [directory, setDirectory] = useState(agent.status.recordDirectory ?? '');
const [token, setToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function save() {
setBusy(true);
try {
await api.updateAgent(agent.id, {
name,
notes,
autoConnectObs: autoConnect,
obs: { host, port: Number(port), password },
} as Partial<AgentView>);
notify('Configuration enregistrée');
onClose();
} catch (err) {
notify(err instanceof Error ? err.message : 'Enregistrement impossible', 'error');
} finally {
setBusy(false);
}
}
async function applyDirectory() {
if (!directory.trim()) return;
await onCommand(agent.id, 'recordDirectory.set', { directory: directory.trim() });
}
async function rotate() {
if (!confirm("Régénérer le jeton ? L'agent sera déconnecté jusqu'à sa reconfiguration.")) return;
try {
const result = await api.rotateToken(agent.id);
setToken(result.token);
} catch (err) {
notify(err instanceof Error ? err.message : 'Rotation impossible', 'error');
}
}
async function remove() {
if (!confirm(`Supprimer définitivement l'agent « ${agent.name} » ?`)) return;
try {
await api.deleteAgent(agent.id);
notify('Agent supprimé');
onClose();
} catch (err) {
notify(err instanceof Error ? err.message : 'Suppression impossible', 'error');
}
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<header className="modal-head">
<h2>Configuration · {agent.name}</h2>
<button className="icon" onClick={onClose}>
</button>
</header>
<div className="modal-body">
<label className="field">
<span>Nom affiché</span>
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<div className="row">
<label className="field grow">
<span>Hôte obs-websocket</span>
<input value={host} onChange={(event) => setHost(event.target.value)} />
</label>
<label className="field small-field">
<span>Port</span>
<input value={port} onChange={(event) => setPort(event.target.value)} inputMode="numeric" />
</label>
</div>
<label className="field">
<span>Mot de passe obs-websocket</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="inchangé"
/>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={autoConnect}
onChange={(event) => setAutoConnect(event.target.checked)}
/>
<span>Connecter OBS automatiquement au démarrage de l'agent</span>
</label>
<label className="field">
<span>Notes</span>
<textarea rows={2} value={notes} onChange={(event) => setNotes(event.target.value)} />
</label>
<div className="row">
<label className="field grow">
<span>Dossier d'enregistrement OBS</span>
<input
value={directory}
onChange={(event) => setDirectory(event.target.value)}
placeholder="D:\\records ou /srv/records"
/>
</label>
<button className="ghost align-end" onClick={() => void applyDirectory()}>
Appliquer
</button>
</div>
<div className="danger-zone">
<div>
<strong>Jeton d'agent</strong>
<p className="muted small">
Affiché une seule fois. À reporter dans le fichier <code>agent.config.json</code> de la VM.
</p>
{token && <code className="token">{token}</code>}
</div>
<button className="ghost" onClick={() => void rotate()}>
Régénérer
</button>
</div>
</div>
<footer className="modal-foot">
<button className="danger ghost" onClick={() => void remove()}>
Supprimer l'agent
</button>
<div className="spacer" />
<button className="ghost" onClick={onClose}>
Annuler
</button>
<button className="primary" disabled={busy} onClick={() => void save()}>
Enregistrer
</button>
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { useEffect, useRef } from 'react';
import type { LogEntry } from '@stream-control/shared';
import { formatTime } from '../format';
export function LogPanel({ logs }: { logs: LogEntry[] }) {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ block: 'end' });
}, [logs.length]);
return (
<section className="logs">
<h3>Journal</h3>
<div className="log-list">
{logs.length === 0 && <p className="muted small">Aucun évènement pour le moment.</p>}
{logs.map((entry) => (
<div key={entry.id} className={`log-line level-${entry.level}`}>
<span className="mono small muted">{formatTime(entry.ts)}</span>
<span className="log-agent">{entry.agentName ?? 'serveur'}</span>
<span className="log-message">{entry.message}</span>
</div>
))}
<div ref={endRef} />
</div>
</section>
);
}

View File

@@ -0,0 +1,42 @@
import { useState, type FormEvent } from 'react';
import { api } from '../api';
export function Login({ onSuccess }: { onSuccess: () => void }) {
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
try {
await api.login(password);
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : 'Connexion impossible');
} finally {
setBusy(false);
}
}
return (
<div className="login">
<form className="login-card" onSubmit={submit}>
<h1>Stream Control</h1>
<p className="muted">Pilotage des agents OBS d'enregistrement</p>
<input
type="password"
placeholder="Mot de passe"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoFocus
/>
{error && <div className="error">{error}</div>}
<button type="submit" className="primary" disabled={busy || !password}>
{busy ? 'Connexion' : 'Se connecter'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,38 @@
export function formatBytes(bytes: number | undefined): string {
if (bytes === undefined || !Number.isFinite(bytes)) return '—';
const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
/** OBS renvoie HH:MM:SS.mmm — on retire les millisecondes. */
export function formatTimecode(timecode: string | undefined): string {
if (!timecode) return '00:00:00';
return timecode.split('.')[0] ?? timecode;
}
export function formatRelative(ts: number | null | undefined): string {
if (!ts) return 'jamais';
const seconds = Math.round((Date.now() - ts) / 1000);
if (seconds < 5) return "à l'instant";
if (seconds < 60) return `il y a ${seconds} s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `il y a ${minutes} min`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `il y a ${hours} h`;
return new Date(ts).toLocaleString('fr-FR');
}
export function formatPercent(value: number | undefined): string {
if (value === undefined || !Number.isFinite(value)) return '—';
return `${value.toFixed(1)} %`;
}
export function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString('fr-FR');
}

13
packages/web/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
const container = document.getElementById('root');
if (!container) throw new Error('#root introuvable');
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);

474
packages/web/src/styles.css Normal file
View File

@@ -0,0 +1,474 @@
:root {
--bg: #0e1116;
--panel: #161b22;
--panel-2: #1c232c;
--border: #2a323d;
--text: #e6edf3;
--muted: #8b97a6;
--accent: #3b82f6;
--ok: #22c55e;
--warn: #f59e0b;
--rec: #ef4444;
--radius: 10px;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
}
h1 {
font-size: 18px;
}
h2 {
font-size: 15px;
}
h3 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.muted {
color: var(--muted);
}
.small {
font-size: 12px;
}
.mono {
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}
.error {
color: #fca5a5;
}
.spacer {
flex: 1;
}
/* --- Boutons & champs --- */
button {
font: inherit;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
cursor: pointer;
transition: filter 0.12s ease;
}
button:hover:not(:disabled) {
filter: brightness(1.25);
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
button.primary {
background: var(--accent);
border-color: transparent;
font-weight: 600;
}
button.danger {
background: var(--rec);
border-color: transparent;
font-weight: 600;
}
button.ghost {
background: transparent;
}
button.danger.ghost {
color: #fca5a5;
border-color: #5b2727;
}
button.icon {
padding: 4px 8px;
background: transparent;
border-color: transparent;
color: var(--muted);
}
input,
select,
textarea {
font: inherit;
width: 100%;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: #0f141a;
color: var(--text);
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field > span {
font-size: 12px;
color: var(--muted);
}
.field.grow {
flex: 1;
}
.field.small-field {
width: 90px;
}
.row {
display: flex;
gap: 10px;
align-items: flex-start;
}
.align-end {
align-self: flex-end;
}
.checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.checkbox input {
width: auto;
}
/* --- Connexion --- */
.login {
min-height: 100vh;
display: grid;
place-items: center;
}
.login-card {
width: min(360px, 90vw);
display: flex;
flex-direction: column;
gap: 12px;
padding: 28px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
/* --- Structure --- */
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.topbar-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
}
.dot.ok {
background: var(--ok);
box-shadow: 0 0 8px var(--ok);
}
.dot.offline {
background: var(--rec);
}
.banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
}
.banner.warn {
background: #3a2a0c;
}
.banner.info {
background: #10243d;
}
.grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: 14px;
padding: 18px;
align-content: start;
}
.empty {
grid-column: 1 / -1;
padding: 48px;
text-align: center;
border: 1px dashed var(--border);
border-radius: var(--radius);
}
/* --- Carte agent --- */
.card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: var(--radius);
}
.card.selected {
outline: 1px solid var(--accent);
}
.card.tone-rec {
border-left-color: var(--rec);
}
.card.tone-ok {
border-left-color: var(--ok);
}
.card.tone-warn {
border-left-color: var(--warn);
}
.card.tone-offline {
border-left-color: #444c57;
opacity: 0.75;
}
.card-head {
display: flex;
align-items: center;
gap: 8px;
}
.identity {
flex: 1;
min-width: 0;
}
.identity h2 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.select input {
width: auto;
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: 999px;
background: var(--panel-2);
white-space: nowrap;
}
.badge.rec {
background: var(--rec);
}
.badge.ok {
background: #14532d;
color: #86efac;
}
.badge.warn {
background: #4a3208;
color: #fcd34d;
}
.badge.offline {
color: var(--muted);
}
.metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 10px;
background: var(--panel-2);
border-radius: 8px;
}
.metric {
display: flex;
flex-direction: column;
min-width: 0;
}
.metric-label {
font-size: 11px;
color: var(--muted);
}
.metric-value {
font-size: 14px;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.card-foot {
display: flex;
justify-content: space-between;
gap: 10px;
border-top: 1px solid var(--border);
padding-top: 8px;
}
.card-foot span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- Journal --- */
.logs {
border-top: 1px solid var(--border);
background: var(--panel);
padding: 12px 18px;
}
.log-list {
max-height: 220px;
overflow-y: auto;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.log-line {
display: grid;
grid-template-columns: 80px 140px 1fr;
gap: 10px;
padding: 2px 0;
font-size: 12.5px;
}
.log-agent {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-line.level-warn .log-message {
color: #fcd34d;
}
.log-line.level-error .log-message {
color: #fca5a5;
}
/* --- Modale --- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: grid;
place-items: center;
padding: 20px;
z-index: 50;
}
.modal {
width: min(560px, 100%);
max-height: 90vh;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.modal-head,
.modal-foot {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 18px;
}
.modal-head {
border-bottom: 1px solid var(--border);
}
.modal-foot {
border-top: 1px solid var(--border);
}
.modal-body {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
}
.danger-zone {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
}
.token {
display: inline-block;
margin-top: 6px;
padding: 4px 8px;
background: #0f141a;
border-radius: 6px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
word-break: break-all;
}
/* --- Toast --- */
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
padding: 10px 18px;
border-radius: 999px;
background: var(--panel-2);
border: 1px solid var(--border);
z-index: 100;
}
.toast.error {
background: #4a1717;
border-color: #7f1d1d;
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import type { AgentView, LogEntry, ServerToDashboard } from '@stream-control/shared';
import { dashboardSocketUrl } from './api';
const MAX_LOGS = 400;
export interface RealtimeState {
agents: AgentView[];
logs: LogEntry[];
connected: boolean;
}
/**
* Maintient une connexion au flux `/ws/dashboard` avec reconnexion automatique
* et applique les mises à jour incrémentales d'agents et de journal.
*/
export function useRealtime(enabled: boolean, onUnauthorized: () => void): RealtimeState {
const [agents, setAgents] = useState<AgentView[]>([]);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [connected, setConnected] = useState(false);
const retryRef = useRef(1000);
useEffect(() => {
if (!enabled) {
setAgents([]);
setLogs([]);
setConnected(false);
return;
}
let socket: WebSocket | null = null;
let retryTimer: number | undefined;
let closed = false;
const open = () => {
socket = new WebSocket(dashboardSocketUrl());
socket.onopen = () => {
retryRef.current = 1000;
setConnected(true);
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data as string) as ServerToDashboard;
switch (message.type) {
case 'snapshot':
setAgents(message.agents);
setLogs(message.logs);
break;
case 'agent':
setAgents((current) => {
const index = current.findIndex((agent) => agent.id === message.agent.id);
if (index === -1) return [...current, message.agent];
const next = [...current];
next[index] = message.agent;
return next;
});
break;
case 'agent.removed':
setAgents((current) => current.filter((agent) => agent.id !== message.agentId));
break;
case 'log':
setLogs((current) => [...current, message.entry].slice(-MAX_LOGS));
break;
}
};
socket.onclose = (event) => {
setConnected(false);
if (closed) return;
// 1008/4401 côté serveur, ou refus d'upgrade : la session n'est plus valide.
if (event.code === 1006 && retryRef.current > 8000) onUnauthorized();
retryTimer = window.setTimeout(open, retryRef.current);
retryRef.current = Math.min(retryRef.current * 2, 15_000);
};
};
open();
return () => {
closed = true;
window.clearTimeout(retryTimer);
socket?.close();
};
}, [enabled, onUnauthorized]);
return { agents, logs, connected };
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const backend = process.env.BACKEND_URL ?? 'http://127.0.0.1:8080';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': { target: backend, changeOrigin: true },
'/ws': { target: backend, ws: true, changeOrigin: true },
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});