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

@@ -36,6 +36,9 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
dédiée, dès qu'il est en direct depuis assez longtemps.
- [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface,
réglables VM par VM : de « Économe » à « Sans perte ».
- [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil
suivi, avec en surimpression ce qui en a été capturé — par profil sur sa fiche, ou tous
ensemble dans l'onglet « Timeline ».
## Prérequis
@@ -546,6 +549,55 @@ Le délai d'amorçage n'est pas une précaution de style : un modèle qui sort d
repasse « public » quelques secondes avant de se remettre en place. Déclencher sur la
première lecture produirait des fichiers de dix secondes.
## Frise des diffusions
Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette, onglet
**Streamers**) montre la sienne ; l'onglet **Timeline** les empile toutes, une voie par
profil.
Les diffusions publiques s'y lisent en bleu, et **les portions réellement capturées se
superposent en rouge**, 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é
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
seule VM, et ce qui, faute de machine libre, est passé à côté.
### Ce qui est enregistré, et comment
| Table | Écrite par | Une ligne = |
| --- | --- | --- |
| `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é |
Deux 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
(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.
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
l'onglet Agents, sans ce calage, reste sans profil et n'apparaît sur aucune voie.
**Une capture dont on n'a jamais vu la fin** (VM éteinte en plein enregistrement, serveur
redémarré) est refermée par un balayage périodique — à sa dernière preuve de vie, pas à
l'heure du balayage. Sans cela une VM disparue une semaine passerait pour avoir enregistré
une semaine. La colonne `last_seen_at`, réécrite à chaque statut reçu, sert exactement à ça,
et la tolérance de 60 s laisse un agent qui redémarre retrouver son intervalle plutôt que
d'en ouvrir un second.
Supprimer un profil suivi efface son historique (`ON DELETE CASCADE`) : ne plus suivre
quelqu'un, c'est aussi l'oublier.
### « Cette VM enregistre » n'est pas « ce profil est enregistré »
Plusieurs profils peuvent être assignés à la même VM, mais elle n'en capture qu'un à la
fois. L'interface distingue donc les deux, partout où un état d'enregistrement paraît :
- le profil réellement capturé porte « ⏺ enregistrement en cours » et le bouton **Arrêter** ;
- les autres profils de la même VM affichent « VM occupée : *nom du profil capturé* », avec
le bouton **Enregistrer** désactivé — ils ne sont pas enregistrés, et la fiche détaillée
l'explique en toutes lettres.
## Capture de fenêtre OBS et réutilisation
La source « capture de fenêtre » d'OBS mémorise un identifiant de fenêtre X11. Fermer la
@@ -622,7 +674,8 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
| `GET` | `/api/agents` | Liste des agents et de leur état |
| `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** |
| `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion, surveillance, navigateur, preset |
| `PATCH` | `/api/watchlist/:id` | Libellé, agent assigné, notifications, automatisme |
| `PATCH` | `/api/watchlist/:id` | Libellé, agent assigné, notifications, automatisme, favori |
| `GET` | `/api/timeline?days=&targetId=` | Diffusions et captures ; sans `targetId`, tous les profils |
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
| `DELETE` | `/api/agents/:id` | Supprime l'agent |
| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent |

View File

@@ -13,7 +13,7 @@ import {
} from '@stream-control/shared';
import { config } from './config.ts';
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
import { agentsRepo, logsRepo, targetsRepo } from './db.ts';
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts';
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
import { startTargetRecording } from './recorder.ts';
@@ -286,6 +286,7 @@ api.patch('/watchlist/:id', (req, res) => {
agentId,
notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify,
autoRecord,
favorite: typeof req.body?.favorite === 'boolean' ? req.body.favorite : target.favorite,
});
const updated = targetsRepo.get(target.id);
@@ -372,6 +373,24 @@ api.post('/watchlist/:id/stop', async (req, res) => {
}
});
/**
* Frise de diffusion : les streams observés et ce qui en a été capturé.
*
* Une seule route pour la frise globale et celle d'un profil — c'est le même
* assemblage, seul le filtre change. Le client recoupe les deux listes par
* `targetId`, il a déjà les profils par le flux temps réel.
*/
api.get('/timeline', (req, res) => {
const days = Math.min(Math.max(Number.parseInt(String(req.query.days ?? '7'), 10) || 7, 1), 120);
const targetId = typeof req.query.targetId === 'string' ? req.query.targetId : null;
const from = Date.now() - days * 86_400_000;
res.json({
sessions: sessionsRepo.since(from, targetId),
spans: spansRepo.since(from, targetId),
});
});
// --- Divers -----------------------------------------------------------------
/** Historique d'une VM : les mêmes entrées que le journal, filtrées et bornées. */

View File

@@ -8,6 +8,8 @@ import type {
ObsSettings,
Platform,
RecordingSettings,
RecordingSpan,
StreamSession,
StreamState,
BrowserSettings,
WatchSettings,
@@ -81,6 +83,35 @@ db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_identity
ON watch_targets (provider, username);
-- Diffusions observées, une ligne par passage en direct. Le CASCADE est
-- voulu : ne plus suivre un profil, c'est aussi oublier son historique.
CREATE TABLE IF NOT EXISTS stream_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id TEXT NOT NULL REFERENCES watch_targets (id) ON DELETE CASCADE,
started_at INTEGER NOT NULL,
ended_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_sessions_target ON stream_sessions (target_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON stream_sessions (started_at DESC);
-- Intervalles réellement capturés par une VM. La colonne last_seen_at est
-- réécrite à chaque statut reçu pendant la capture : c'est elle qui permet de
-- refermer honnêtement un intervalle dont on n'a jamais vu la fin (agent
-- disparu, serveur redémarré) sans lui attribuer la durée de l'interruption.
CREATE TABLE IF NOT EXISTS recording_spans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id TEXT REFERENCES watch_targets (id) ON DELETE CASCADE,
agent_id TEXT,
started_at INTEGER NOT NULL,
ended_at INTEGER,
last_seen_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_spans_target ON recording_spans (target_id, 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;
-- Réglages globaux, hors agents et profils : une ligne par clé, en JSON.
-- Pensé pour grandir (un jour d'autres canaux que Pushover) sans nouvelle
-- table à chaque fois.
@@ -119,6 +150,8 @@ addColumnIfMissing('watch_targets', 'last_live_ended_at', 'INTEGER');
// Enregistrement automatique dès que le profil est en direct depuis assez
// longtemps. Desactivé par défaut : rien ne doit s'enclencher sans un geste.
addColumnIfMissing('watch_targets', 'auto_record', 'INTEGER NOT NULL DEFAULT 0');
// Épinglage en tête de liste, sans autre effet.
addColumnIfMissing('watch_targets', 'favorite', 'INTEGER NOT NULL DEFAULT 0');
/**
* `idle` — le « revient bientôt » de Stripchat — relevait de la clôture et non de
@@ -426,6 +459,7 @@ interface TargetRow {
agent_id: string | null;
notify: number;
auto_record: number;
favorite: number;
state: string;
raw_status: string | null;
state_since: number;
@@ -449,6 +483,7 @@ function toTarget(row: TargetRow): WatchTarget {
url: stripchatProfileUrl(row.username),
agentId: row.agent_id,
notify: Number(row.notify) === 1,
favorite: Number(row.favorite) === 1,
autoRecord: Number(row.auto_record) === 1,
state: (row.state as StreamState) ?? 'unknown',
rawStatus: row.raw_status,
@@ -473,7 +508,7 @@ const targetStmts = {
VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?)
`),
updateSettings: db.prepare(
'UPDATE watch_targets SET label = ?, agent_id = ?, notify = ?, auto_record = ? WHERE id = ?',
'UPDATE watch_targets SET label = ?, agent_id = ?, notify = ?, auto_record = ?, favorite = ? WHERE id = ?',
),
updateState: db.prepare(`
UPDATE watch_targets
@@ -531,6 +566,7 @@ export const targetsRepo = {
agentId: string | null;
notify: boolean;
autoRecord: boolean;
favorite: boolean;
},
): void {
targetStmts.updateSettings.run(
@@ -538,6 +574,7 @@ export const targetsRepo = {
settings.agentId,
settings.notify ? 1 : 0,
settings.autoRecord ? 1 : 0,
settings.favorite ? 1 : 0,
id,
);
},
@@ -574,6 +611,146 @@ export const targetsRepo = {
},
};
// --- Historique de diffusion -------------------------------------------------
const sessionStmts = {
openFor: db.prepare(
'SELECT * FROM stream_sessions WHERE target_id = ? AND ended_at IS NULL ORDER BY started_at DESC',
),
insert: db.prepare('INSERT INTO stream_sessions (target_id, started_at) VALUES (?, ?)'),
// MAX() borne la fin au démarrage : l'heure de fin vient de la plateforme et
// rien ne garantit qu'elle soit postérieure à celle du début qu'elle nous
// avait donnée. Une durée négative rendrait la frise illisible.
close: db.prepare(`
UPDATE stream_sessions SET ended_at = MAX(started_at, ?)
WHERE target_id = ? AND ended_at IS NULL
`),
since: db.prepare(
'SELECT * FROM stream_sessions WHERE started_at >= ? ORDER BY started_at',
),
sinceForTarget: db.prepare(
'SELECT * FROM stream_sessions WHERE target_id = ? AND started_at >= ? ORDER BY started_at',
),
};
interface SessionRow {
id: number;
target_id: string;
started_at: number;
ended_at: number | null;
}
const toSession = (row: SessionRow): StreamSession => ({
id: Number(row.id),
targetId: row.target_id,
startedAt: Number(row.started_at),
endedAt: num(row.ended_at),
});
export const sessionsRepo = {
/**
* Ouvre une diffusion — sans effet s'il en reste déjà une ouverte.
*
* L'idempotence n'est pas un luxe : au redémarrage du serveur, un profil déjà
* en direct ne produit aucune transition, et la diffusion en cours doit être
* reprise telle quelle plutôt que coupée en deux.
*/
open(targetId: string, startedAt: number): void {
const existing = sessionStmts.openFor.get(targetId);
if (existing) return;
sessionStmts.insert.run(targetId, startedAt);
},
close(targetId: string, endedAt: number): void {
sessionStmts.close.run(endedAt, targetId);
},
since(from: number, targetId?: string | null): StreamSession[] {
const rows = (
targetId
? sessionStmts.sinceForTarget.all(targetId, from)
: sessionStmts.since.all(from)
) as unknown as SessionRow[];
return rows.map(toSession);
},
};
interface SpanRow {
id: number;
target_id: string | null;
agent_id: string | null;
started_at: number;
ended_at: number | null;
last_seen_at: number;
}
const toSpan = (row: SpanRow): RecordingSpan => ({
id: Number(row.id),
targetId: row.target_id,
agentId: row.agent_id,
startedAt: Number(row.started_at),
endedAt: num(row.ended_at),
});
const spanStmts = {
openFor: db.prepare(
'SELECT * FROM recording_spans WHERE agent_id = ? AND ended_at IS NULL ORDER BY started_at DESC',
),
insert: db.prepare(`
INSERT INTO recording_spans (target_id, agent_id, started_at, last_seen_at)
VALUES (?, ?, ?, ?)
`),
touch: db.prepare('UPDATE recording_spans SET last_seen_at = ? WHERE id = ?'),
// Le profil n'est pas toujours connu à l'ouverture : une capture lancée
// depuis l'onglet Agents précède parfois le calage de la surveillance.
attach: db.prepare('UPDATE recording_spans SET target_id = ? WHERE id = ? AND target_id IS NULL'),
close: db.prepare('UPDATE recording_spans SET ended_at = MAX(started_at, ?) WHERE id = ?'),
sweep: db.prepare(`
UPDATE recording_spans SET ended_at = last_seen_at
WHERE ended_at IS NULL AND last_seen_at < ?
`),
since: db.prepare('SELECT * FROM recording_spans WHERE started_at >= ? ORDER BY started_at'),
sinceForTarget: db.prepare(
'SELECT * FROM recording_spans WHERE target_id = ? AND started_at >= ? ORDER BY started_at',
),
};
export const spansRepo = {
openForAgent(agentId: string): RecordingSpan | null {
const row = spanStmts.openFor.get(agentId) as unknown as SpanRow | undefined;
return row ? toSpan(row) : null;
},
open(agentId: string, targetId: string | null, at: number): void {
spanStmts.insert.run(targetId, agentId, at, at);
},
touch(id: number, at: number, targetId: string | null): void {
spanStmts.touch.run(at, id);
if (targetId) spanStmts.attach.run(targetId, id);
},
close(id: number, endedAt: number): void {
spanStmts.close.run(endedAt, id);
},
/**
* Referme les captures dont on n'a jamais vu la fin, à leur dernière preuve
* de vie plutôt qu'à maintenant : une VM disparue une semaine ne doit pas
* laisser croire à une semaine d'enregistrement.
*/
sweepStale(deadline: number): void {
spanStmts.sweep.run(deadline);
},
since(from: number, targetId?: string | null): RecordingSpan[] {
const rows = (
targetId ? spanStmts.sinceForTarget.all(targetId, from) : spanStmts.since.all(from)
) as unknown as SpanRow[];
return rows.map(toSpan);
},
};
interface LogRow {
id: number;
agent_id: string | null;

View File

@@ -13,7 +13,17 @@ import type {
} from '@stream-control/shared';
import { emptyStatus } from '@stream-control/shared';
import { config } from './config.ts';
import { agentsRepo, logsRepo, targetsRepo, type AgentRecord } from './db.ts';
import { agentsRepo, logsRepo, spansRepo, targetsRepo, type AgentRecord } from './db.ts';
/**
* Au-delà de ce silence, une capture en cours est considérée comme terminée.
*
* Généreux face aux deux secondes du cycle de statut : un agent qui redémarre
* pendant un enregistrement doit retrouver son intervalle plutôt que d'en
* ouvrir un second. Le délai ne coûte rien en justesse — l'intervalle est
* refermé à sa dernière preuve de vie, pas à l'heure du balayage.
*/
const SPAN_STALE_MS = 60_000;
interface PendingCommand {
resolve: (value: unknown) => void;
@@ -87,9 +97,44 @@ class Hub {
updateStatus(agentId: string, status: AgentStatus): void {
this.statuses.set(agentId, status);
this.markSeen(agentId);
this.trackRecording(agentId, status);
this.publishAgent(agentId);
}
/**
* Tient à jour l'intervalle de capture en cours de cette VM.
*
* Une pause OBS (show privé) n'interrompt pas l'intervalle : elle n'écrit
* rien mais ne clôt pas le fichier, et le flux public s'est de toute façon
* arrêté pendant ce temps — la frise le montre déjà comme une coupure de
* diffusion. Découper ici laisserait croire à deux captures distinctes.
*/
private trackRecording(agentId: string, status: AgentStatus): void {
const now = Date.now();
const open = spansRepo.openForAgent(agentId);
if (!status.recording) {
if (open) spansRepo.close(open.id, now);
return;
}
if (open) {
// Le profil n'est cherché que tant qu'il manque : une capture déjà
// rattachée l'est pour de bon, inutile de reposer la question toutes les
// deux secondes.
spansRepo.touch(open.id, now, open.targetId ? null : this.recordedTargetId(agentId));
} else {
spansRepo.open(agentId, this.recordedTargetId(agentId), now);
}
}
/** Le profil que cette VM capture, d'après le pseudo sur lequel sa veille est calée. */
private recordedTargetId(agentId: string): string | null {
const record = agentsRepo.get(agentId);
if (!record?.watch.username) return null;
return targetsRepo.findByUsername(record.watch.provider, record.watch.username)?.id ?? null;
}
resolveCommand(agentId: string, requestId: string, ok: boolean, data: unknown, error?: string): void {
const pending = this.connections.get(agentId)?.pending.get(requestId);
if (!pending) return;
@@ -271,6 +316,12 @@ class Hub {
/** Coupe les agents silencieux : le heartbeat n'arrive plus. */
reapStale(): void {
// Même ménage côté captures : une VM disparue en plein enregistrement
// laisse un intervalle ouvert. On le referme à sa dernière preuve de vie,
// pas à maintenant — sans quoi une VM éteinte une semaine passerait pour
// avoir enregistré une semaine.
spansRepo.sweepStale(Date.now() - SPAN_STALE_MS);
const deadline = Date.now() - config.agentTimeoutMs;
for (const [agentId, connection] of this.connections) {
if (connection.lastSeenAt < deadline) {

View File

@@ -1,7 +1,7 @@
import type { StreamState, WatchTarget } from '@stream-control/shared';
import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/shared';
import { config } from './config.ts';
import { targetsRepo } from './db.ts';
import { sessionsRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts';
import { notifyTargetLive } from './pushover.ts';
import { startTargetRecording } from './recorder.ts';
@@ -89,6 +89,14 @@ class Watchlist {
const changed = next !== target.state;
// Diffusion en cours : sans condition sur `changed`, car l'ouverture est
// idempotente. C'est ce qui rattrape un profil déjà en direct au démarrage
// du serveur — aucune transition ne se produira pour lui, et sa diffusion
// resterait sinon absente de la frise jusqu'à la suivante.
if (next === 'public') {
sessionsRepo.open(target.id, statusChangedAt ?? Date.now());
}
// Fin de diffusion : on fige le stream qui vient de se terminer. Son début
// est le statusChangedAt d'avant la transition, que la plateforme vient de
// remplacer par celui du nouveau statut.
@@ -97,6 +105,7 @@ class Watchlist {
if (changed && target.state === 'public') {
lastLiveStartedAt = target.statusChangedAt ?? target.stateSince;
lastLiveEndedAt = Date.now();
sessionsRepo.close(target.id, lastLiveEndedAt);
}
targetsRepo.updateState(target.id, {

View File

@@ -880,6 +880,8 @@ export interface WatchTarget {
/** Agent qui enregistrera ce streamer, s'il est assigné. */
agentId: string | null;
notify: boolean;
/** Épinglé en tête de liste. Purement un confort de tri, sans effet ailleurs. */
favorite: boolean;
/**
* Lancer l'enregistrement sans intervention dès que ce profil est en direct
* depuis assez longtemps. Exige un agent assigné, et un seul profil en
@@ -906,6 +908,70 @@ export interface WatchTarget {
lastLiveEndedAt: number | null;
}
// ---------------------------------------------------------------------------
// Historique de diffusion
// ---------------------------------------------------------------------------
/**
* Une diffusion publique observée, du passage en direct à sa fin.
*
* Distinct de `WatchTarget.lastLive*`, qui ne garde que la dernière : ici on
* conserve l'historique, c'est ce qui rend une frise possible.
*/
export interface StreamSession {
id: number;
targetId: string;
startedAt: number;
/** Nul tant que la diffusion est en cours. */
endedAt: number | null;
}
/**
* Un intervalle réellement capturé par une VM.
*
* Séparé des diffusions à dessein : 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. Les superposer sur la frise est justement ce qui montre
* ce qui a été gardé et ce qui a été manqué.
*/
export interface RecordingSpan {
id: number;
/** Nul si l'enregistrement n'a pas pu être rattaché à un profil suivi. */
targetId: string | null;
agentId: string | null;
startedAt: number;
/** Nul tant que la capture est en cours. */
endedAt: number | null;
}
export interface TimelineData {
sessions: StreamSession[];
spans: RecordingSpan[];
}
/**
* Le pseudo que cette VM est en train de capturer, ou `null`.
*
* `startTargetRecording()` cale la surveillance de l'agent sur le profil qu'il
* enregistre : ce pseudo est donc le seul lien fiable entre une capture en
* cours et le profil concerné. Sans lui, une VM occupée ferait passer *tous*
* les profils qui lui sont assignés pour « en enregistrement », alors qu'elle
* n'en capture qu'un seul à la fois.
*/
export function recordingUsername(agent: Pick<AgentView, 'status' | 'watch'>): string | null {
if (!agent.status.recording) return null;
return agent.watch.username || null;
}
/** Cette VM enregistre-t-elle bien *ce* profil, et non un autre qui lui est assigné ? */
export function isRecordingTarget(
agent: Pick<AgentView, 'status' | 'watch'> | null | undefined,
target: Pick<WatchTarget, 'username'>,
): boolean {
if (!agent) return false;
return recordingUsername(agent) === target.username;
}
export type ServerToDashboard =
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[]; targets: WatchTarget[] }
| { type: 'agent'; agent: AgentView }

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) => {
/**
* 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) || a.username.localeCompare(b.username);
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 {