Files
stream-control/packages/web/src/App.tsx
jeanotx32 6f11b72cbb FP
2026-08-11 00:26:56 +02:00

190 lines
6.1 KiB
TypeScript

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