190 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|