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

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