Feat : Streamers page revamp
All checks were successful
release / build (push) Successful in 27s
release / verify-windows (push) Successful in 1m26s

This commit is contained in:
jeanotx32
2026-08-12 15:28:56 +02:00
parent d8846aac9a
commit 96b364bd66
15 changed files with 1625 additions and 131 deletions

View File

@@ -9,6 +9,7 @@ import { AgentHistory } from './components/AgentHistory';
import { LogPanel } from './components/LogPanel';
import { NotificationsPage } from './components/NotificationsPage';
import { StreamersPage } from './components/StreamersPage';
import { TimelinePage } from './components/TimelinePage';
import { useHashRoute } from './useHashRoute';
interface Toast {
@@ -180,6 +181,12 @@ export function App() {
Streamers
{liveTargets > 0 && <span className="pill">{liveTargets}</span>}
</button>
<button
className={route === 'timeline' ? 'tab active' : 'tab'}
onClick={() => setRoute('timeline')}
>
Timeline
</button>
<button
className={route === 'logs' ? 'tab active' : 'tab'}
onClick={() => setRoute('logs')}
@@ -237,6 +244,8 @@ export function App() {
<LogPanel logs={logs} />
) : route === 'notifications' ? (
<NotificationsPage notify={notify} />
) : route === 'timeline' ? (
<TimelinePage targets={targets} agents={agents} notify={notify} />
) : route === 'streamers' ? (
<StreamersPage
targets={targets}

View File

@@ -3,6 +3,7 @@ import type {
AgentView,
LogEntry,
PushoverSettings,
TimelineData,
WatchTarget,
} from '@stream-control/shared';
@@ -120,7 +121,7 @@ export const api = {
updateTarget: (
id: string,
body: Partial<Pick<WatchTarget, 'label' | 'agentId' | 'notify' | 'autoRecord'>>,
body: Partial<Pick<WatchTarget, 'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite'>>,
) =>
request<{ target: WatchTarget }>(`/watchlist/${id}`, {
method: 'PATCH',
@@ -137,6 +138,12 @@ export const api = {
stopTarget: (id: string) => request<{ ok: boolean }>(`/watchlist/${id}/stop`, { method: 'POST' }),
/** Diffusions et captures d'une fenêtre glissante ; sans `targetId`, tous les profils. */
timeline: (days: number, targetId?: string) =>
request<TimelineData>(
`/timeline?days=${days}${targetId ? `&targetId=${encodeURIComponent(targetId)}` : ''}`,
),
// --- Notifications ----------------------------------------------------------
pushoverSettings: () => request<{ settings: PushoverSettings }>('/settings/pushover'),

View File

@@ -0,0 +1,342 @@
import { useEffect, useState } from 'react';
import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared';
import { api } from '../api';
import { formatDateTime, formatDuration, formatRelative } from '../format';
import { autoRecordHint, recordingContext, stateLabel, TIMELINE_RANGES } from '../streamers';
import { Timeline } from './Timeline';
interface Props {
target: WatchTarget;
targets: WatchTarget[];
agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void;
onClose: () => void;
}
const EMPTY: TimelineData = { sessions: [], spans: [] };
/**
* Fiche détaillée d'un profil : identité, réglages, et son historique de
* diffusion.
*
* Les réglages engageants (agent assigné, automatisme, suppression) vivent ici
* plutôt que sur la vignette. La grille en devient lisible d'un coup d'œil, et
* ces réglages-là se posent une fois pour toutes — pas à chaque passage sur la
* page.
*/
export function StreamerDetail({ target, targets, agents, notify, onClose }: Props) {
const [busy, setBusy] = useState(false);
const [avatarBroken, setAvatarBroken] = useState(false);
const [days, setDays] = useState<number>(7);
const [timeline, setTimeline] = useState<TimelineData>(EMPTY);
const [loading, setLoading] = useState(true);
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const context = recordingContext(target, agent, targets);
const state = stateLabel(target);
const isLive = target.state === 'public';
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
useEffect(() => {
let cancelled = false;
setLoading(true);
api
.timeline(days, target.id)
.then((data) => {
if (!cancelled) setTimeline(data);
})
.catch((err: unknown) => {
if (!cancelled) notify(err instanceof Error ? err.message : 'Frise indisponible', 'error');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// `notify` est stable côté App, et le relire relancerait la requête pour rien.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [days, target.id]);
async function run(action: () => Promise<unknown>, success?: string) {
setBusy(true);
try {
await action();
if (success) notify(success);
} catch (err) {
notify(err instanceof Error ? err.message : 'Action impossible', 'error');
} finally {
setBusy(false);
}
}
const now = Date.now();
const from = now - days * 86_400_000;
// Du plus récent au plus ancien : c'est la diffusion de la veille qu'on vient
// vérifier, pas celle d'il y a un mois.
const sessions = [...timeline.sessions].reverse();
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal modal-wide" onClick={(event) => event.stopPropagation()}>
<header className="modal-head">
<h2>{target.label ?? target.username}</h2>
<span className={`badge ${state.tone}`}>{state.text}</span>
<div className="spacer" />
<button className="icon" onClick={onClose} title="Fermer">
</button>
</header>
<div className="modal-body">
<section className="streamer-hero">
{target.avatarUrl && !avatarBroken ? (
<img
className="avatar-large"
src={target.avatarUrl}
alt={`Photo de profil de ${target.username}`}
referrerPolicy="no-referrer"
onError={() => setAvatarBroken(true)}
/>
) : (
<div className="avatar-large avatar-fallback" aria-hidden="true">
{target.username.slice(0, 1).toUpperCase()}
</div>
)}
<div className="streamer-hero-facts">
<a href={target.url} target="_blank" rel="noreferrer" className="streamer-name">
@{target.username}
</a>
<dl className="streamer-facts">
<div>
<dt>{isLive ? 'En direct depuis' : 'Statut depuis'}</dt>
<dd>
{isLive
? formatDuration(liveFor)
: target.statusChangedAt
? formatRelative(target.statusChangedAt)
: '—'}
</dd>
</div>
<div>
<dt>Dernier stream</dt>
<dd>
{isLive ? (
<span className="muted">en cours</span>
) : target.lastLiveEndedAt ? (
formatDateTime(target.lastLiveEndedAt)
) : (
<span className="muted">jamais observé</span>
)}
</dd>
</div>
<div>
<dt>Suivi depuis</dt>
<dd>{formatDateTime(target.createdAt)}</dd>
</div>
<div>
<dt>Dernière sonde</dt>
<dd>{target.lastCheckedAt ? formatRelative(target.lastCheckedAt) : 'jamais'}</dd>
</div>
</dl>
<div className="row">
<button
className={target.favorite ? 'chip active' : 'chip'}
disabled={busy}
onClick={() =>
void run(() => api.updateTarget(target.id, { favorite: !target.favorite }))
}
title="Épingler en tête de la liste"
>
{target.favorite ? '★' : '☆'} Favori
</button>
<button
className={target.notify ? 'chip active' : 'chip'}
disabled={busy}
onClick={() =>
void run(() => api.updateTarget(target.id, { notify: !target.notify }))
}
title="Être prévenu dès le passage en direct"
>
{target.notify ? '🔔' : '🔕'} Notifications
</button>
</div>
</div>
</section>
{target.lastError && <p className="error small">Sonde en échec : {target.lastError}</p>}
<section className="group">
<h3>Enregistrement</h3>
<label className="field">
<span>Agent d'enregistrement</span>
<select
value={target.agentId ?? ''}
disabled={busy}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { agentId: event.target.value || null }))
}
>
<option value="">— aucun —</option>
{agents.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
{candidate.online ? '' : ' (hors-ligne)'}
</option>
))}
</select>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={target.autoRecord}
disabled={busy || !target.agentId}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { autoRecord: event.target.checked }))
}
/>
<span>
Enregistrer automatiquement
<span className="muted small">
{!target.agentId
? " — assigne d'abord un agent"
: autoRecordHint(target, agent, context)}
</span>
</span>
</label>
{context.busyWith && (
<p className="muted small">
ⓘ {agent?.name} enregistre actuellement {context.busyWith} — ce profil n'est pas
capturé. Une VM ne traite qu'un flux à la fois.
</p>
)}
<div className="row">
{context.self ? (
<button
className="danger"
disabled={busy || !agent?.online}
onClick={() => void run(() => api.stopTarget(target.id), 'Enregistrement arrêté')}
>
■ Arrêter l'enregistrement
</button>
) : (
<button
className="primary"
disabled={busy || !agent?.online || !isLive || Boolean(context.busyWith)}
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
>
● Enregistrer
</button>
)}
<button
className="ghost"
disabled={busy}
onClick={() => void run(() => api.checkTarget(target.id), 'Statut rafraîchi')}
>
⟳ Sonder maintenant
</button>
</div>
</section>
<section className="group">
<div className="row timeline-head">
<h3>Historique de diffusion</h3>
<div className="spacer" />
{TIMELINE_RANGES.map((range) => (
<button
key={range.days}
className={days === range.days ? 'chip active' : 'chip'}
onClick={() => setDays(range.days)}
>
{range.label}
</button>
))}
</div>
<p className="muted small timeline-legend">
<span className="legend-swatch live" /> diffusion publique
<span className="legend-swatch rec" /> enregistré
</p>
{loading ? (
<p className="muted small">Chargement…</p>
) : (
<Timeline
from={from}
to={now}
hideLabels
lanes={[
{
id: target.id,
label: target.label ?? target.username,
sessions: timeline.sessions,
spans: timeline.spans,
},
]}
/>
)}
{!loading && sessions.length > 0 && (
<ul className="session-list">
{sessions.map((session) => {
const end = session.endedAt ?? now;
const recorded = overlapMs(timeline.spans, session.startedAt, end);
return (
<li key={session.id}>
<span className="session-when">{formatDateTime(session.startedAt)}</span>
<span className="session-duration">{formatDuration(end - session.startedAt)}</span>
<span className={recorded > 0 ? 'session-rec' : 'muted small'}>
{recorded > 0 ? `⏺ ${formatDuration(recorded)} enregistré` : 'non enregistré'}
{session.endedAt === null && <span className="muted"> · en cours</span>}
</span>
</li>
);
})}
</ul>
)}
</section>
</div>
<footer className="modal-foot">
<button
className="danger ghost"
disabled={busy}
onClick={() => {
if (!confirm(`Ne plus suivre « ${target.username} » ? Son historique sera effacé.`)) return;
void run(() => api.removeTarget(target.id)).then(onClose);
}}
>
Ne plus suivre
</button>
<div className="spacer" />
<button className="ghost" onClick={onClose}>
Fermer
</button>
</footer>
</div>
</div>
);
}
/** Part d'une diffusion réellement couverte par des captures. */
function overlapMs(
spans: Array<{ startedAt: number; endedAt: number | null }>,
from: number,
to: number,
): number {
const now = Date.now();
let total = 0;
for (const span of spans) {
const start = Math.max(span.startedAt, from);
const end = Math.min(span.endedAt ?? now, to);
if (end > start) total += end - start;
}
return total;
}

View File

@@ -1,26 +1,9 @@
import { useState, type FormEvent } from 'react';
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared';
import { useMemo, useState, type FormEvent } from 'react';
import type { AgentView, WatchTarget } from '@stream-control/shared';
import { api } from '../api';
import { formatDateTime, formatDuration, formatRelative } from '../format';
const STATE_LABELS: Record<StreamState, { text: string; tone: string }> = {
public: { text: 'en direct', tone: 'rec' },
private: { text: 'show privé', tone: 'warn' },
offline: { text: 'hors-ligne', tone: 'offline' },
unknown: { text: '…', tone: 'offline' },
};
/**
* `idle` et `private` partagent le même état normalisé — les deux mettent
* l'enregistrement en pause sans le clore — mais ne veulent pas dire la même
* chose à l'écran : le premier est une absence, le second un show payant.
*/
function stateLabel(target: WatchTarget): { text: string; tone: string } {
if (target.state === 'private' && target.rawStatus === 'idle') {
return { text: 'revient bientôt', tone: 'warn' };
}
return STATE_LABELS[target.state];
}
import { recordingContext, stateLabel, type RecordingContext } from '../streamers';
import { StreamerDetail } from './StreamerDetail';
interface Props {
targets: WatchTarget[];
@@ -30,6 +13,14 @@ interface Props {
onEnableNotifications: () => void;
}
const FILTERS = [
{ id: 'all', label: 'Tous' },
{ id: 'favorites', label: '★ Favoris' },
{ id: 'live', label: 'En direct' },
] as const;
type FilterId = (typeof FILTERS)[number]['id'];
export function StreamersPage({
targets,
agents,
@@ -39,6 +30,8 @@ export function StreamersPage({
}: Props) {
const [url, setUrl] = useState('');
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState<FilterId>('all');
const [openId, setOpenId] = useState<string | null>(null);
async function add(event: FormEvent) {
event.preventDefault();
@@ -55,13 +48,35 @@ export function StreamersPage({
}
}
// En direct d'abord, puis show privé, puis le reste par ordre alphabétique.
const ordered = [...targets].sort((a, b) => {
const rank = (target: WatchTarget) =>
target.state === 'public' ? 0 : target.state === 'private' ? 1 : 2;
return rank(a) - rank(b) || a.username.localeCompare(b.username);
/**
* En direct d'abord, puis show privé, puis le reste ; à statut égal, les
* favoris devant. L'ordre du statut prime : un direct en cours appelle une
* décision, un favori hors-ligne non.
*/
const ordered = useMemo(
() =>
[...targets].sort((a, b) => {
const rank = (target: WatchTarget) =>
target.state === 'public' ? 0 : target.state === 'private' ? 1 : 2;
return (
rank(a) - rank(b) ||
Number(b.favorite) - Number(a.favorite) ||
a.username.localeCompare(b.username)
);
}),
[targets],
);
const visible = ordered.filter((target) => {
if (filter === 'favorites') return target.favorite;
if (filter === 'live') return target.state === 'public';
return true;
});
// La fiche ouverte doit refléter les mises à jour temps réel, et se refermer
// d'elle-même si le profil disparaît de la liste.
const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null;
return (
<div className="page">
<form className="row streamer-add" onSubmit={add}>
@@ -81,7 +96,23 @@ export function StreamersPage({
)}
</form>
{ordered.length === 0 ? (
{targets.length > 0 && (
<div className="row filter-row">
{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} profil(s)</span>
</div>
)}
{targets.length === 0 ? (
<div className="empty">
<h2>Aucun streamer suivi</h2>
<p className="muted">
@@ -89,46 +120,60 @@ export function StreamersPage({
et tu pourras lancer l'enregistrement d'un clic.
</p>
</div>
) : visible.length === 0 ? (
<p className="muted small">Aucun profil pour ce filtre.</p>
) : (
<div className="streamer-grid">
{ordered.map((target) => (
<StreamerCard key={target.id} target={target} agents={agents} notify={notify} />
{visible.map((target) => (
<StreamerCard
key={target.id}
target={target}
targets={targets}
agents={agents}
notify={notify}
onOpen={() => setOpenId(target.id)}
/>
))}
</div>
)}
{openTarget && (
<StreamerDetail
target={openTarget}
targets={targets}
agents={agents}
notify={notify}
onClose={() => setOpenId(null)}
/>
)}
</div>
);
}
/**
* Explique, en une ligne, ce que l'automatisme fera ou pourquoi il ne fera rien.
* Un interrupteur coché qui reste sans effet est plus déroutant qu'un
* interrupteur absent.
* Vignette : identité, statut, et la seule action qui presse — lancer ou
* arrêter la capture. Tout le reste (agent assigné, automatisme, historique)
* vit dans la fiche détaillée, qu'un clic sur la vignette ouvre.
*/
function autoRecordHint(target: WatchTarget, agent: AgentView | null): string {
if (!target.autoRecord) return ' — dès une minute de direct';
if (!agent) return ' — agent introuvable';
if (!agent.online) return ` — en attente : ${agent.name} est hors-ligne`;
if (agent.status.recording) return ` — en attente : ${agent.name} enregistre déjà`;
if (target.state === 'public') return ' — armé, direct en cours';
return ' — armé, en attente du prochain direct';
}
function StreamerCard({
target,
targets,
agents,
notify,
onOpen,
}: {
target: WatchTarget;
targets: WatchTarget[];
agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void;
onOpen: () => void;
}) {
const [busy, setBusy] = useState(false);
const [avatarBroken, setAvatarBroken] = useState(false);
const state = stateLabel(target);
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const recording = agent?.status.recording ?? false;
const context = recordingContext(target, agent, targets);
const isLive = target.state === 'public';
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
@@ -149,19 +194,27 @@ function StreamerCard({
}
}
const recordTitle = !target.agentId
? 'Assigne un agent à ce streamer'
: !agent?.online
? 'Agent hors-ligne'
: !isLive
? "Le streamer n'est pas en direct"
: agent.browser.enabled
? "Ouvrir la page, passer en plein écran et lancer l'enregistrement"
: "Lancer OBS seul — le pilotage du navigateur est désactivé sur cet agent, "
+ "la page ne sera pas ouverte";
/** Les commandes ne doivent pas ouvrir la fiche en même temps qu'elles s'exécutent. */
const isolate = (handler: () => void) => (event: React.MouseEvent) => {
event.stopPropagation();
handler();
};
return (
<article className={`streamer tone-${state.tone}`}>
<article
// La vignette entière est cliquable ; chaque commande arrête la propagation.
className={`streamer tone-${context.self ? 'rec' : state.tone} clickable`}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
role="button"
tabIndex={0}
title="Ouvrir la fiche"
>
<header className="streamer-head">
{target.avatarUrl && !avatarBroken ? (
<img
@@ -180,12 +233,21 @@ function StreamerCard({
)}
<div className="streamer-identity">
<a href={target.url} target="_blank" rel="noreferrer" className="streamer-name">
{target.label ?? target.username}
</a>
<span className="streamer-name">{target.label ?? target.username}</span>
<span className="muted small">@{target.username}</span>
</div>
<button
className={target.favorite ? 'icon star on' : 'icon star'}
disabled={busy}
title={target.favorite ? 'Retirer des favoris' : 'Mettre en favori'}
onClick={isolate(() =>
void run(() => api.updateTarget(target.id, { favorite: !target.favorite })),
)}
>
{target.favorite ? '★' : '☆'}
</button>
<span className={`badge ${state.tone}`} title={`statut brut « ${target.rawStatus ?? '?'} »`}>
{state.text}
</span>
@@ -219,48 +281,8 @@ function StreamerCard({
</div>
</dl>
{target.lastError && <p className="error small">Sonde en échec : {target.lastError}</p>}
<label className="field">
<span>Agent d'enregistrement</span>
<select
value={target.agentId ?? ''}
disabled={busy}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { agentId: event.target.value || null }))
}
>
<option value="">— aucun —</option>
{agents.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
{candidate.online ? '' : ' (hors-ligne)'}
</option>
))}
</select>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={target.autoRecord}
disabled={busy || !target.agentId}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { autoRecord: event.target.checked }))
}
/>
<span>
Enregistrer automatiquement
<span className="muted small">
{!target.agentId
? ' assigne d\'abord un agent'
: autoRecordHint(target, agent)}
</span>
</span>
</label>
<div className="actions">
{recording ? (
<div className="actions" onClick={(event) => event.stopPropagation()}>
{context.self ? (
<button
className="danger"
disabled={busy || !agent?.online}
@@ -271,8 +293,8 @@ function StreamerCard({
) : (
<button
className="primary"
disabled={busy || !agent?.online || !isLive}
title={recordTitle}
disabled={busy || !agent?.online || !isLive || Boolean(context.busyWith)}
title={recordTitle(target, agent, context)}
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
>
Enregistrer
@@ -281,34 +303,38 @@ function StreamerCard({
<div className="spacer" />
<button
className="icon"
disabled={busy}
title="Sonder maintenant"
onClick={() => void run(() => api.checkTarget(target.id))}
>
</button>
<button
className="icon"
disabled={busy}
title="Ne plus suivre"
onClick={() => {
if (confirm(`Ne plus suivre « ${target.username} » ?`)) {
void run(() => api.removeTarget(target.id));
}
}}
>
</button>
<span className="muted small">{agent ? agent.name : 'aucun agent'}</span>
</div>
<footer className="card-foot muted small">
<span>
{target.lastCheckedAt ? `sondé ${formatRelative(target.lastCheckedAt)}` : 'pas encore sondé'}
<span className={context.self ? 'session-rec' : undefined}>
{context.self
? '⏺ enregistrement en cours'
: context.busyWith
? `VM occupée : ${context.busyWith}`
: target.lastCheckedAt
? `sondé ${formatRelative(target.lastCheckedAt)}`
: 'pas encore sondé'}
</span>
{target.notify ? <span>🔔</span> : <span title="Notifications désactivées">🔕</span>}
{target.notify ? <span title="Notifications activées">🔔</span> : <span title="Notifications désactivées">🔕</span>}
</footer>
</article>
);
}
function recordTitle(
target: WatchTarget,
agent: AgentView | null,
context: RecordingContext,
): string {
if (!target.agentId) return 'Assigne un agent à ce streamer (clique la fiche)';
if (!agent?.online) return 'Agent hors-ligne';
if (context.busyWith) {
return `${agent.name} enregistre déjà ${context.busyWith} — une VM ne traite qu'un flux à la fois`;
}
if (target.state !== 'public') return "Le streamer n'est pas en direct";
return agent.browser.enabled
? "Ouvrir la page, passer en plein écran et lancer l'enregistrement"
: 'Lancer OBS seul — le pilotage du navigateur est désactivé sur cet agent, ' +
'la page ne sera pas ouverte';
}

View File

@@ -0,0 +1,255 @@
import { useMemo } from 'react';
import type { RecordingSpan, StreamSession } from '@stream-control/shared';
import { formatDuration } from '../format';
export interface TimelineLane {
id: string;
label: string;
avatarUrl?: string | null;
sessions: StreamSession[];
spans: RecordingSpan[];
}
interface Props {
from: number;
to: number;
lanes: TimelineLane[];
/** Une seule voie : la colonne des noms n'apporte rien. */
hideLabels?: boolean;
onSelectLane?: (id: string) => void;
}
interface Bar {
key: string;
left: number;
width: number;
title: string;
/** Durée réellement visible, en millisecondes. */
ms: number;
/** Toujours en cours au moment du rendu. */
open: boolean;
}
/**
* Frise des diffusions, avec les portions capturées en surimpression.
*
* Deux couches sur une même piste plutôt que deux pistes séparées : ce qui
* intéresse l'opérateur n'est pas « quand ai-je enregistré » dans l'absolu,
* mais quelle part de chaque diffusion a été gardée — et un décalage vertical
* obligerait l'œil à faire lui-même l'alignement.
*
* Sert autant à un seul profil (fiche de détail) qu'à tous (onglet Timeline) :
* seule la liste des voies change.
*/
export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
const now = Date.now();
const ticks = useMemo(() => buildTicks(from, to), [from, to]);
const nowLeft = now >= from && now <= to ? percent(now, from, to) : null;
return (
<div className={hideLabels ? 'timeline timeline-bare' : 'timeline'}>
<div className="timeline-axis">
{!hideLabels && <div className="timeline-name" />}
<div className="timeline-track timeline-ruler">
{ticks.map((tick) => (
<span
key={tick.at}
className={tick.major ? 'timeline-tick major' : 'timeline-tick'}
style={{ left: `${percent(tick.at, from, to)}%` }}
>
{tick.label}
</span>
))}
</div>
</div>
{lanes.map((lane) => {
const sessions = toBars(lane.sessions, from, to, now, 'Diffusion');
const spans = toBars(lane.spans, from, to, now, 'Enregistrement');
const liveMs = totalMs(sessions);
const recordedMs = totalMs(spans);
return (
<div key={lane.id} className="timeline-lane">
{!hideLabels && (
<div className="timeline-name">
<button
className="timeline-name-button"
onClick={() => onSelectLane?.(lane.id)}
disabled={!onSelectLane}
title={onSelectLane ? 'Ouvrir la fiche' : undefined}
>
{lane.avatarUrl ? (
<img
className="timeline-avatar"
src={lane.avatarUrl}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<span className="timeline-avatar timeline-avatar-fallback" aria-hidden="true">
{lane.label.slice(0, 1).toUpperCase()}
</span>
)}
<span className="timeline-label">{lane.label}</span>
</button>
<span
className="muted small timeline-totals"
title={`${formatDuration(liveMs)} en direct, dont ${formatDuration(recordedMs)} enregistré`}
>
{formatDuration(liveMs)}
{recordedMs > 0 ? <span className="timeline-rec-total"> · {formatDuration(recordedMs)}</span> : null}
</span>
</div>
)}
<div className="timeline-track">
{ticks.map((tick) => (
<span
key={tick.at}
className={tick.major ? 'timeline-grid major' : 'timeline-grid'}
style={{ left: `${percent(tick.at, from, to)}%` }}
/>
))}
{sessions.map((bar) => (
<span
key={bar.key}
className={bar.open ? 'timeline-bar live' : 'timeline-bar'}
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
title={bar.title}
/>
))}
{spans.map((bar) => (
<span
key={bar.key}
className={bar.open ? 'timeline-rec live' : 'timeline-rec'}
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
title={bar.title}
/>
))}
{nowLeft !== null && (
<span className="timeline-now" style={{ left: `${nowLeft}%` }} title="maintenant" />
)}
</div>
</div>
);
})}
{lanes.length === 0 && (
<p className="muted small">Aucune diffusion observée sur cette période.</p>
)}
</div>
);
}
const percent = (at: number, from: number, to: number) => ((at - from) / (to - from)) * 100;
/**
* Découpe les intervalles à la fenêtre affichée.
*
* Un intervalle encore ouvert court jusqu'à maintenant, et non jusqu'au bord
* de la fenêtre : une diffusion en cours doit se lire comme telle, pas comme
* remplissant tout l'écran.
*/
function toBars(
intervals: Array<StreamSession | RecordingSpan>,
from: number,
to: number,
now: number,
kind: string,
): Bar[] {
const bars: Bar[] = [];
for (const interval of intervals) {
const start = Math.max(interval.startedAt, from);
const end = Math.min(interval.endedAt ?? now, to);
if (end <= start) continue;
bars.push({
key: `${kind}-${interval.id}`,
left: percent(start, from, to),
width: percent(end, from, to) - percent(start, from, to),
// Conservé à part des pourcentages : repasser de la largeur affichée aux
// millisecondes ferait perdre une minute sur deux heures, et le total
// annoncé ne collerait plus aux durées listées juste à côté.
ms: end - start,
open: interval.endedAt === null,
title:
`${kind} · ${formatStamp(interval.startedAt)}` +
`${interval.endedAt === null ? 'en cours' : formatStamp(interval.endedAt)}` +
` (${formatDuration((interval.endedAt ?? now) - interval.startedAt)})`,
});
}
return bars;
}
/** Total réellement visible dans la fenêtre — les bords sont déjà rognés. */
function totalMs(bars: Bar[]): number {
return bars.reduce((sum, bar) => sum + bar.ms, 0);
}
function formatStamp(ts: number): string {
return new Date(ts).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
interface Tick {
at: number;
label: string;
/** Début de journée : trait plus marqué. */
major: boolean;
}
/**
* Graduations adaptées à la largeur de la fenêtre.
*
* L'arithmétique passe par `Date` plutôt que par des additions de
* millisecondes : un pas de 24 h dérive d'une heure aux changements d'heure,
* et les graduations finiraient par ne plus tomber sur minuit.
*/
function buildTicks(from: number, to: number): Tick[] {
const span = to - from;
const ticks: Tick[] = [];
const cursor = new Date(from);
if (span <= 48 * 3_600_000) {
cursor.setMinutes(0, 0, 0);
cursor.setHours(Math.ceil(cursor.getHours() / 6) * 6);
while (cursor.getTime() <= to) {
if (cursor.getTime() >= from) {
const midnight = cursor.getHours() === 0;
ticks.push({
at: cursor.getTime(),
label: midnight
? cursor.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
: `${String(cursor.getHours()).padStart(2, '0')}h`,
major: midnight,
});
}
cursor.setHours(cursor.getHours() + 6);
}
return ticks;
}
cursor.setHours(0, 0, 0, 0);
// Au-delà d'une dizaine de repères les étiquettes se chevauchent : on saute
// des jours plutôt que de les empiler.
const step = Math.max(1, Math.ceil(span / 86_400_000 / 10));
while (cursor.getTime() < from) cursor.setDate(cursor.getDate() + step);
while (cursor.getTime() <= to) {
ticks.push({
at: cursor.getTime(),
label: cursor.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
major: true,
});
cursor.setDate(cursor.getDate() + step);
}
return ticks;
}

View File

@@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from 'react';
import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared';
import { api } from '../api';
import { formatDuration } from '../format';
import { TIMELINE_RANGES } from '../streamers';
import { StreamerDetail } from './StreamerDetail';
import { Timeline, type TimelineLane } from './Timeline';
interface Props {
targets: WatchTarget[];
agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void;
}
const EMPTY: TimelineData = { sessions: [], spans: [] };
/**
* Frise de toutes les diffusions suivies, une voie par profil.
*
* La vue qui manquait pour arbitrer : elle montre d'un coup d'œil les
* chevauchements — deux streamers en direct en même temps sur une seule VM —
* et ce qui, faute de machine libre, n'a pas été capturé.
*/
export function TimelinePage({ targets, agents, notify }: Props) {
const [days, setDays] = useState<number>(7);
const [data, setData] = useState<TimelineData>(EMPTY);
const [loading, setLoading] = useState(true);
const [onlyRecorded, setOnlyRecorded] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
api
.timeline(days)
.then((result) => {
if (!cancelled) setData(result);
})
.catch((err: unknown) => {
if (!cancelled) notify(err instanceof Error ? err.message : 'Frise indisponible', 'error');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [days]);
const now = Date.now();
const from = now - days * 86_400_000;
const lanes = useMemo<TimelineLane[]>(() => {
const byTarget = new Map<string, TimelineLane>();
for (const target of targets) {
byTarget.set(target.id, {
id: target.id,
label: target.label ?? target.username,
avatarUrl: target.avatarUrl,
sessions: [],
spans: [],
});
}
for (const session of data.sessions) byTarget.get(session.targetId)?.sessions.push(session);
for (const span of data.spans) {
if (span.targetId) byTarget.get(span.targetId)?.spans.push(span);
}
// Un profil sans la moindre diffusion sur la période n'apporte qu'une ligne
// vide : la frise sert à comparer des activités, pas à recenser les suivis.
return [...byTarget.values()]
.filter((lane) => lane.sessions.length > 0 || lane.spans.length > 0)
.filter((lane) => !onlyRecorded || lane.spans.length > 0)
.sort((a, b) => lastActivity(b) - lastActivity(a));
}, [data, targets, onlyRecorded]);
const totalRecorded = data.spans.reduce(
(sum, span) => sum + ((span.endedAt ?? now) - span.startedAt),
0,
);
const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null;
return (
<div className="page">
<div className="row filter-row">
<h2>Frise des diffusions</h2>
<div className="spacer" />
<button
className={onlyRecorded ? 'chip active' : 'chip'}
onClick={() => setOnlyRecorded((value) => !value)}
title="Ne garder que les profils dont une diffusion a été capturée"
>
Enregistrés
</button>
{TIMELINE_RANGES.map((range) => (
<button
key={range.days}
className={days === range.days ? 'chip active' : 'chip'}
onClick={() => setDays(range.days)}
>
{range.label}
</button>
))}
</div>
<p className="muted small timeline-legend">
<span className="legend-swatch live" /> diffusion publique
<span className="legend-swatch rec" /> enregistré
<span className="spacer" />
{data.sessions.length} diffusion(s) · {formatDuration(totalRecorded)} enregistré au total
</p>
{loading ? (
<p className="muted small">Chargement</p>
) : (
<div className="timeline-scroll">
<Timeline from={from} to={now} lanes={lanes} onSelectLane={setOpenId} />
</div>
)}
{openTarget && (
<StreamerDetail
target={openTarget}
targets={targets}
agents={agents}
notify={notify}
onClose={() => setOpenId(null)}
/>
)}
</div>
);
}
/** Dernier signe d'activité, diffusion ou capture confondues. */
function lastActivity(lane: TimelineLane): number {
let latest = 0;
for (const session of lane.sessions) latest = Math.max(latest, session.endedAt ?? Date.now());
for (const span of lane.spans) latest = Math.max(latest, span.endedAt ?? Date.now());
return latest;
}

View File

@@ -0,0 +1,83 @@
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared';
import { isRecordingTarget } from '@stream-control/shared';
/** Logique d'affichage partagée entre la fiche compacte et la fiche détaillée. */
const STATE_LABELS: Record<StreamState, { text: string; tone: string }> = {
public: { text: 'en direct', tone: 'rec' },
private: { text: 'show privé', tone: 'warn' },
offline: { text: 'hors-ligne', tone: 'offline' },
unknown: { text: '…', tone: 'offline' },
};
/**
* `idle` et `private` partagent le même état normalisé — les deux mettent
* l'enregistrement en pause sans le clore — mais ne veulent pas dire la même
* chose à l'écran : le premier est une absence, le second un show payant.
*/
export function stateLabel(target: WatchTarget): { text: string; tone: string } {
if (target.state === 'private' && target.rawStatus === 'idle') {
return { text: 'revient bientôt', tone: 'warn' };
}
return STATE_LABELS[target.state];
}
/**
* Ce que la VM assignée à un profil est en train de faire, de son point de vue.
*
* Une VM n'enregistre qu'un flux à la fois, mais plusieurs profils peuvent lui
* être assignés : « la VM enregistre » ne veut donc pas dire « ce profil est
* enregistré ». Les confondre affichait un enregistrement en cours sur des
* profils hors-ligne dont la VM captait le voisin — d'où cette distinction
* explicite, que l'interface reprend partout où l'état d'enregistrement paraît.
*/
export interface RecordingContext {
/** C'est bien ce profil que la VM capture. */
self: boolean;
/** La VM capture, mais autre chose : ce que l'on peut en nommer. */
busyWith: string | null;
}
export function recordingContext(
target: WatchTarget,
agent: AgentView | null,
targets: WatchTarget[],
): RecordingContext {
if (!agent?.status.recording) return { self: false, busyWith: null };
if (isRecordingTarget(agent, target)) return { self: true, busyWith: null };
// La VM enregistre sans que sa veille soit calée sur un pseudo : capture
// lancée à la main depuis l'onglet Agents, le plus souvent. On ne peut pas
// dire quoi, mais surtout pas laisser croire que c'est ce profil.
const username = agent.watch.username;
if (!username) return { self: false, busyWith: 'une source non identifiée' };
const other = targets.find((candidate) => candidate.username === username);
return { self: false, busyWith: other ? (other.label ?? other.username) : `@${username}` };
}
/**
* Explique, en une ligne, ce que l'automatisme fera ou pourquoi il ne fera rien.
* Un interrupteur coché qui reste sans effet est plus déroutant qu'un
* interrupteur absent.
*/
export function autoRecordHint(
target: WatchTarget,
agent: AgentView | null,
context: RecordingContext,
): string {
if (!target.autoRecord) return ' — dès une minute de direct';
if (!agent) return ' — agent introuvable';
if (!agent.online) return ` — en attente : ${agent.name} est hors-ligne`;
if (context.self) return ' — enregistrement en cours';
if (context.busyWith) return ` — en attente : ${agent.name} enregistre ${context.busyWith}`;
if (target.state === 'public') return ' — armé, direct en cours';
return ' — armé, en attente du prochain direct';
}
/** Fenêtres proposées sous les frises. */
export const TIMELINE_RANGES = [
{ days: 1, label: '24 h' },
{ days: 7, label: '7 jours' },
{ days: 30, label: '30 jours' },
] as const;

View File

@@ -371,7 +371,9 @@ textarea:focus {
white-space: nowrap;
}
fieldset.group {
/* `fieldset` dans les formulaires de réglages, `section` dans la fiche d'un
streamer : même encadré, les deux balises portent la classe. */
.group {
display: flex;
flex-direction: column;
gap: 12px;
@@ -399,6 +401,9 @@ fieldset.group {
* 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-wide {
width: min(760px, 100%);
}
.modal.modal-tall {
display: flex;
flex-direction: column;
@@ -608,6 +613,34 @@ fieldset.group > legend {
.streamer.tone-offline {
opacity: 0.72;
}
.streamer.clickable {
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease;
}
.streamer.clickable:hover,
.streamer.clickable:focus-visible {
background: var(--panel-2);
border-color: var(--accent);
outline: none;
}
/* Une vignette hors-ligne s'éclaircit au survol : elle reste cliquable, et
l'atténuation ne doit pas la faire passer pour inerte. */
.streamer.clickable.tone-offline:hover,
.streamer.clickable.tone-offline:focus-visible {
opacity: 1;
}
.icon.star {
font-size: 15px;
line-height: 1;
}
.icon.star.on {
color: var(--warn);
}
.filter-row {
align-items: center;
flex-wrap: wrap;
}
.streamer-head {
display: flex;
@@ -673,6 +706,229 @@ fieldset.group > legend {
white-space: nowrap;
}
/* --- Fiche détaillée d'un streamer --- */
.streamer-hero {
display: flex;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
.avatar-large {
width: 132px;
height: 132px;
flex: 0 0 132px;
border-radius: var(--radius);
object-fit: cover;
background: var(--panel-2);
font-size: 44px;
}
.streamer-hero-facts {
flex: 1;
min-width: 240px;
display: flex;
flex-direction: column;
gap: 10px;
}
.streamer-hero .streamer-facts {
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
}
.session-list {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
font-size: 12.5px;
}
.session-list li {
display: grid;
grid-template-columns: 1fr 70px 1.2fr;
gap: 10px;
padding: 3px 0;
border-top: 1px solid var(--border);
}
.session-duration {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--muted);
}
.session-rec {
color: #fca5a5;
}
/* --- Frise --- */
.timeline {
display: flex;
flex-direction: column;
gap: 3px;
}
/* Défilement horizontal réservé à la frise : sur une fenêtre large avec
beaucoup de profils, c'est elle qui déborde, pas la page. */
.timeline-scroll {
overflow-x: auto;
}
.timeline-axis,
.timeline-lane {
display: flex;
align-items: center;
gap: 10px;
}
.timeline-name {
flex: 0 0 190px;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.timeline-name-button {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 2px 4px;
background: transparent;
border-color: transparent;
text-align: left;
}
.timeline-name-button:disabled {
opacity: 1;
cursor: default;
}
.timeline-avatar {
width: 20px;
height: 20px;
flex: 0 0 20px;
border-radius: 50%;
object-fit: cover;
background: var(--panel-2);
}
.timeline-avatar-fallback {
display: grid;
place-items: center;
font-size: 10px;
font-weight: 700;
color: var(--muted);
border: 1px solid var(--border);
}
.timeline-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12.5px;
}
.timeline-totals {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.timeline-rec-total {
color: #fca5a5;
}
.timeline-track {
position: relative;
flex: 1;
min-width: 320px;
height: 20px;
background: var(--panel-2);
border-radius: 4px;
overflow: hidden;
}
.timeline-ruler {
height: 16px;
background: transparent;
overflow: visible;
}
.timeline-tick {
position: absolute;
top: 0;
transform: translateX(-50%);
font-size: 10px;
color: var(--muted);
white-space: nowrap;
}
.timeline-tick.major {
color: var(--text);
}
.timeline-grid {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--border);
}
.timeline-grid.major {
background: #3a4553;
}
/* Diffusion et capture se superposent sur la même piste : la seconde, plus
étroite et posée par-dessus, se lit comme une part de la première. */
.timeline-bar {
position: absolute;
top: 3px;
bottom: 3px;
min-width: 2px;
border-radius: 2px;
background: var(--accent);
}
.timeline-bar.live {
background: repeating-linear-gradient(
90deg,
var(--accent) 0 6px,
#2563eb 6px 12px
);
}
.timeline-rec {
position: absolute;
top: 7px;
bottom: 7px;
min-width: 2px;
border-radius: 2px;
background: var(--rec);
}
.timeline-rec.live {
background: repeating-linear-gradient(90deg, var(--rec) 0 6px, #b91c1c 6px 12px);
}
.timeline-now {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--ok);
}
.timeline-head {
align-items: center;
flex-wrap: wrap;
}
.timeline-legend {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin: 0;
}
.legend-swatch {
display: inline-block;
width: 14px;
height: 8px;
border-radius: 2px;
margin-left: 8px;
}
.legend-swatch:first-child {
margin-left: 0;
}
.legend-swatch.live {
background: var(--accent);
}
.legend-swatch.rec {
background: var(--rec);
}
/* --- Journal --- */
/* Vue pleine page depuis l'onglet Journal. Elle défile avec la page, comme la

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
export const ROUTES = ['agents', 'streamers', 'logs', 'notifications'] as const;
export const ROUTES = ['agents', 'streamers', 'timeline', 'logs', 'notifications'] as const;
export type Route = (typeof ROUTES)[number];
function currentRoute(): Route {