Feat : Streamers page revamp
This commit is contained in:
55
README.md
55
README.md
@@ -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.
|
dédiée, dès qu'il est en direct depuis assez longtemps.
|
||||||
- [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface,
|
- [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface,
|
||||||
réglables VM par VM : de « Économe » à « Sans perte ».
|
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
|
## 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
|
repasse « public » quelques secondes avant de se remettre en place. Déclencher sur la
|
||||||
première lecture produirait des fichiers de dix secondes.
|
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
|
## 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
|
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 |
|
| `GET` | `/api/agents` | Liste des agents et de leur état |
|
||||||
| `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** |
|
| `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/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) |
|
| `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 |
|
||||||
| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent |
|
| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ 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, targetsRepo } from './db.ts';
|
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, targetsRepo } from './db.ts';
|
||||||
import { hub } from './hub.ts';
|
import { hub } from './hub.ts';
|
||||||
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
|
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
|
||||||
import { startTargetRecording } from './recorder.ts';
|
import { startTargetRecording } from './recorder.ts';
|
||||||
@@ -286,6 +286,7 @@ api.patch('/watchlist/:id', (req, res) => {
|
|||||||
agentId,
|
agentId,
|
||||||
notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify,
|
notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify,
|
||||||
autoRecord,
|
autoRecord,
|
||||||
|
favorite: typeof req.body?.favorite === 'boolean' ? req.body.favorite : target.favorite,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = targetsRepo.get(target.id);
|
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 -----------------------------------------------------------------
|
// --- Divers -----------------------------------------------------------------
|
||||||
|
|
||||||
/** Historique d'une VM : les mêmes entrées que le journal, filtrées et bornées. */
|
/** Historique d'une VM : les mêmes entrées que le journal, filtrées et bornées. */
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
ObsSettings,
|
ObsSettings,
|
||||||
Platform,
|
Platform,
|
||||||
RecordingSettings,
|
RecordingSettings,
|
||||||
|
RecordingSpan,
|
||||||
|
StreamSession,
|
||||||
StreamState,
|
StreamState,
|
||||||
BrowserSettings,
|
BrowserSettings,
|
||||||
WatchSettings,
|
WatchSettings,
|
||||||
@@ -81,6 +83,35 @@ db.exec(`
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_identity
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_identity
|
||||||
ON watch_targets (provider, username);
|
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.
|
-- 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
|
-- Pensé pour grandir (un jour d'autres canaux que Pushover) sans nouvelle
|
||||||
-- table à chaque fois.
|
-- 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
|
// 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.
|
// longtemps. Desactivé par défaut : rien ne doit s'enclencher sans un geste.
|
||||||
addColumnIfMissing('watch_targets', 'auto_record', 'INTEGER NOT NULL DEFAULT 0');
|
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
|
* `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;
|
agent_id: string | null;
|
||||||
notify: number;
|
notify: number;
|
||||||
auto_record: number;
|
auto_record: number;
|
||||||
|
favorite: number;
|
||||||
state: string;
|
state: string;
|
||||||
raw_status: string | null;
|
raw_status: string | null;
|
||||||
state_since: number;
|
state_since: number;
|
||||||
@@ -449,6 +483,7 @@ function toTarget(row: TargetRow): WatchTarget {
|
|||||||
url: stripchatProfileUrl(row.username),
|
url: stripchatProfileUrl(row.username),
|
||||||
agentId: row.agent_id,
|
agentId: row.agent_id,
|
||||||
notify: Number(row.notify) === 1,
|
notify: Number(row.notify) === 1,
|
||||||
|
favorite: Number(row.favorite) === 1,
|
||||||
autoRecord: Number(row.auto_record) === 1,
|
autoRecord: Number(row.auto_record) === 1,
|
||||||
state: (row.state as StreamState) ?? 'unknown',
|
state: (row.state as StreamState) ?? 'unknown',
|
||||||
rawStatus: row.raw_status,
|
rawStatus: row.raw_status,
|
||||||
@@ -473,7 +508,7 @@ const targetStmts = {
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?)
|
||||||
`),
|
`),
|
||||||
updateSettings: db.prepare(
|
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(`
|
updateState: db.prepare(`
|
||||||
UPDATE watch_targets
|
UPDATE watch_targets
|
||||||
@@ -531,6 +566,7 @@ export const targetsRepo = {
|
|||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
notify: boolean;
|
notify: boolean;
|
||||||
autoRecord: boolean;
|
autoRecord: boolean;
|
||||||
|
favorite: boolean;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
targetStmts.updateSettings.run(
|
targetStmts.updateSettings.run(
|
||||||
@@ -538,6 +574,7 @@ export const targetsRepo = {
|
|||||||
settings.agentId,
|
settings.agentId,
|
||||||
settings.notify ? 1 : 0,
|
settings.notify ? 1 : 0,
|
||||||
settings.autoRecord ? 1 : 0,
|
settings.autoRecord ? 1 : 0,
|
||||||
|
settings.favorite ? 1 : 0,
|
||||||
id,
|
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 {
|
interface LogRow {
|
||||||
id: number;
|
id: number;
|
||||||
agent_id: string | null;
|
agent_id: string | null;
|
||||||
|
|||||||
@@ -13,7 +13,17 @@ 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, 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 {
|
interface PendingCommand {
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
@@ -87,9 +97,44 @@ class Hub {
|
|||||||
updateStatus(agentId: string, status: AgentStatus): void {
|
updateStatus(agentId: string, status: AgentStatus): void {
|
||||||
this.statuses.set(agentId, status);
|
this.statuses.set(agentId, status);
|
||||||
this.markSeen(agentId);
|
this.markSeen(agentId);
|
||||||
|
this.trackRecording(agentId, status);
|
||||||
this.publishAgent(agentId);
|
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 {
|
resolveCommand(agentId: string, requestId: string, ok: boolean, data: unknown, error?: string): void {
|
||||||
const pending = this.connections.get(agentId)?.pending.get(requestId);
|
const pending = this.connections.get(agentId)?.pending.get(requestId);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
@@ -271,6 +316,12 @@ class Hub {
|
|||||||
|
|
||||||
/** Coupe les agents silencieux : le heartbeat n'arrive plus. */
|
/** Coupe les agents silencieux : le heartbeat n'arrive plus. */
|
||||||
reapStale(): void {
|
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;
|
const deadline = Date.now() - config.agentTimeoutMs;
|
||||||
for (const [agentId, connection] of this.connections) {
|
for (const [agentId, connection] of this.connections) {
|
||||||
if (connection.lastSeenAt < deadline) {
|
if (connection.lastSeenAt < deadline) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { StreamState, WatchTarget } from '@stream-control/shared';
|
import type { StreamState, WatchTarget } from '@stream-control/shared';
|
||||||
import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/shared';
|
import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/shared';
|
||||||
import { config } from './config.ts';
|
import { config } from './config.ts';
|
||||||
import { targetsRepo } from './db.ts';
|
import { sessionsRepo, targetsRepo } from './db.ts';
|
||||||
import { hub } from './hub.ts';
|
import { hub } from './hub.ts';
|
||||||
import { notifyTargetLive } from './pushover.ts';
|
import { notifyTargetLive } from './pushover.ts';
|
||||||
import { startTargetRecording } from './recorder.ts';
|
import { startTargetRecording } from './recorder.ts';
|
||||||
@@ -89,6 +89,14 @@ class Watchlist {
|
|||||||
|
|
||||||
const changed = next !== target.state;
|
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
|
// 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
|
// est le statusChangedAt d'avant la transition, que la plateforme vient de
|
||||||
// remplacer par celui du nouveau statut.
|
// remplacer par celui du nouveau statut.
|
||||||
@@ -97,6 +105,7 @@ class Watchlist {
|
|||||||
if (changed && target.state === 'public') {
|
if (changed && target.state === 'public') {
|
||||||
lastLiveStartedAt = target.statusChangedAt ?? target.stateSince;
|
lastLiveStartedAt = target.statusChangedAt ?? target.stateSince;
|
||||||
lastLiveEndedAt = Date.now();
|
lastLiveEndedAt = Date.now();
|
||||||
|
sessionsRepo.close(target.id, lastLiveEndedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
targetsRepo.updateState(target.id, {
|
targetsRepo.updateState(target.id, {
|
||||||
|
|||||||
@@ -880,6 +880,8 @@ export interface WatchTarget {
|
|||||||
/** Agent qui enregistrera ce streamer, s'il est assigné. */
|
/** Agent qui enregistrera ce streamer, s'il est assigné. */
|
||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
notify: boolean;
|
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
|
* 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
|
* depuis assez longtemps. Exige un agent assigné, et un seul profil en
|
||||||
@@ -906,6 +908,70 @@ export interface WatchTarget {
|
|||||||
lastLiveEndedAt: number | null;
|
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 =
|
export type ServerToDashboard =
|
||||||
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[]; targets: WatchTarget[] }
|
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[]; targets: WatchTarget[] }
|
||||||
| { type: 'agent'; agent: AgentView }
|
| { type: 'agent'; agent: AgentView }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AgentHistory } from './components/AgentHistory';
|
|||||||
import { LogPanel } from './components/LogPanel';
|
import { LogPanel } from './components/LogPanel';
|
||||||
import { NotificationsPage } from './components/NotificationsPage';
|
import { NotificationsPage } from './components/NotificationsPage';
|
||||||
import { StreamersPage } from './components/StreamersPage';
|
import { StreamersPage } from './components/StreamersPage';
|
||||||
|
import { TimelinePage } from './components/TimelinePage';
|
||||||
import { useHashRoute } from './useHashRoute';
|
import { useHashRoute } from './useHashRoute';
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
@@ -180,6 +181,12 @@ export function App() {
|
|||||||
Streamers
|
Streamers
|
||||||
{liveTargets > 0 && <span className="pill">{liveTargets}</span>}
|
{liveTargets > 0 && <span className="pill">{liveTargets}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={route === 'timeline' ? 'tab active' : 'tab'}
|
||||||
|
onClick={() => setRoute('timeline')}
|
||||||
|
>
|
||||||
|
Timeline
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={route === 'logs' ? 'tab active' : 'tab'}
|
className={route === 'logs' ? 'tab active' : 'tab'}
|
||||||
onClick={() => setRoute('logs')}
|
onClick={() => setRoute('logs')}
|
||||||
@@ -237,6 +244,8 @@ export function App() {
|
|||||||
<LogPanel logs={logs} />
|
<LogPanel logs={logs} />
|
||||||
) : route === 'notifications' ? (
|
) : route === 'notifications' ? (
|
||||||
<NotificationsPage notify={notify} />
|
<NotificationsPage notify={notify} />
|
||||||
|
) : route === 'timeline' ? (
|
||||||
|
<TimelinePage targets={targets} agents={agents} notify={notify} />
|
||||||
) : route === 'streamers' ? (
|
) : route === 'streamers' ? (
|
||||||
<StreamersPage
|
<StreamersPage
|
||||||
targets={targets}
|
targets={targets}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
AgentView,
|
AgentView,
|
||||||
LogEntry,
|
LogEntry,
|
||||||
PushoverSettings,
|
PushoverSettings,
|
||||||
|
TimelineData,
|
||||||
WatchTarget,
|
WatchTarget,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ export const api = {
|
|||||||
|
|
||||||
updateTarget: (
|
updateTarget: (
|
||||||
id: string,
|
id: string,
|
||||||
body: Partial<Pick<WatchTarget, 'label' | 'agentId' | 'notify' | 'autoRecord'>>,
|
body: Partial<Pick<WatchTarget, 'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite'>>,
|
||||||
) =>
|
) =>
|
||||||
request<{ target: WatchTarget }>(`/watchlist/${id}`, {
|
request<{ target: WatchTarget }>(`/watchlist/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -137,6 +138,12 @@ export const api = {
|
|||||||
|
|
||||||
stopTarget: (id: string) => request<{ ok: boolean }>(`/watchlist/${id}/stop`, { method: 'POST' }),
|
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 ----------------------------------------------------------
|
// --- Notifications ----------------------------------------------------------
|
||||||
|
|
||||||
pushoverSettings: () => request<{ settings: PushoverSettings }>('/settings/pushover'),
|
pushoverSettings: () => request<{ settings: PushoverSettings }>('/settings/pushover'),
|
||||||
|
|||||||
342
packages/web/src/components/StreamerDetail.tsx
Normal file
342
packages/web/src/components/StreamerDetail.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,26 +1,9 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useMemo, useState, type FormEvent } from 'react';
|
||||||
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared';
|
import type { AgentView, WatchTarget } from '@stream-control/shared';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { formatDateTime, formatDuration, formatRelative } from '../format';
|
import { formatDateTime, formatDuration, formatRelative } from '../format';
|
||||||
|
import { recordingContext, stateLabel, type RecordingContext } from '../streamers';
|
||||||
const STATE_LABELS: Record<StreamState, { text: string; tone: string }> = {
|
import { StreamerDetail } from './StreamerDetail';
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
targets: WatchTarget[];
|
targets: WatchTarget[];
|
||||||
@@ -30,6 +13,14 @@ interface Props {
|
|||||||
onEnableNotifications: () => void;
|
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({
|
export function StreamersPage({
|
||||||
targets,
|
targets,
|
||||||
agents,
|
agents,
|
||||||
@@ -39,6 +30,8 @@ export function StreamersPage({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [filter, setFilter] = useState<FilterId>('all');
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
|
||||||
async function add(event: FormEvent) {
|
async function add(event: FormEvent) {
|
||||||
event.preventDefault();
|
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
|
||||||
const rank = (target: WatchTarget) =>
|
* favoris devant. L'ordre du statut prime : un direct en cours appelle une
|
||||||
target.state === 'public' ? 0 : target.state === 'private' ? 1 : 2;
|
* décision, un favori hors-ligne non.
|
||||||
return rank(a) - rank(b) || a.username.localeCompare(b.username);
|
*/
|
||||||
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<form className="row streamer-add" onSubmit={add}>
|
<form className="row streamer-add" onSubmit={add}>
|
||||||
@@ -81,7 +96,23 @@ export function StreamersPage({
|
|||||||
)}
|
)}
|
||||||
</form>
|
</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">
|
<div className="empty">
|
||||||
<h2>Aucun streamer suivi</h2>
|
<h2>Aucun streamer suivi</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
@@ -89,46 +120,60 @@ export function StreamersPage({
|
|||||||
et tu pourras lancer l'enregistrement d'un clic.
|
et tu pourras lancer l'enregistrement d'un clic.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<p className="muted small">Aucun profil pour ce filtre.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="streamer-grid">
|
<div className="streamer-grid">
|
||||||
{ordered.map((target) => (
|
{visible.map((target) => (
|
||||||
<StreamerCard key={target.id} target={target} agents={agents} notify={notify} />
|
<StreamerCard
|
||||||
|
key={target.id}
|
||||||
|
target={target}
|
||||||
|
targets={targets}
|
||||||
|
agents={agents}
|
||||||
|
notify={notify}
|
||||||
|
onOpen={() => setOpenId(target.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{openTarget && (
|
||||||
|
<StreamerDetail
|
||||||
|
target={openTarget}
|
||||||
|
targets={targets}
|
||||||
|
agents={agents}
|
||||||
|
notify={notify}
|
||||||
|
onClose={() => setOpenId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explique, en une ligne, ce que l'automatisme fera ou pourquoi il ne fera rien.
|
* Vignette : identité, statut, et la seule action qui presse — lancer ou
|
||||||
* Un interrupteur coché qui reste sans effet est plus déroutant qu'un
|
* arrêter la capture. Tout le reste (agent assigné, automatisme, historique)
|
||||||
* interrupteur absent.
|
* 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({
|
function StreamerCard({
|
||||||
target,
|
target,
|
||||||
|
targets,
|
||||||
agents,
|
agents,
|
||||||
notify,
|
notify,
|
||||||
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
target: WatchTarget;
|
target: WatchTarget;
|
||||||
|
targets: WatchTarget[];
|
||||||
agents: AgentView[];
|
agents: AgentView[];
|
||||||
notify: (message: string, tone?: 'info' | 'error') => void;
|
notify: (message: string, tone?: 'info' | 'error') => void;
|
||||||
|
onOpen: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [avatarBroken, setAvatarBroken] = useState(false);
|
const [avatarBroken, setAvatarBroken] = useState(false);
|
||||||
|
|
||||||
const state = stateLabel(target);
|
const state = stateLabel(target);
|
||||||
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
|
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 isLive = target.state === 'public';
|
||||||
|
|
||||||
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
|
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
|
||||||
@@ -149,19 +194,27 @@ function StreamerCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordTitle = !target.agentId
|
/** Les commandes ne doivent pas ouvrir la fiche en même temps qu'elles s'exécutent. */
|
||||||
? 'Assigne un agent à ce streamer'
|
const isolate = (handler: () => void) => (event: React.MouseEvent) => {
|
||||||
: !agent?.online
|
event.stopPropagation();
|
||||||
? 'Agent hors-ligne'
|
handler();
|
||||||
: !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";
|
|
||||||
|
|
||||||
return (
|
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">
|
<header className="streamer-head">
|
||||||
{target.avatarUrl && !avatarBroken ? (
|
{target.avatarUrl && !avatarBroken ? (
|
||||||
<img
|
<img
|
||||||
@@ -180,12 +233,21 @@ function StreamerCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="streamer-identity">
|
<div className="streamer-identity">
|
||||||
<a href={target.url} target="_blank" rel="noreferrer" className="streamer-name">
|
<span className="streamer-name">{target.label ?? target.username}</span>
|
||||||
{target.label ?? target.username}
|
|
||||||
</a>
|
|
||||||
<span className="muted small">@{target.username}</span>
|
<span className="muted small">@{target.username}</span>
|
||||||
</div>
|
</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 ?? '?'} »`}>
|
<span className={`badge ${state.tone}`} title={`statut brut « ${target.rawStatus ?? '?'} »`}>
|
||||||
{state.text}
|
{state.text}
|
||||||
</span>
|
</span>
|
||||||
@@ -219,48 +281,8 @@ function StreamerCard({
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{target.lastError && <p className="error small">Sonde en échec : {target.lastError}</p>}
|
<div className="actions" onClick={(event) => event.stopPropagation()}>
|
||||||
|
{context.self ? (
|
||||||
<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 ? (
|
|
||||||
<button
|
<button
|
||||||
className="danger"
|
className="danger"
|
||||||
disabled={busy || !agent?.online}
|
disabled={busy || !agent?.online}
|
||||||
@@ -271,8 +293,8 @@ function StreamerCard({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="primary"
|
className="primary"
|
||||||
disabled={busy || !agent?.online || !isLive}
|
disabled={busy || !agent?.online || !isLive || Boolean(context.busyWith)}
|
||||||
title={recordTitle}
|
title={recordTitle(target, agent, context)}
|
||||||
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
|
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
|
||||||
>
|
>
|
||||||
● Enregistrer
|
● Enregistrer
|
||||||
@@ -281,34 +303,38 @@ function StreamerCard({
|
|||||||
|
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
|
|
||||||
<button
|
<span className="muted small">{agent ? agent.name : 'aucun agent'}</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="card-foot muted small">
|
<footer className="card-foot muted small">
|
||||||
<span>
|
<span className={context.self ? 'session-rec' : undefined}>
|
||||||
{target.lastCheckedAt ? `sondé ${formatRelative(target.lastCheckedAt)}` : 'pas encore sondé'}
|
{context.self
|
||||||
|
? '⏺ enregistrement en cours'
|
||||||
|
: context.busyWith
|
||||||
|
? `VM occupée : ${context.busyWith}`
|
||||||
|
: target.lastCheckedAt
|
||||||
|
? `sondé ${formatRelative(target.lastCheckedAt)}`
|
||||||
|
: 'pas encore sondé'}
|
||||||
</span>
|
</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>
|
</footer>
|
||||||
</article>
|
</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';
|
||||||
|
}
|
||||||
|
|||||||
255
packages/web/src/components/Timeline.tsx
Normal file
255
packages/web/src/components/Timeline.tsx
Normal 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;
|
||||||
|
}
|
||||||
141
packages/web/src/components/TimelinePage.tsx
Normal file
141
packages/web/src/components/TimelinePage.tsx
Normal 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;
|
||||||
|
}
|
||||||
83
packages/web/src/streamers.ts
Normal file
83
packages/web/src/streamers.ts
Normal 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;
|
||||||
@@ -371,7 +371,9 @@ textarea:focus {
|
|||||||
white-space: nowrap;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
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
|
* 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.
|
* les filtres et les intitulés de jour restent visibles sur un long historique.
|
||||||
*/
|
*/
|
||||||
|
.modal.modal-wide {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
}
|
||||||
.modal.modal-tall {
|
.modal.modal-tall {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -608,6 +613,34 @@ fieldset.group > legend {
|
|||||||
.streamer.tone-offline {
|
.streamer.tone-offline {
|
||||||
opacity: 0.72;
|
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 {
|
.streamer-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -673,6 +706,229 @@ fieldset.group > legend {
|
|||||||
white-space: nowrap;
|
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 --- */
|
/* --- Journal --- */
|
||||||
|
|
||||||
/* Vue pleine page depuis l'onglet Journal. Elle défile avec la page, comme la
|
/* Vue pleine page depuis l'onglet Journal. Elle défile avec la page, comme la
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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];
|
export type Route = (typeof ROUTES)[number];
|
||||||
|
|
||||||
function currentRoute(): Route {
|
function currentRoute(): Route {
|
||||||
|
|||||||
Reference in New Issue
Block a user