Feat : Log recorder
This commit is contained in:
@@ -5,6 +5,7 @@ import { useRealtime } from './useRealtime';
|
||||
import { Login } from './components/Login';
|
||||
import { AgentCard } from './components/AgentCard';
|
||||
import { AgentSettings } from './components/AgentSettings';
|
||||
import { AgentHistory } from './components/AgentHistory';
|
||||
import { LogPanel } from './components/LogPanel';
|
||||
import { StreamersPage } from './components/StreamersPage';
|
||||
import { useHashRoute } from './useHashRoute';
|
||||
@@ -18,6 +19,7 @@ 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 [historyFor, setHistoryFor] = useState<AgentView | null>(null);
|
||||
const [toast, setToast] = useState<Toast | null>(null);
|
||||
const [newAgentToken, setNewAgentToken] = useState<{ name: string; token: string } | null>(null);
|
||||
|
||||
@@ -78,6 +80,10 @@ export function App() {
|
||||
() => (settingsFor ? (agents.find((agent) => agent.id === settingsFor.id) ?? null) : null),
|
||||
[agents, settingsFor],
|
||||
);
|
||||
const historyAgent = useMemo(
|
||||
() => (historyFor ? (agents.find((agent) => agent.id === historyFor.id) ?? null) : null),
|
||||
[agents, historyFor],
|
||||
);
|
||||
|
||||
const online = agents.filter((agent) => agent.online);
|
||||
const recording = agents.filter((agent) => agent.status.recording);
|
||||
@@ -241,6 +247,7 @@ export function App() {
|
||||
onToggleSelect={toggleSelect}
|
||||
onCommand={runCommand}
|
||||
onOpenSettings={setSettingsFor}
|
||||
onOpenHistory={setHistoryFor}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
@@ -258,6 +265,14 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{historyAgent && (
|
||||
<AgentHistory
|
||||
agent={historyAgent}
|
||||
liveLogs={logs}
|
||||
onClose={() => setHistoryFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toast && <div className={`toast ${toast.tone}`}>{toast.message}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -66,6 +66,10 @@ export const api = {
|
||||
|
||||
logs: (limit = 200) => request<{ logs: LogEntry[] }>(`/logs?limit=${limit}`),
|
||||
|
||||
/** Historique d'une VM, ordre chronologique. */
|
||||
agentLogs: (id: string, limit = 300) =>
|
||||
request<{ logs: LogEntry[] }>(`/agents/${id}/logs?limit=${limit}`),
|
||||
|
||||
createAgent: (body: { name: string; notes?: string }) =>
|
||||
request<{ agent: AgentView; token: string }>('/agents', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -9,9 +9,17 @@ interface Props {
|
||||
onToggleSelect: (id: string) => void;
|
||||
onCommand: (id: string, action: AgentAction, params?: Record<string, unknown>) => Promise<void>;
|
||||
onOpenSettings: (agent: AgentView) => void;
|
||||
onOpenHistory: (agent: AgentView) => void;
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSettings }: Props) {
|
||||
export function AgentCard({
|
||||
agent,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
onCommand,
|
||||
onOpenSettings,
|
||||
onOpenHistory,
|
||||
}: Props) {
|
||||
const [pending, setPending] = useState<AgentAction | null>(null);
|
||||
const { status } = agent;
|
||||
|
||||
@@ -67,6 +75,9 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
|
||||
</span>
|
||||
)}
|
||||
<span className={`badge ${state.tone}`}>{state.label}</span>
|
||||
<button className="icon" onClick={() => onOpenHistory(agent)} title="Historique de cette VM">
|
||||
🕘
|
||||
</button>
|
||||
<button className="icon" onClick={() => onOpenSettings(agent)} title="Configuration">
|
||||
⚙
|
||||
</button>
|
||||
|
||||
216
packages/web/src/components/AgentHistory.tsx
Normal file
216
packages/web/src/components/AgentHistory.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { AgentEvent, AgentView, LogEntry } from '@stream-control/shared';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../format';
|
||||
|
||||
interface Props {
|
||||
agent: AgentView;
|
||||
/** Entrées reçues en direct depuis l'ouverture du dashboard, toutes VM confondues. */
|
||||
liveLogs: LogEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface EventStyle {
|
||||
icon: string;
|
||||
tone: 'ok' | 'warn' | 'rec' | 'offline' | 'error' | 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pictogramme et couleur par type d'évènement.
|
||||
*
|
||||
* On s'en tient aux glyphes typographiques déjà employés sur les fiches (⏺ ⏸ ⛶ ⚙)
|
||||
* plutôt qu'à des émojis : même graisse, même alignement, lisible à 12 px.
|
||||
*/
|
||||
const EVENT_STYLES: Record<AgentEvent, EventStyle> = {
|
||||
'agent.connected': { icon: '⇢', tone: 'ok' },
|
||||
'agent.disconnected': { icon: '⇠', tone: 'offline' },
|
||||
'agent.enrolled': { icon: '+', tone: 'ok' },
|
||||
'agent.updated': { icon: '⬆', tone: 'ok' },
|
||||
'obs.connected': { icon: '◉', tone: 'ok' },
|
||||
'obs.disconnected': { icon: '◌', tone: 'warn' },
|
||||
'record.started': { icon: '⏺', tone: 'rec' },
|
||||
'record.stopped': { icon: '■', tone: 'offline' },
|
||||
'record.paused': { icon: '⏸', tone: 'warn' },
|
||||
'record.resumed': { icon: '▶', tone: 'ok' },
|
||||
'record.split': { icon: '✂', tone: 'info' },
|
||||
'stream.started': { icon: '⇡', tone: 'rec' },
|
||||
'stream.stopped': { icon: '⇣', tone: 'offline' },
|
||||
'capture.started': { icon: '▣', tone: 'rec' },
|
||||
'capture.stopped': { icon: '□', tone: 'offline' },
|
||||
'watch.started': { icon: '⟳', tone: 'info' },
|
||||
'watch.private': { icon: '⊘', tone: 'warn' },
|
||||
'watch.public': { icon: '◉', tone: 'ok' },
|
||||
'watch.offline': { icon: '○', tone: 'offline' },
|
||||
'watch.failed': { icon: '!', tone: 'warn' },
|
||||
'fullscreen.restored': { icon: '⛶', tone: 'ok' },
|
||||
'fullscreen.failed': { icon: '⛶', tone: 'warn' },
|
||||
'browser.opened': { icon: '⊞', tone: 'info' },
|
||||
'browser.closed': { icon: '⊟', tone: 'offline' },
|
||||
'preset.applied': { icon: '≡', tone: 'ok' },
|
||||
'preset.failed': { icon: '≡', tone: 'warn' },
|
||||
'config.changed': { icon: '⚙', tone: 'info' },
|
||||
'command.failed': { icon: '✖', tone: 'error' },
|
||||
};
|
||||
|
||||
const FILTERS = [
|
||||
{ id: 'all', label: 'Tout' },
|
||||
{ id: 'record', label: 'Enregistrement' },
|
||||
{ id: 'obs', label: 'OBS' },
|
||||
{ id: 'watch', label: 'Surveillance' },
|
||||
{ id: 'issues', label: 'Problèmes' },
|
||||
] as const;
|
||||
|
||||
type FilterId = (typeof FILTERS)[number]['id'];
|
||||
|
||||
/** Le préfixe de l'évènement porte la catégorie ; les entrées sans évènement restent dans « Tout ». */
|
||||
const FILTER_PREFIXES: Record<Exclude<FilterId, 'all' | 'issues'>, string[]> = {
|
||||
record: ['record.', 'capture.', 'stream.'],
|
||||
obs: ['obs.', 'preset.', 'config.'],
|
||||
watch: ['watch.', 'fullscreen.', 'browser.'],
|
||||
};
|
||||
|
||||
function matches(entry: LogEntry, filter: FilterId): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'issues') return entry.level === 'warn' || entry.level === 'error';
|
||||
return FILTER_PREFIXES[filter].some((prefix) => entry.event?.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function AgentHistory({ agent, liveLogs, onClose }: Props) {
|
||||
const [fetched, setFetched] = useState<LogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<FilterId>('all');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFetched(null);
|
||||
setError(null);
|
||||
api
|
||||
.agentLogs(agent.id)
|
||||
.then((result) => {
|
||||
if (!cancelled) setFetched(result.logs);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Historique indisponible');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agent.id]);
|
||||
|
||||
/**
|
||||
* L'historique persistant et le flux temps réel se recouvrent : les entrées
|
||||
* arrivées pendant le chargement figurent dans les deux. L'identifiant de ligne
|
||||
* tranche, et l'ordre décroissant place le plus récent en tête.
|
||||
*/
|
||||
const entries = useMemo(() => {
|
||||
const byId = new Map<number, LogEntry>();
|
||||
for (const entry of fetched ?? []) byId.set(entry.id, entry);
|
||||
for (const entry of liveLogs) {
|
||||
if (entry.agentId === agent.id) byId.set(entry.id, entry);
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => b.id - a.id);
|
||||
}, [agent.id, fetched, liveLogs]);
|
||||
|
||||
const visible = entries.filter((entry) => matches(entry, filter));
|
||||
const days = groupByDay(visible);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal modal-tall" onClick={(event) => event.stopPropagation()}>
|
||||
<header className="modal-head">
|
||||
<h2>Historique · {agent.name}</h2>
|
||||
<button className="icon" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="history-filters">
|
||||
{FILTERS.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
className={filter === entry.id ? 'chip active' : 'chip'}
|
||||
onClick={() => setFilter(entry.id)}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="spacer" />
|
||||
<span className="muted small">{visible.length} évènement(s)</span>
|
||||
</div>
|
||||
|
||||
<div className="modal-body history-body">
|
||||
{error && <p className="error small">{error}</p>}
|
||||
{!error && fetched === null && <p className="muted small">Chargement…</p>}
|
||||
{!error && fetched !== null && visible.length === 0 && (
|
||||
<p className="muted small">
|
||||
{entries.length === 0
|
||||
? "Aucun évènement pour cette VM. L'historique se remplit dès que l'agent se connecte."
|
||||
: 'Aucun évènement pour ce filtre.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{days.map(([day, dayEntries]) => (
|
||||
<section key={day} className="history-day">
|
||||
<h3 className="history-day-label">{day}</h3>
|
||||
{dayEntries.map((entry) => {
|
||||
const style = entry.event ? EVENT_STYLES[entry.event] : null;
|
||||
return (
|
||||
<div key={entry.id} className={`history-line level-${entry.level}`}>
|
||||
<span className="mono small muted">{formatTime(entry.ts)}</span>
|
||||
<span
|
||||
className={`history-icon ${style?.tone ?? 'info'}`}
|
||||
title={entry.event ?? 'évènement non typé'}
|
||||
>
|
||||
{style?.icon ?? '·'}
|
||||
</span>
|
||||
<span className="history-message">{entry.message}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="modal-foot">
|
||||
<span className="muted small">
|
||||
Les entrées les plus anciennes sont purgées avec le journal global.
|
||||
</span>
|
||||
<div className="spacer" />
|
||||
<button className="ghost" onClick={onClose}>
|
||||
Fermer
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Regroupe par jour en conservant l'ordre décroissant reçu. */
|
||||
function groupByDay(entries: LogEntry[]): Array<[string, LogEntry[]]> {
|
||||
const groups = new Map<string, LogEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const day = dayLabel(entry.ts);
|
||||
const bucket = groups.get(day);
|
||||
if (bucket) bucket.push(entry);
|
||||
else groups.set(day, [entry]);
|
||||
}
|
||||
return [...groups.entries()];
|
||||
}
|
||||
|
||||
function dayLabel(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today.getTime() - 86_400_000);
|
||||
|
||||
if (isSameDay(date, today)) return "Aujourd'hui";
|
||||
if (isSameDay(date, yesterday)) return 'Hier';
|
||||
return date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
@@ -393,6 +393,109 @@ fieldset.group {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* --- Historique par VM --- */
|
||||
|
||||
/*
|
||||
* La fiche standard défile d'un bloc ; ici seul le corps doit défiler, pour que
|
||||
* les filtres et les intitulés de jour restent visibles sur un long historique.
|
||||
*/
|
||||
.modal.modal-tall {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(680px, 100%);
|
||||
height: min(760px, 90vh);
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal.modal-tall .modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.chip.active {
|
||||
background: var(--panel-2);
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.history-body {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.history-day-label {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
margin: 0 0 4px;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.history-line {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 20px 1fr;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.history-line:hover {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.history-icon {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.history-icon.ok {
|
||||
color: #86efac;
|
||||
}
|
||||
.history-icon.warn {
|
||||
color: #fcd34d;
|
||||
}
|
||||
.history-icon.rec {
|
||||
color: #fca5a5;
|
||||
}
|
||||
.history-icon.error {
|
||||
color: var(--rec);
|
||||
}
|
||||
.history-icon.offline,
|
||||
.history-icon.info {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.history-message {
|
||||
word-break: break-word;
|
||||
}
|
||||
.history-line.level-error .history-message {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.preset-report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user