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