FP
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# --- Serveur de contrôle ---
|
||||
PORT=8080
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Mot de passe d'accès au dashboard web
|
||||
ADMIN_PASSWORD=change-me
|
||||
|
||||
# Secret utilisé pour signer les sessions du dashboard (32+ caractères aléatoires)
|
||||
# Générer : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
SESSION_SECRET=change-me-please-with-a-long-random-string
|
||||
|
||||
# Jeton d'enrôlement : un agent qui se connecte avec ce jeton s'auto-enregistre
|
||||
# et reçoit son propre jeton permanent. Mettre à vide pour désactiver l'enrôlement.
|
||||
ENROLLMENT_TOKEN=change-me-enrollment-token
|
||||
|
||||
# Base SQLite
|
||||
DB_PATH=./data/stream-control.sqlite
|
||||
|
||||
# Intervalle de remontée de statut demandé aux agents (ms)
|
||||
STATUS_INTERVAL_MS=2000
|
||||
|
||||
# Délai avant de considérer un agent comme hors-ligne (ms)
|
||||
AGENT_TIMEOUT_MS=15000
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
data/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
*.log
|
||||
packages/agent/agent.config.json
|
||||
.DS_Store
|
||||
161
README.md
Normal file
161
README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Stream Control
|
||||
|
||||
Plan de contrôle web pour piloter des enregistrements OBS répartis sur plusieurs VM/VPS
|
||||
(Windows et Ubuntu) depuis une seule interface.
|
||||
|
||||
```
|
||||
┌────────────┐ HTTPS + WS ┌──────────────────┐ WS sortant ┌──────────────────┐
|
||||
│ Navigateur │ ───────────────►│ Serveur de │◄────────────────│ Agent (VM) │
|
||||
│ dashboard │◄─── temps réel ─│ contrôle │──── commandes ─►│ └► OBS (4455) │
|
||||
└────────────┘ │ Express + SQLite│ └──────────────────┘
|
||||
└──────────────────┘ ┌──────────────────┐
|
||||
◄────────────────│ Agent (VM) … │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
**Le sens des connexions est le point clé** : ce sont les agents qui se connectent au
|
||||
serveur, pas l'inverse. Aucun port entrant à ouvrir sur les VM d'enregistrement, aucune
|
||||
IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Découverte automatique des agents par jeton d'enrôlement, ou provisionnement manuel.
|
||||
- État temps réel par VM : OBS connecté, enregistrement en cours, durée, taille du
|
||||
fichier, scène active, FPS, frames perdues, CPU, espace disque restant.
|
||||
- Commandes unitaires ou groupées : démarrer/arrêter/mettre en pause/découper un
|
||||
enregistrement, démarrer/arrêter un stream, changer de scène, de profil, de collection,
|
||||
changer le dossier d'enregistrement.
|
||||
- Journal d'évènements horodaté, persistant et diffusé en direct.
|
||||
- Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur).
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Node.js **22+** sur le serveur et sur chaque VM (le serveur utilise `node:sqlite`).
|
||||
- OBS **28+** sur chaque VM, avec *Outils → Paramètres du serveur WebSocket* activé.
|
||||
Note le port (4455 par défaut) et le mot de passe.
|
||||
|
||||
## Démarrage rapide (développement)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # renseigne ADMIN_PASSWORD, SESSION_SECRET, ENROLLMENT_TOKEN
|
||||
npm run dev # serveur :8080 + dashboard Vite :5173
|
||||
```
|
||||
|
||||
Puis, dans un autre terminal, un agent local :
|
||||
|
||||
```bash
|
||||
cd packages/agent
|
||||
cp agent.config.example.json agent.config.json # colle le ENROLLMENT_TOKEN dans "token"
|
||||
npm run dev
|
||||
```
|
||||
|
||||
L'agent s'enrôle, reçoit son jeton permanent, le réécrit dans `agent.config.json`, et
|
||||
apparaît dans le dashboard sur <http://localhost:5173>.
|
||||
|
||||
## Déploiement
|
||||
|
||||
### Serveur de contrôle
|
||||
|
||||
```bash
|
||||
npm ci && npm run build
|
||||
npm start # sert l'API, le WebSocket et le dashboard compilé sur $PORT
|
||||
```
|
||||
|
||||
Mets-le derrière un reverse proxy TLS ([exemple nginx](deploy/nginx.conf.example)) et
|
||||
installe l'unité systemd [`deploy/stream-control-server.service`](deploy/stream-control-server.service).
|
||||
`ADMIN_PASSWORD` et `SESSION_SECRET` sont obligatoires hors développement.
|
||||
|
||||
### Agent Ubuntu
|
||||
|
||||
Copie `packages/agent/dist`, `packages/agent/node_modules` et `agent.config.json` dans
|
||||
`/opt/stream-control-agent`, puis installe
|
||||
[`deploy/stream-control-agent.service`](deploy/stream-control-agent.service).
|
||||
|
||||
### Agent Windows
|
||||
|
||||
Un service Windows classique tourne en session 0 et ne verrait pas OBS. Utilise la tâche
|
||||
planifiée « à l'ouverture de session » fournie :
|
||||
|
||||
```powershell
|
||||
.\deploy\windows-agent-task.ps1 -AgentDir 'C:\stream-control-agent' -RunAsUser 'VM01\obs'
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Serveur — `.env` (voir [.env.example](.env.example))
|
||||
|
||||
| Variable | Rôle |
|
||||
| --- | --- |
|
||||
| `PORT`, `HOST` | Écoute HTTP/WebSocket |
|
||||
| `ADMIN_PASSWORD` | Mot de passe du dashboard |
|
||||
| `SESSION_SECRET` | Clé HMAC des sessions (32+ octets aléatoires) |
|
||||
| `ENROLLMENT_TOKEN` | Jeton d'auto-enregistrement ; vide = désactivé |
|
||||
| `DB_PATH` | Fichier SQLite |
|
||||
| `STATUS_INTERVAL_MS` | Fréquence de remontée d'état des agents |
|
||||
| `AGENT_TIMEOUT_MS` | Délai avant de déclarer un agent hors-ligne |
|
||||
|
||||
### Agent — `agent.config.json` (ou variables d'environnement)
|
||||
|
||||
| Clé | Variable | Rôle |
|
||||
| --- | --- | --- |
|
||||
| `serverUrl` | `SERVER_URL` | `wss://control.exemple.com/ws/agent` |
|
||||
| `token` | `AGENT_TOKEN` | Jeton d'enrôlement puis jeton propre à l'agent |
|
||||
| `name` | `AGENT_NAME` | Nom affiché (défaut : hostname) |
|
||||
| `obs.host/port/password` | `OBS_HOST` / `OBS_PORT` / `OBS_PASSWORD` | Repli local ; le serveur pousse la config de référence |
|
||||
| `insecureTls` | `INSECURE_TLS=1` | Accepter un certificat auto-signé |
|
||||
|
||||
Les paramètres OBS édités dans le dashboard sont poussés à chaud vers l'agent : pas
|
||||
besoin de se connecter à la VM pour changer un mot de passe obs-websocket.
|
||||
|
||||
## API HTTP
|
||||
|
||||
Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de session>`.
|
||||
|
||||
| Méthode | Route | Rôle |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/login` | Ouvre une session (`{ password }`) |
|
||||
| `GET` | `/api/agents` | Liste des agents et de leur état |
|
||||
| `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** |
|
||||
| `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion |
|
||||
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
|
||||
| `DELETE` | `/api/agents/:id` | Supprime l'agent |
|
||||
| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent |
|
||||
| `POST` | `/api/commands/bulk` | Même action sur plusieurs agents, résultat par agent |
|
||||
| `GET` | `/api/logs?limit=` | Journal récent |
|
||||
| `GET` | `/healthz` | Sonde de vie (non authentifiée) |
|
||||
|
||||
Actions disponibles : `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`.
|
||||
|
||||
## Structure
|
||||
|
||||
| Paquet | Rôle |
|
||||
| --- | --- |
|
||||
| [packages/shared/](packages/shared/) | Types du protocole, partagés par les trois autres |
|
||||
| [packages/server/](packages/server/) | API REST, passerelle agents, diffusion dashboard, SQLite |
|
||||
| [packages/agent/](packages/agent/) | Binaire à déployer sur chaque VM, pilote OBS |
|
||||
| [packages/web/](packages/web/) | Dashboard React/Vite |
|
||||
|
||||
Points d'entrée utiles : [packages/shared/src/index.ts](packages/shared/src/index.ts)
|
||||
(le protocole), [packages/server/src/hub.ts](packages/server/src/hub.ts) (état central et
|
||||
dispatch), [packages/agent/src/obs.ts](packages/agent/src/obs.ts) (traduction
|
||||
action → obs-websocket).
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Les jetons d'agent ne sont stockés qu'en SHA-256 ; le clair n'est affiché qu'à la création.
|
||||
- Le mot de passe obs-websocket n'est jamais renvoyé au navigateur (masqué en `********`).
|
||||
- Les sessions dashboard sont des jetons HMAC à durée limitée, sans état serveur.
|
||||
- **Sers l'application en HTTPS/WSS** : jetons d'agent et de session circulent dans les
|
||||
en-têtes et les URL de WebSocket.
|
||||
- Après un `POST /api/agents/:id/token`, l'agent est déconnecté jusqu'à ce que son
|
||||
`agent.config.json` soit mis à jour.
|
||||
|
||||
## Pistes d'évolution
|
||||
|
||||
Enregistrements programmés (cron par agent), rapatriement automatique des fichiers
|
||||
(rclone/S3 déclenché à `record.stop`), alertes sur seuil d'espace disque ou de frames
|
||||
perdues, comptes utilisateurs multiples, groupes d'agents.
|
||||
33
deploy/nginx.conf.example
Normal file
33
deploy/nginx.conf.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# Reverse proxy TLS devant le serveur de contrôle.
|
||||
# Indispensable en production : les jetons d'agent et de session transitent en clair sinon.
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name control.exemple.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/control.exemple.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/control.exemple.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Upgrade WebSocket (/ws/agent et /ws/dashboard)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Les sessions agent sont longues : pas de coupure prématurée.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name control.exemple.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
25
deploy/stream-control-agent.service
Normal file
25
deploy/stream-control-agent.service
Normal file
@@ -0,0 +1,25 @@
|
||||
# Agent Ubuntu — /etc/systemd/system/stream-control-agent.service
|
||||
#
|
||||
# L'agent doit tourner dans la MÊME session que OBS s'il pilote un OBS graphique.
|
||||
# Sur une VM headless, OBS tourne généralement sous Xvfb : garder le même User.
|
||||
#
|
||||
# systemctl daemon-reload && systemctl enable --now stream-control-agent
|
||||
|
||||
[Unit]
|
||||
Description=Stream Control — agent OBS
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=obs
|
||||
WorkingDirectory=/opt/stream-control-agent
|
||||
Environment=NODE_ENV=production
|
||||
# Chemin du fichier d'identité (jeton + agentId) — doit être inscriptible.
|
||||
Environment=AGENT_CONFIG=/opt/stream-control-agent/agent.config.json
|
||||
ExecStart=/usr/bin/node /opt/stream-control-agent/dist/index.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
26
deploy/stream-control-server.service
Normal file
26
deploy/stream-control-server.service
Normal file
@@ -0,0 +1,26 @@
|
||||
# Serveur de contrôle — /etc/systemd/system/stream-control-server.service
|
||||
# systemctl daemon-reload && systemctl enable --now stream-control-server
|
||||
|
||||
[Unit]
|
||||
Description=Stream Control — serveur de contrôle
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stream
|
||||
WorkingDirectory=/opt/stream-control
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/bin/node --disable-warning=ExperimentalWarning packages/server/dist/index.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Durcissement
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/stream-control/data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
41
deploy/windows-agent-task.ps1
Normal file
41
deploy/windows-agent-task.ps1
Normal file
@@ -0,0 +1,41 @@
|
||||
# Installe l'agent Stream Control comme tâche planifiée Windows.
|
||||
#
|
||||
# L'agent doit s'exécuter dans la session interactive de l'utilisateur qui lance
|
||||
# OBS : un service Windows classique tourne en session 0 et ne verrait pas OBS.
|
||||
# On utilise donc une tâche « à l'ouverture de session » avec redémarrage auto.
|
||||
#
|
||||
# Usage (PowerShell administrateur) :
|
||||
# .\windows-agent-task.ps1 -AgentDir 'C:\stream-control-agent' -RunAsUser 'VM01\obs'
|
||||
|
||||
param(
|
||||
[string]$AgentDir = 'C:\stream-control-agent',
|
||||
[string]$RunAsUser = "$env:USERDOMAIN\$env:USERNAME",
|
||||
[string]$TaskName = 'StreamControlAgent'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$node = (Get-Command node).Source
|
||||
$entry = Join-Path $AgentDir 'dist\index.js'
|
||||
|
||||
if (-not (Test-Path $entry)) {
|
||||
throw "Agent introuvable : $entry — copie d'abord packages/agent (dist + node_modules) dans $AgentDir"
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $AgentDir 'agent.config.json'))) {
|
||||
Write-Warning "agent.config.json absent de $AgentDir : l'agent refusera de démarrer sans jeton."
|
||||
}
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $node -Argument "`"$entry`"" -WorkingDirectory $AgentDir
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $RunAsUser
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $RunAsUser -LogonType Interactive -RunLevel Limited
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
|
||||
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
|
||||
-Principal $principal -Settings $settings -Force | Out-Null
|
||||
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
Write-Host "Tâche « $TaskName » installée et démarrée pour $RunAsUser."
|
||||
Write-Host "Journal : Get-ScheduledTaskInfo -TaskName $TaskName"
|
||||
3279
package-lock.json
generated
Normal file
3279
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "stream-control",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Plan de contrôle web pour piloter des agents OBS sur plusieurs VM/VPS",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/shared",
|
||||
"packages/server",
|
||||
"packages/agent",
|
||||
"packages/web"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build -w @stream-control/shared && npm run build -w @stream-control/server && npm run build -w @stream-control/agent && npm run build -w @stream-control/web",
|
||||
"build:shared": "npm run build -w @stream-control/shared",
|
||||
"dev": "npm run build:shared && concurrently -n server,web -c cyan,magenta \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"dev:server": "npm run dev -w @stream-control/server",
|
||||
"dev:web": "npm run dev -w @stream-control/web",
|
||||
"dev:agent": "npm run dev -w @stream-control/agent",
|
||||
"start": "npm run start -w @stream-control/server",
|
||||
"typecheck": "tsc -b packages/shared packages/server packages/agent --pretty"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
11
packages/agent/agent.config.example.json
Normal file
11
packages/agent/agent.config.example.json
Normal 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
|
||||
}
|
||||
25
packages/agent/package.json
Normal file
25
packages/agent/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
79
packages/agent/src/config.ts
Normal file
79
packages/agent/src/config.ts
Normal 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
215
packages/agent/src/index.ts
Normal 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
313
packages/agent/src/obs.ts
Normal 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;
|
||||
}
|
||||
56
packages/agent/src/system.ts
Normal file
56
packages/agent/src/system.ts
Normal 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 };
|
||||
}
|
||||
13
packages/agent/tsconfig.json
Normal file
13
packages/agent/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
24
packages/server/package.json
Normal file
24
packages/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
154
packages/server/src/agentGateway.ts
Normal file
154
packages/server/src/agentGateway.ts
Normal 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
209
packages/server/src/api.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
63
packages/server/src/auth.ts
Normal file
63
packages/server/src/auth.ts
Normal 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();
|
||||
}
|
||||
55
packages/server/src/config.ts
Normal file
55
packages/server/src/config.ts
Normal 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
255
packages/server/src/db.ts
Normal 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
267
packages/server/src/hub.ts
Normal 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();
|
||||
111
packages/server/src/index.ts
Normal file
111
packages/server/src/index.ts
Normal 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'));
|
||||
13
packages/server/tsconfig.json
Normal file
13
packages/server/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
22
packages/shared/package.json
Normal file
22
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
257
packages/shared/src/index.ts
Normal file
257
packages/shared/src/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
13
packages/web/index.html
Normal file
13
packages/web/index.html
Normal 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
23
packages/web/package.json
Normal 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
189
packages/web/src/App.tsx
Normal 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
106
packages/web/src/api.ts
Normal 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() ?? '')}`;
|
||||
}
|
||||
165
packages/web/src/components/AgentCard.tsx
Normal file
165
packages/web/src/components/AgentCard.tsx
Normal 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;
|
||||
}
|
||||
161
packages/web/src/components/AgentSettings.tsx
Normal file
161
packages/web/src/components/AgentSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
packages/web/src/components/LogPanel.tsx
Normal file
28
packages/web/src/components/LogPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
packages/web/src/components/Login.tsx
Normal file
42
packages/web/src/components/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
packages/web/src/format.ts
Normal file
38
packages/web/src/format.ts
Normal 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
13
packages/web/src/main.tsx
Normal 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
474
packages/web/src/styles.css
Normal 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;
|
||||
}
|
||||
88
packages/web/src/useRealtime.ts
Normal file
88
packages/web/src/useRealtime.ts
Normal 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 };
|
||||
}
|
||||
19
packages/web/tsconfig.json
Normal file
19
packages/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
19
packages/web/vite.config.ts
Normal file
19
packages/web/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
18
tsconfig.base.json
Normal file
18
tsconfig.base.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user