FP
This commit is contained in:
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user