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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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