Feat : pause now showing in another color
All checks were successful
release / build (push) Successful in 28s
release / verify-windows (push) Successful in 1m27s

This commit is contained in:
jeanotx32
2026-08-13 14:42:14 -04:00
parent 830b27470c
commit b45ad4bca3
9 changed files with 244 additions and 29 deletions

View File

@@ -813,8 +813,9 @@ Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette,
**Streamers**) montre la sienne ; l'onglet **Timeline** les empile toutes, une voie par **Streamers**) montre la sienne ; l'onglet **Timeline** les empile toutes, une voie par
profil. profil.
Les diffusions publiques s'y lisent en bleu, et **les portions réellement capturées se Les diffusions publiques s'y lisent en bleu, **les portions capturées se superposent en
superposent en rouge**, sur la même piste. C'est le point de la vue : ce qui intéresse n'est rouge**, et **les passages en pause en jaune** par-dessus le rouge — la capture tournait, mais
n'écrivait rien. Le tout sur la même piste. C'est le point de la vue : ce qui intéresse n'est
pas « quand ai-je enregistré » dans l'absolu mais quelle part de chaque diffusion a été pas « quand ai-je enregistré » dans l'absolu mais quelle part de chaque diffusion a été
gardée — deux pistes séparées obligeraient l'œil à faire l'alignement lui-même. La vue gardée — deux pistes séparées obligeraient l'œil à faire l'alignement lui-même. La vue
globale rend visibles les chevauchements : deux streamers en direct en même temps sur une globale rend visibles les chevauchements : deux streamers en direct en même temps sur une
@@ -854,12 +855,26 @@ une fois écrit, il est servi avec un cache immuable.
| Table | Écrite par | Une ligne = | | Table | Écrite par | Une ligne = |
| --- | --- | --- | | --- | --- | --- |
| `stream_sessions` | la veille, sur transition d'état | une diffusion publique observée | | `stream_sessions` | la veille, sur transition d'état | une diffusion publique observée |
| `recording_spans` | le hub, sur changement de statut d'un agent | un intervalle réellement capturé | | `recording_spans` | le hub, sur changement de statut d'un agent | un intervalle de capture ouvert |
| `recording_pauses` | le hub, sur changement de statut d'un agent | une pause à l'intérieur d'un intervalle |
Deux tables et non une : un enregistrement démarre en général après le début du stream, Plusieurs tables et non une : un enregistrement démarre en général après le début du stream,
peut s'arrêter avant sa fin, et une même diffusion peut en compter plusieurs. Une pause OBS peut s'arrêter avant sa fin, et une même diffusion peut en compter plusieurs.
(show privé) ne coupe pas l'intervalle — elle n'écrit rien mais ne ferme pas le fichier, et
la diffusion publique s'est de toute façon interrompue pendant ce temps. **Une pause OBS (show privé, flux hors-ligne) ne coupe pas l'intervalle** — elle n'écrit rien
mais ne ferme pas le fichier. La découper en deux intervalles ferait passer les morceaux pour
deux captures distinctes, alors qu'il n'y en a qu'une, et qu'un seul fichier. Elle est donc
relevée à part, **en surimpression**, et apparaît en jaune par-dessus le rouge de la capture :
la barre se lit d'un coup d'œil comme « la capture tournait ici, mais n'écrivait rien ». Les
totaux annoncés déduisent ces pauses — les y inclure surestimerait ce qui est réellement
dans le fichier.
Deux invariants tiennent ce relevé : les ouvertures et fermetures de pause sont **idempotentes**
(le hub les rejoue à chaque cycle de statut, toutes les 2 s, et elles n'agissent qu'aux
transitions), et **aucune pause ne survit à l'intervalle qui la porte**. Ce second point est
réparé après coup plutôt que supposé : une VM disparue en pleine pause voit son intervalle
refermé par le balayage périodique, sans passer par le chemin qui referme les pauses — sans
cette réparation, la frise resterait jaune jusqu'à maintenant.
Le rattachement d'une capture à un profil passe par le pseudo sur lequel la veille de l'agent Le rattachement d'une capture à un profil passe par le pseudo sur lequel la veille de l'agent
est calée — c'est `startTargetRecording()` qui l'y pose. Une capture lancée à la main depuis est calée — c'est `startTargetRecording()` qui l'y pose. Une capture lancée à la main depuis
@@ -966,7 +981,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
| `POST` | `/api/tags` | Crée l'étiquette, ou renvoie celle qui porte déjà ce nom | | `POST` | `/api/tags` | Crée l'étiquette, ou renvoie celle qui porte déjà ce nom |
| `PATCH` | `/api/tags/:id` | Renomme (409 si le nom est déjà pris) | | `PATCH` | `/api/tags/:id` | Renomme (409 si le nom est déjà pris) |
| `DELETE` | `/api/tags/:id` | Supprime, et la retire de tous les profils | | `DELETE` | `/api/tags/:id` | Supprime, et la retire de tous les profils |
| `GET` | `/api/timeline?days=&targetId=` | Diffusions et captures ; sans `targetId`, tous les profils | | `GET` | `/api/timeline?days=&targetId=` | Diffusions, captures et pauses ; sans `targetId`, tous les profils |
| `GET` | `/api/timeline/sessions/:id/preview` | Vignette conservée d'une diffusion (jeton accepté en query) | | `GET` | `/api/timeline/sessions/:id/preview` | Vignette conservée d'une diffusion (jeton accepté en query) |
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) | | `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
| `DELETE` | `/api/agents/:id` | Supprime l'agent | | `DELETE` | `/api/agents/:id` | Supprime l'agent |

View File

@@ -14,7 +14,15 @@ import {
} from '@stream-control/shared'; } from '@stream-control/shared';
import { config } from './config.ts'; import { config } from './config.ts';
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts'; import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, tagsRepo, targetsRepo } from './db.ts'; import {
agentsRepo,
logsRepo,
pausesRepo,
sessionsRepo,
spansRepo,
tagsRepo,
targetsRepo,
} from './db.ts';
import { hub } from './hub.ts'; import { hub } from './hub.ts';
import { previewFileFor } from './previews.ts'; import { previewFileFor } from './previews.ts';
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts'; import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
@@ -463,6 +471,7 @@ api.get('/timeline', (req, res) => {
res.json({ res.json({
sessions: sessionsRepo.since(from, targetId), sessions: sessionsRepo.since(from, targetId),
spans: spansRepo.since(from, targetId), spans: spansRepo.since(from, targetId),
pauses: pausesRepo.since(from, targetId),
}); });
}); });

View File

@@ -7,6 +7,7 @@ import type {
LogLevel, LogLevel,
ObsSettings, ObsSettings,
Platform, Platform,
RecordingPause,
RecordingSettings, RecordingSettings,
RecordingSpan, RecordingSpan,
StreamSession, StreamSession,
@@ -115,6 +116,21 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_spans_started ON recording_spans (started_at DESC); CREATE INDEX IF NOT EXISTS idx_spans_started ON recording_spans (started_at DESC);
CREATE INDEX IF NOT EXISTS idx_spans_open ON recording_spans (agent_id) WHERE ended_at IS NULL; CREATE INDEX IF NOT EXISTS idx_spans_open ON recording_spans (agent_id) WHERE ended_at IS NULL;
-- Passages en pause d'une capture, en surimpression de l'intervalle qui les
-- porte. Table à part plutôt qu'un découpage des intervalles : une capture
-- mise en pause reste une seule capture, un seul fichier — la scinder en
-- ferait passer les morceaux pour des enregistrements distincts. Le CASCADE
-- suit celui des intervalles, eux-mêmes rattachés à un profil.
CREATE TABLE IF NOT EXISTS recording_pauses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
span_id INTEGER NOT NULL REFERENCES recording_spans (id) ON DELETE CASCADE,
started_at INTEGER NOT NULL,
ended_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_pauses_span ON recording_pauses (span_id);
CREATE INDEX IF NOT EXISTS idx_pauses_started ON recording_pauses (started_at DESC);
-- Étiquettes libres, posables sur autant de profils qu'on veut. Table à part -- Étiquettes libres, posables sur autant de profils qu'on veut. Table à part
-- plutôt qu'un texte répété sur chaque profil : c'est ce qui rend le renommage -- plutôt qu'un texte répété sur chaque profil : c'est ce qui rend le renommage
-- possible d'un geste, et garde une étiquette disponible même quand plus aucun -- possible d'un geste, et garde une étiquette disponible même quand plus aucun
@@ -946,8 +962,9 @@ export const spansRepo = {
return row ? toSpan(row) : null; return row ? toSpan(row) : null;
}, },
open(agentId: string, targetId: string | null, at: number): void { /** Renvoie l'identifiant du nouvel intervalle : les pauses s'y rattachent. */
spanStmts.insert.run(targetId, agentId, at, at); open(agentId: string, targetId: string | null, at: number): number {
return Number(spanStmts.insert.run(targetId, agentId, at, at).lastInsertRowid);
}, },
touch(id: number, at: number, targetId: string | null): void { touch(id: number, at: number, targetId: string | null): void {
@@ -976,6 +993,86 @@ export const spansRepo = {
}, },
}; };
// --- Pauses de capture --------------------------------------------------------
interface PauseRow {
id: number;
span_id: number;
target_id: string | null;
started_at: number;
ended_at: number | null;
}
const toPause = (row: PauseRow): RecordingPause => ({
id: Number(row.id),
spanId: Number(row.span_id),
targetId: row.target_id,
startedAt: Number(row.started_at),
endedAt: num(row.ended_at),
});
const pauseStmts = {
openFor: db.prepare(
'SELECT id FROM recording_pauses WHERE span_id = ? AND ended_at IS NULL ORDER BY started_at DESC',
),
insert: db.prepare('INSERT INTO recording_pauses (span_id, started_at) VALUES (?, ?)'),
// MAX() borne la fin au début, comme pour les intervalles : une durée négative
// rendrait la frise illisible.
close: db.prepare(`
UPDATE recording_pauses SET ended_at = MAX(started_at, ?)
WHERE span_id = ? AND ended_at IS NULL
`),
/**
* Aucune pause ne survit à l'intervalle qui la porte. Répare l'invariant après
* coup plutôt que de le supposer : un intervalle refermé par le balayage
* périodique (VM disparue en pleine capture) l'est sans passer par le chemin
* qui referme les pauses.
*/
sweep: db.prepare(`
UPDATE recording_pauses
SET ended_at = (SELECT ended_at FROM recording_spans WHERE id = span_id)
WHERE ended_at IS NULL
AND (SELECT ended_at FROM recording_spans WHERE id = span_id) IS NOT NULL
`),
// Le rattachement au profil vient de l'intervalle : c'est lui qui le porte, et
// le dupliquer sur la pause le ferait diverger dès qu'une capture est
// rattachée après coup.
since: db.prepare(`
SELECT p.*, s.target_id FROM recording_pauses p
JOIN recording_spans s ON s.id = p.span_id
WHERE p.started_at >= ? ORDER BY p.started_at
`),
sinceForTarget: db.prepare(`
SELECT p.*, s.target_id FROM recording_pauses p
JOIN recording_spans s ON s.id = p.span_id
WHERE s.target_id = ? AND p.started_at >= ? ORDER BY p.started_at
`),
};
export const pausesRepo = {
/** Ouvre une pause — sans effet s'il en reste une ouverte sur cet intervalle. */
open(spanId: number, at: number): void {
if (pauseStmts.openFor.get(spanId)) return;
pauseStmts.insert.run(spanId, at);
},
/** Referme la pause en cours de cet intervalle, s'il y en a une. */
close(spanId: number, at: number): void {
pauseStmts.close.run(at, spanId);
},
sweepOrphans(): void {
pauseStmts.sweep.run();
},
since(from: number, targetId?: string | null): RecordingPause[] {
const rows = (
targetId ? pauseStmts.sinceForTarget.all(targetId, from) : pauseStmts.since.all(from)
) as unknown as PauseRow[];
return rows.map(toPause);
},
};
interface LogRow { interface LogRow {
id: number; id: number;
agent_id: string | null; agent_id: string | null;

View File

@@ -13,7 +13,7 @@ import type {
} from '@stream-control/shared'; } from '@stream-control/shared';
import { emptyStatus } from '@stream-control/shared'; import { emptyStatus } from '@stream-control/shared';
import { config } from './config.ts'; import { config } from './config.ts';
import { agentsRepo, logsRepo, spansRepo, targetsRepo, type AgentRecord } from './db.ts'; import { agentsRepo, logsRepo, pausesRepo, spansRepo, targetsRepo, type AgentRecord } from './db.ts';
/** /**
* Au-delà de ce silence, une capture en cours est considérée comme terminée. * Au-delà de ce silence, une capture en cours est considérée comme terminée.
@@ -102,19 +102,25 @@ class Hub {
} }
/** /**
* Tient à jour l'intervalle de capture en cours de cette VM. * Tient à jour l'intervalle de capture en cours de cette VM, et ses pauses.
* *
* Une pause OBS (show privé) n'interrompt pas l'intervalle : elle n'écrit * Une pause OBS (show privé) n'interrompt pas l'intervalle : elle n'écrit rien
* rien mais ne clôt pas le fichier, et le flux public s'est de toute façon * mais ne clôt pas le fichier. Découper ici laisserait croire à deux captures
* arrêté pendant ce temps — la frise le montre déjà comme une coupure de * distinctes, alors qu'il n'y en a qu'une, et qu'un seul fichier. Elle est donc
* diffusion. Découper ici laisserait croire à deux captures distinctes. * relevée à part, en surimpression — ce qui permet à la frise de distinguer le
* temps réellement écrit du temps passé à tourner à vide.
*/ */
private trackRecording(agentId: string, status: AgentStatus): void { private trackRecording(agentId: string, status: AgentStatus): void {
const now = Date.now(); const now = Date.now();
const open = spansRepo.openForAgent(agentId); const open = spansRepo.openForAgent(agentId);
if (!status.recording) { if (!status.recording) {
if (open) spansRepo.close(open.id, now); if (open) {
// Dans cet ordre : la pause ne doit pas survivre à l'intervalle qui la
// porte, et sa fin est celle de la capture.
pausesRepo.close(open.id, now);
spansRepo.close(open.id, now);
}
return; return;
} }
@@ -123,9 +129,17 @@ class Hub {
// rattachée l'est pour de bon, inutile de reposer la question toutes les // rattachée l'est pour de bon, inutile de reposer la question toutes les
// deux secondes. // deux secondes.
spansRepo.touch(open.id, now, open.targetId ? null : this.recordingTarget(agentId)?.id ?? null); spansRepo.touch(open.id, now, open.targetId ? null : this.recordingTarget(agentId)?.id ?? null);
} else { // Les deux appels sont idempotents : appelés à chaque cycle de statut, ils
spansRepo.open(agentId, this.recordingTarget(agentId)?.id ?? null, now); // n'ouvrent et ne referment qu'aux transitions.
if (status.recordPaused) pausesRepo.open(open.id, now);
else pausesRepo.close(open.id, now);
return;
} }
// Capture découverte déjà en pause : rare, mais possible si l'agent se
// connecte au milieu d'un show privé.
const spanId = spansRepo.open(agentId, this.recordingTarget(agentId)?.id ?? null, now);
if (status.recordPaused) pausesRepo.open(spanId, now);
} }
/** /**
@@ -328,6 +342,10 @@ class Hub {
// pas à maintenant — sans quoi une VM éteinte une semaine passerait pour // pas à maintenant — sans quoi une VM éteinte une semaine passerait pour
// avoir enregistré une semaine. // avoir enregistré une semaine.
spansRepo.sweepStale(Date.now() - SPAN_STALE_MS); spansRepo.sweepStale(Date.now() - SPAN_STALE_MS);
// Ce balayage referme des intervalles sans passer par le chemin qui referme
// les pauses : une capture disparue en plein show privé laisserait sinon
// une pause ouverte pour toujours, donc une frise jaune jusqu'à maintenant.
pausesRepo.sweepOrphans();
const deadline = Date.now() - config.agentTimeoutMs; const deadline = Date.now() - config.agentTimeoutMs;
for (const [agentId, connection] of this.connections) { for (const [agentId, connection] of this.connections) {

View File

@@ -1042,9 +1042,31 @@ export interface RecordingSpan {
endedAt: number | null; endedAt: number | null;
} }
/**
* Un passage en pause à l'intérieur d'une capture.
*
* En surimpression d'un {@link RecordingSpan}, jamais à sa place : une capture
* mise en pause reste une seule capture, un seul fichier. La découper en
* plusieurs intervalles ferait passer les morceaux pour des enregistrements
* distincts — alors que rien n'a été clos, seulement suspendu.
*
* C'est ce qui permet de distinguer sur la frise le temps réellement écrit du
* temps où la capture tournait à vide (show privé, flux hors-ligne).
*/
export interface RecordingPause {
id: number;
spanId: number;
/** Repris de l'intervalle porteur, qui seul rattache la capture à un profil. */
targetId: string | null;
startedAt: number;
/** Nul tant que la capture est toujours en pause. */
endedAt: number | null;
}
export interface TimelineData { export interface TimelineData {
sessions: StreamSession[]; sessions: StreamSession[];
spans: RecordingSpan[]; spans: RecordingSpan[];
pauses: RecordingPause[];
} }
/** /**

View File

@@ -22,7 +22,7 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
const EMPTY: TimelineData = { sessions: [], spans: [] }; const EMPTY: TimelineData = { sessions: [], spans: [], pauses: [] };
/** /**
* Fiche détaillée d'un profil : identité, réglages, et son historique de * Fiche détaillée d'un profil : identité, réglages, et son historique de
@@ -343,6 +343,7 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
<p className="muted small timeline-legend"> <p className="muted small timeline-legend">
<span className="legend-swatch live" /> diffusion publique <span className="legend-swatch live" /> diffusion publique
<span className="legend-swatch rec" /> enregistré <span className="legend-swatch rec" /> enregistré
<span className="legend-swatch paused" /> en pause
</p> </p>
{loading ? ( {loading ? (
@@ -358,6 +359,7 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
label: target.label ?? target.username, label: target.label ?? target.username,
sessions: timeline.sessions, sessions: timeline.sessions,
spans: timeline.spans, spans: timeline.spans,
pauses: timeline.pauses,
}, },
]} ]}
/> />
@@ -367,7 +369,12 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
<ul className="session-list"> <ul className="session-list">
{sessions.map((session) => { {sessions.map((session) => {
const end = session.endedAt ?? now; const end = session.endedAt ?? now;
const recorded = overlapMs(timeline.spans, session.startedAt, end); // Pauses déduites : elles n'ont rien écrit dans le fichier.
const recorded = Math.max(
0,
overlapMs(timeline.spans, session.startedAt, end) -
overlapMs(timeline.pauses, session.startedAt, end),
);
return ( return (
<li key={session.id}> <li key={session.id}>
<span className="session-when">{formatDateTime(session.startedAt)}</span> <span className="session-when">{formatDateTime(session.startedAt)}</span>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { RecordingSpan, StreamSession } from '@stream-control/shared'; import type { RecordingPause, RecordingSpan, StreamSession } from '@stream-control/shared';
import { sessionPreviewSrc } from '../api'; import { sessionPreviewSrc } from '../api';
import { formatDuration } from '../format'; import { formatDuration } from '../format';
@@ -9,6 +9,7 @@ export interface TimelineLane {
avatarUrl?: string | null; avatarUrl?: string | null;
sessions: StreamSession[]; sessions: StreamSession[];
spans: RecordingSpan[]; spans: RecordingSpan[];
pauses: RecordingPause[];
} }
interface Props { interface Props {
@@ -81,8 +82,11 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
{lanes.map((lane) => { {lanes.map((lane) => {
const sessions = toBars(lane.sessions, from, to, now, 'Diffusion'); const sessions = toBars(lane.sessions, from, to, now, 'Diffusion');
const spans = toBars(lane.spans, from, to, now, 'Enregistrement'); const spans = toBars(lane.spans, from, to, now, 'Enregistrement');
const pauses = toBars(lane.pauses, from, to, now, 'En pause');
const liveMs = totalMs(sessions); const liveMs = totalMs(sessions);
const recordedMs = totalMs(spans); // Le temps en pause est retranché : la capture tournait, mais n'écrivait
// rien. L'inclure gonflerait le total d'un temps absent du fichier.
const recordedMs = Math.max(0, totalMs(spans) - totalMs(pauses));
return ( return (
<div key={lane.id} className="timeline-lane"> <div key={lane.id} className="timeline-lane">
@@ -111,7 +115,7 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
</button> </button>
<span <span
className="muted small timeline-totals" className="muted small timeline-totals"
title={`${formatDuration(liveMs)} en direct, dont ${formatDuration(recordedMs)} enregistré`} title={`${formatDuration(liveMs)} en direct, dont ${formatDuration(recordedMs)} réellement enregistré (pauses déduites)`}
> >
{formatDuration(liveMs)} {formatDuration(liveMs)}
{recordedMs > 0 ? <span className="timeline-rec-total"> · {formatDuration(recordedMs)}</span> : null} {recordedMs > 0 ? <span className="timeline-rec-total"> · {formatDuration(recordedMs)}</span> : null}
@@ -164,6 +168,18 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
/> />
))} ))}
{/* Après les intervalles, donc par-dessus : une pause se lit comme
une portion de la capture qui l'entoure, pas comme une barre
concurrente. */}
{pauses.map((bar) => (
<span
key={bar.key}
className={bar.open ? 'timeline-pause live' : 'timeline-pause'}
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
title={bar.title}
/>
))}
{nowLeft !== null && ( {nowLeft !== null && (
<span className="timeline-now" style={{ left: `${nowLeft}%` }} title="maintenant" /> <span className="timeline-now" style={{ left: `${nowLeft}%` }} title="maintenant" />
)} )}
@@ -224,7 +240,7 @@ const percent = (at: number, from: number, to: number) => ((at - from) / (to - f
* remplissant tout l'écran. * remplissant tout l'écran.
*/ */
function toBars( function toBars(
intervals: Array<StreamSession | RecordingSpan>, intervals: Array<StreamSession | RecordingSpan | RecordingPause>,
from: number, from: number,
to: number, to: number,
now: number, now: number,

View File

@@ -12,7 +12,7 @@ interface Props {
notify: (message: string, tone?: 'info' | 'error') => void; notify: (message: string, tone?: 'info' | 'error') => void;
} }
const EMPTY: TimelineData = { sessions: [], spans: [] }; const EMPTY: TimelineData = { sessions: [], spans: [], pauses: [] };
/** /**
* Frise de toutes les diffusions suivies, une voie par profil. * Frise de toutes les diffusions suivies, une voie par profil.
@@ -60,12 +60,16 @@ export function TimelinePage({ targets, agents, notify }: Props) {
avatarUrl: target.avatarUrl, avatarUrl: target.avatarUrl,
sessions: [], sessions: [],
spans: [], spans: [],
pauses: [],
}); });
} }
for (const session of data.sessions) byTarget.get(session.targetId)?.sessions.push(session); for (const session of data.sessions) byTarget.get(session.targetId)?.sessions.push(session);
for (const span of data.spans) { for (const span of data.spans) {
if (span.targetId) byTarget.get(span.targetId)?.spans.push(span); if (span.targetId) byTarget.get(span.targetId)?.spans.push(span);
} }
for (const pause of data.pauses) {
if (pause.targetId) byTarget.get(pause.targetId)?.pauses.push(pause);
}
// Un profil sans la moindre diffusion sur la période n'apporte qu'une ligne // 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. // vide : la frise sert à comparer des activités, pas à recenser les suivis.
@@ -75,9 +79,14 @@ export function TimelinePage({ targets, agents, notify }: Props) {
.sort((a, b) => lastActivity(b) - lastActivity(a)); .sort((a, b) => lastActivity(b) - lastActivity(a));
}, [data, targets, onlyRecorded]); }, [data, targets, onlyRecorded]);
const totalRecorded = data.spans.reduce( // Pauses déduites : la capture tournait, mais n'écrivait rien pendant ce
(sum, span) => sum + ((span.endedAt ?? now) - span.startedAt), // temps-là. L'annoncer comme enregistré surestimerait ce qu'on a réellement.
const elapsed = (interval: { startedAt: number; endedAt: number | null }) =>
(interval.endedAt ?? now) - interval.startedAt;
const totalRecorded = Math.max(
0, 0,
data.spans.reduce((sum, span) => sum + elapsed(span), 0) -
data.pauses.reduce((sum, pause) => sum + elapsed(pause), 0),
); );
const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null; const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null;
@@ -107,6 +116,7 @@ export function TimelinePage({ targets, agents, notify }: Props) {
<p className="muted small timeline-legend"> <p className="muted small timeline-legend">
<span className="legend-swatch live" /> diffusion publique <span className="legend-swatch live" /> diffusion publique
<span className="legend-swatch rec" /> enregistré <span className="legend-swatch rec" /> enregistré
<span className="legend-swatch paused" /> en pause
<span className="spacer" /> <span className="spacer" />
{data.sessions.length} diffusion(s) · {formatDuration(totalRecorded)} enregistré au total {data.sessions.length} diffusion(s) · {formatDuration(totalRecorded)} enregistré au total
</p> </p>

View File

@@ -1021,6 +1021,24 @@ fieldset.group > legend {
.timeline-rec.live { .timeline-rec.live {
background: repeating-linear-gradient(90deg, var(--rec) 0 6px, #b91c1c 6px 12px); background: repeating-linear-gradient(90deg, var(--rec) 0 6px, #b91c1c 6px 12px);
} }
/*
* Portion en pause, posée par-dessus la capture qui la contient et calée sur sa
* géométrie : elle se lit comme une part de l'enregistrement, pas comme une
* barre concurrente. Jaune plutôt que rouge — la capture tournait, mais
* n'écrivait rien.
*/
.timeline-pause {
position: absolute;
top: 7px;
bottom: 7px;
min-width: 2px;
border-radius: 2px;
background: var(--warn);
}
.timeline-pause.live {
background: repeating-linear-gradient(90deg, var(--warn) 0 6px, #b45309 6px 12px);
}
.timeline-now { .timeline-now {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -1056,6 +1074,9 @@ fieldset.group > legend {
.legend-swatch.rec { .legend-swatch.rec {
background: var(--rec); background: var(--rec);
} }
.legend-swatch.paused {
background: var(--warn);
}
/* /*
* Vignette conservée d'une diffusion, montrée au survol de sa barre. En `fixed` * Vignette conservée d'une diffusion, montrée au survol de sa barre. En `fixed`