diff --git a/README.md b/README.md index befebb7..bc528e9 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`. - [Clôture différée sur passage hors-ligne](#passage-hors-ligne) : une coupure brève ne découpe pas le fichier, une vraie fin de diffusion le termine. - [Enregistrement automatique](#enregistrement-automatique) d'un streamer sur une VM - 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, avec + [priorité et interruption](#priorité-et-interruption) quand plusieurs profils se disputent + la même machine. - [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface, réglables VM par VM : de « Économe » à « Sans perte ». - [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil @@ -520,7 +522,9 @@ identifiants du formulaire, sans attendre un vrai passage en direct pour découv de frappe. Déclenché sur le même évènement que la notification navigateur : un profil dont `notify` est -coché (la cloche 🔔) passant à l'état public. Un échec d'envoi (identifiants invalides, +coché (la cloche 🔔) passant à l'état public. **Un profil nouvellement suivi est silencieux** — +suivre quelqu'un sert souvent à l'observer, et une liste qui grandit ne doit pas transformer +le téléphone en sonnette. La cloche s'active profil par profil, depuis sa fiche. Un échec d'envoi (identifiants invalides, Pushover injoignable) finit dans le journal, jamais en erreur qui interromprait la veille — c'est un canal secondaire, pas une condition de son fonctionnement. @@ -535,20 +539,61 @@ Sur la fiche d'un streamer (onglet Streamers), assigne un agent puis coche (60 s par défaut), le serveur lance la capture sur cet agent, exactement comme le bouton « Enregistrer » — surveillance des shows privés comprise. -Trois règles cadrent l'automatisme : +Plusieurs profils peuvent être en automatisme sur la même VM ; c'est la +[priorité](#priorité-et-interruption) qui les départage. **Par défaut la capture en place +l'emporte** : un profil qui trouve sa VM occupée passe son tour sans rien écraser. -- **Un streamer par VM.** Activer l'automatisme sur un agent déjà pris est refusé, avec le - nom du profil qui l'occupe. -- **Aucune préemption.** Si la VM enregistre déjà quoi que ce soit, l'automatisme passe son - tour plutôt que d'écraser la capture en cours. -- **Trois tentatives par diffusion.** Les échecs restants une fois ces garde-fous passés - (OBS injoignable, par exemple) sont surtout persistants ; au-delà, l'automatisme - abandonne jusqu'à la diffusion suivante et le dit dans le journal. +**Trois tentatives par diffusion.** Les échecs restants une fois les garde-fous passés (OBS +injoignable, par exemple) sont surtout persistants ; au-delà, l'automatisme abandonne +jusqu'à la diffusion suivante et le dit dans le journal. Passer son tour faute de VM libre +n'en consomme aucune — sinon un profil bloqué une heure aurait épuisé ses essais avant même +que la machine ne se libère. Le délai d'amorçage n'est pas une précaution de style : un modèle qui sort d'un show privé repasse « public » quelques secondes avant de se remettre en place. Déclencher sur la première lecture produirait des fichiers de dix secondes. +## Priorité et interruption + +Une VM ne capture qu'un flux à la fois, mais rien n'empêche d'y assigner plusieurs profils. +Deux réglages, sur la fiche d'un streamer, disent qui l'emporte. + +**Priorité** — *Basse, Normale (défaut), Haute, Critique*. L'échelle est globale pour rester +comparable partout, mais **elle ne tranche qu'entre profils assignés au même agent** : deux +VM distinctes ne se disputent rien, et un classement global mêlerait des profils qui ne se +croiseront jamais. La fiche affiche donc l'ordre qui compte — celui de la VM : +« Ordre sur vm-rec-01 : 1. alice (Haute) ⚡ · 2. bob (Normale) ». + +**Peut interrompre un enregistrement en cours** — décoché par défaut. C'est l'autorisation +de couper une capture de rang inférieur pour prendre sa place. + +Ce droit est délibérément **séparé de la priorité, et non déduit d'elle** : interrompre +détruit la fin d'un fichier. Cela ne doit pas découler d'un simple changement de rang, mais +d'un choix explicite, profil par profil. + +L'interruption n'a lieu que si les trois conditions sont réunies : + +1. le profil candidat a le droit d'interrompre ; +2. sa priorité est **strictement** supérieure — à rang égal, la capture en place reste, ce + qui rend les égalités inoffensives ; +3. la capture en cours est **identifiable**, c'est-à-dire rattachée à un profil suivi. Une + capture lancée à la main depuis l'onglet Agents n'est jamais coupée : on ignore ce + qu'elle vaut, et il n'y a aucune priorité à lui comparer. + +La capture évincée est **close proprement** — commande d'arrêt, puis attente de la +confirmation de l'agent avant de relancer — et non écrasée : son fichier reste. Le journal +en garde une trace en avertissement : « Priorité : « bob » interrompu au profit de +« alice » sur vm-rec-01 ». + +La règle vaut aussi pour le bouton **Enregistrer** : quand elle s'applique, il annonce ce +qu'il fera (« Enregistrer (interrompt bob) ») plutôt que de le faire découvrir par un échec ; +sinon il reste désactivé, avec le motif du refus en infobulle. + +Enfin, la veille sonde les profils **du plus prioritaire au moins prioritaire**. C'est +l'ordre de sondage qui décide qui tente sa chance en premier sur une VM libre : sonder au +hasard laisserait un profil mineur s'en emparer, pour se faire couper dans la foulée par son +aîné — même résultat, au prix d'un fichier de quelques secondes. + ## Frise des diffusions Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette, onglet @@ -674,7 +719,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer { username, label: typeof req.body?.label === 'string' && req.body.label.trim() ? req.body.label.trim() : null, agentId: typeof req.body?.agentId === 'string' ? req.body.agentId : null, - notify: req.body?.notify !== false, + // Silencieux tant que l'opérateur ne l'a pas demandé, cloche 🔔 sur la fiche. + notify: req.body?.notify === true, }); hub.log(null, 'info', `Veille : « ${username} » ajouté`); @@ -266,20 +267,12 @@ api.patch('/watchlist/:id', (req, res) => { return; } - // Un streamer par VM : deux automatismes sur le même agent se disputeraient la - // machine, et le second échouerait systématiquement sur « déjà en - // enregistrement ». Autant le refuser ici, où l'on peut l'expliquer. - if (autoRecord && agentId) { - const conflict = targetsRepo - .list() - .find((other) => other.id !== target.id && other.autoRecord && other.agentId === agentId); - if (conflict) { - res.status(409).json({ - error: `« ${conflict.label ?? conflict.username} » est déjà en automatisme sur cet agent`, - }); - return; - } - } + // Plusieurs automatismes par VM sont désormais permis : c'est la priorité qui + // arbitre. Une version antérieure refusait le second en 409, faute de règle + // pour départager — le refus n'avait plus lieu d'être une fois cette règle + // écrite, et l'interdire empêcherait justement d'exprimer un ordre. + + const priority = Number(req.body?.priority); targetsRepo.updateSettings(target.id, { label: typeof req.body?.label === 'string' ? req.body.label.trim() || null : target.label, @@ -287,6 +280,12 @@ api.patch('/watchlist/:id', (req, res) => { notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify, autoRecord, favorite: typeof req.body?.favorite === 'boolean' ? req.body.favorite : target.favorite, + // Borné : la valeur sert de comparaison, une saisie farfelue n'apporterait + // rien de plus qu'un rang très haut ou très bas. + priority: Number.isFinite(priority) + ? Math.min(Math.max(Math.round(priority), 0), 100) + : target.priority, + preempt: typeof req.body?.preempt === 'boolean' ? req.body.preempt : target.preempt, }); const updated = targetsRepo.get(target.id); diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index d38949d..94277ab 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -71,7 +71,9 @@ db.exec(` username TEXT NOT NULL, label TEXT, agent_id TEXT, - notify INTEGER NOT NULL DEFAULT 1, + -- Éteintes par défaut : suivre un profil sert souvent à l'observer, et une + -- liste qui grandit ne doit pas transformer le téléphone en sonnette. + notify INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'unknown', raw_status TEXT, state_since INTEGER NOT NULL, @@ -152,6 +154,13 @@ addColumnIfMissing('watch_targets', 'last_live_ended_at', 'INTEGER'); 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'); +// Arbitrage entre profils qui se disputent la même VM. Le défaut « Normale » +// et non zéro : les profils existants doivent atterrir au milieu de l'échelle, +// pas tout en bas, faute de quoi le premier rang réglé les surclasserait tous. +addColumnIfMissing('watch_targets', 'priority', 'INTEGER NOT NULL DEFAULT 1'); +// Droit de couper une capture de rang inférieur. Refusé par défaut : un +// enregistrement en cours ne s'interrompt pas sans un geste explicite. +addColumnIfMissing('watch_targets', 'preempt', 'INTEGER NOT NULL DEFAULT 0'); /** * `idle` — le « revient bientôt » de Stripchat — relevait de la clôture et non de @@ -460,6 +469,8 @@ interface TargetRow { notify: number; auto_record: number; favorite: number; + priority: number; + preempt: number; state: string; raw_status: string | null; state_since: number; @@ -485,6 +496,8 @@ function toTarget(row: TargetRow): WatchTarget { notify: Number(row.notify) === 1, favorite: Number(row.favorite) === 1, autoRecord: Number(row.auto_record) === 1, + priority: Number(row.priority), + preempt: Number(row.preempt) === 1, state: (row.state as StreamState) ?? 'unknown', rawStatus: row.raw_status, stateSince: Number(row.state_since), @@ -507,9 +520,11 @@ const targetStmts = { state, state_since, created_at) VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?) `), - updateSettings: db.prepare( - 'UPDATE watch_targets SET label = ?, agent_id = ?, notify = ?, auto_record = ?, favorite = ? WHERE id = ?', - ), + updateSettings: db.prepare(` + UPDATE watch_targets + SET label = ?, agent_id = ?, notify = ?, auto_record = ?, favorite = ?, priority = ?, preempt = ? + WHERE id = ? + `), updateState: db.prepare(` UPDATE watch_targets SET state = ?, raw_status = ?, state_since = ?, last_checked_at = ?, last_error = ?, @@ -550,7 +565,7 @@ export const targetsRepo = { input.username, input.label ?? null, input.agentId ?? null, - input.notify === false ? 0 : 1, + input.notify === true ? 1 : 0, now, now, ); @@ -567,6 +582,8 @@ export const targetsRepo = { notify: boolean; autoRecord: boolean; favorite: boolean; + priority: number; + preempt: boolean; }, ): void { targetStmts.updateSettings.run( @@ -575,6 +592,8 @@ export const targetsRepo = { settings.notify ? 1 : 0, settings.autoRecord ? 1 : 0, settings.favorite ? 1 : 0, + settings.priority, + settings.preempt ? 1 : 0, id, ); }, diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts index 7ebb38e..4aadf1d 100644 --- a/packages/server/src/hub.ts +++ b/packages/server/src/hub.ts @@ -122,17 +122,24 @@ class Hub { // 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)); + spansRepo.touch(open.id, now, open.targetId ? null : this.recordingTarget(agentId)?.id ?? null); } else { - spansRepo.open(agentId, this.recordedTargetId(agentId), now); + spansRepo.open(agentId, this.recordingTarget(agentId)?.id ?? null, 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 { + /** + * Le profil que cette VM capture, d'après le pseudo sur lequel sa veille est + * calée — `startTargetRecording()` l'y pose, c'est le seul lien fiable. + * + * Nul si la VM n'enregistre pas, ou si sa veille ne désigne aucun profil + * suivi : une capture lancée à la main depuis l'onglet Agents, par exemple. + */ + recordingTarget(agentId: string): WatchTarget | null { + if (!this.statusOf(agentId).recording) return null; const record = agentsRepo.get(agentId); if (!record?.watch.username) return null; - return targetsRepo.findByUsername(record.watch.provider, record.watch.username)?.id ?? null; + return targetsRepo.findByUsername(record.watch.provider, record.watch.username); } resolveCommand(agentId: string, requestId: string, ok: boolean, data: unknown, error?: string): void { diff --git a/packages/server/src/recorder.ts b/packages/server/src/recorder.ts index ca3fb95..30fae2f 100644 --- a/packages/server/src/recorder.ts +++ b/packages/server/src/recorder.ts @@ -1,8 +1,19 @@ import type { WatchTarget } from '@stream-control/shared'; -import { normalizeWatchSettings } from '@stream-control/shared'; -import { agentsRepo } from './db.ts'; +import { normalizeWatchSettings, preemptionVerdict } from '@stream-control/shared'; +import { agentsRepo, type AgentRecord } from './db.ts'; import { hub } from './hub.ts'; +/** + * Attente de la confirmation d'arrêt avant de relancer sur le même agent. + * + * Le résultat de la commande revient avant que le statut ne le reflète : c'est + * le cycle de statut de l'agent (deux secondes) qui fait foi, et OBS met un + * instant à clore son fichier. Relancer trop tôt se heurterait au garde-fou + * « déjà en enregistrement » que l'on vient justement de lever. + */ +const STOP_TIMEOUT_MS = 20_000; +const STOP_POLL_MS = 250; + /** * Lance l'enregistrement d'un profil sur l'agent qui lui est assigné. * @@ -18,10 +29,12 @@ export async function startTargetRecording(target: WatchTarget): Promise { if (!record) throw new Error("L'agent assigné n'existe plus"); if (!hub.isOnline(record.id)) throw new Error(`Agent « ${record.name} » hors-ligne`); - // Une VM n'enregistre qu'un flux à la fois : écraser une capture en cours - // perdrait la première sans que personne ne l'ait demandé. + // Une VM n'enregistre qu'un flux à la fois. Écraser une capture en cours + // reste refusé par défaut — sauf si ce profil a reçu le droit d'interrompre + // et qu'il l'emporte en priorité, auquel cas on referme proprement avant de + // relancer plutôt que de laisser les deux se marcher dessus. if (hub.statusOf(record.id).recording) { - throw new Error(`Agent « ${record.name} » déjà en enregistrement`); + await preemptRecording(record, target); } // La surveillance de l'agent suit le profil qu'on enregistre : c'est elle qui @@ -69,3 +82,47 @@ export async function startTargetRecording(target: WatchTarget): Promise { ); } } + +/** + * Referme la capture en cours au profit de `target`, ou refuse en expliquant. + * + * Le verdict est calculé côté partagé et non ici : le dashboard doit pouvoir + * annoncer ce que fera le bouton — « interrompt Bob » plutôt qu'un échec + * découvert après coup — et deux règles séparées finiraient par diverger. + */ +async function preemptRecording(record: AgentRecord, target: WatchTarget): Promise { + const current = hub.recordingTarget(record.id); + const verdict = preemptionVerdict(target, current); + + if (!verdict.allowed) { + throw new Error(`Agent « ${record.name} » déjà en enregistrement : ${verdict.reason}`); + } + + const name = target.label ?? target.username; + const displaced = current ? (current.label ?? current.username) : 'la capture en cours'; + hub.log( + record.id, + 'warn', + `Priorité : « ${displaced} » interrompu au profit de « ${name} » sur ${record.name}`, + Date.now(), + 'record.stopped', + ); + + await stopAgentRecording(record); +} + +/** Arrête la capture et attend que l'agent le confirme par son statut. */ +export async function stopAgentRecording(record: AgentRecord): Promise { + await hub.sendCommand(record.id, record.browser.enabled ? 'capture.stop' : 'record.stop'); + + const deadline = Date.now() + STOP_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!hub.statusOf(record.id).recording) return; + await delay(STOP_POLL_MS); + } + throw new Error(`Agent « ${record.name} » n'a pas confirmé l'arrêt de sa capture`); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/server/src/watchlist.ts b/packages/server/src/watchlist.ts index c814b43..9b10233 100644 --- a/packages/server/src/watchlist.ts +++ b/packages/server/src/watchlist.ts @@ -1,5 +1,9 @@ import type { StreamState, WatchTarget } from '@stream-control/shared'; -import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/shared'; +import { + DEFAULT_WATCH_SETTINGS, + fetchStripchatStatus, + preemptionVerdict, +} from '@stream-control/shared'; import { config } from './config.ts'; import { sessionsRepo, targetsRepo } from './db.ts'; import { hub } from './hub.ts'; @@ -51,7 +55,14 @@ class Watchlist { if (this.running) return; // un cycle lent ne doit pas s'empiler sur le suivant this.running = true; try { - const targets = targetsRepo.list(); + // Du plus prioritaire au moins prioritaire : c'est l'ordre de sondage qui + // décide qui tente sa chance en premier sur une VM libre. Sonder au + // hasard laisserait un profil mineur s'en emparer, pour se faire couper + // dans la foulée par son aîné — le résultat serait le même, au prix d'un + // fichier de quelques secondes. + const targets = targetsRepo + .list() + .sort((a, b) => b.priority - a.priority || a.username.localeCompare(b.username)); // Séquentiel et espacé : une poignée de profils ne justifie pas de // marteler l'API en parallèle. for (const target of targets) { @@ -162,9 +173,16 @@ class Watchlist { if (Date.now() - liveSince < config.autoRecordDelayMs) return; if (!hub.isOnline(target.agentId)) return; - // Une VM déjà occupée n'est jamais préemptée : l'enregistrement en cours - // prime sur celui qu'on allait lancer. - if (hub.statusOf(target.agentId).recording) return; + + // VM occupée : par défaut la capture en place l'emporte, sauf si ce profil + // a reçu le droit d'interrompre et le rang pour le faire. Le verdict est + // recalculé par startTargetRecording — le refaire ici évite seulement de + // consommer une des trois tentatives à chaque cycle pour une interruption + // qui restera refusée tant que rien ne change. + if (hub.statusOf(target.agentId).recording) { + const verdict = preemptionVerdict(target, hub.recordingTarget(target.agentId)); + if (!verdict.allowed) return; + } const agentId = target.agentId; const name = target.label ?? target.username; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3ceaae9..b560ba4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -884,10 +884,28 @@ export interface WatchTarget { favorite: boolean; /** * Lancer l'enregistrement sans intervention dès que ce profil est en direct - * depuis assez longtemps. Exige un agent assigné, et un seul profil en - * automatisme par agent : une VM n'enregistre qu'un flux à la fois. + * depuis assez longtemps. Exige un agent assigné. + * + * Plusieurs profils peuvent l'activer sur une même VM : elle n'en capture + * qu'un à la fois, et c'est {@link WatchTarget.priority} qui arbitre. */ autoRecord: boolean; + /** + * Rang dans la file d'attente d'une VM, le plus élevé l'emportant. + * + * L'échelle est globale pour rester comparable partout, mais elle ne tranche + * qu'entre profils assignés au même agent : deux VM distinctes ne se + * disputent rien. + */ + priority: number; + /** + * Autorise ce profil à couper une capture de priorité inférieure sur sa VM. + * + * Séparé de la priorité, et non déduit d'elle : interrompre un + * enregistrement en cours perd la fin d'un fichier. Cela ne doit pas + * découler d'un simple changement de rang, mais d'un choix explicite. + */ + preempt: boolean; state: StreamState; rawStatus: string | null; /** Depuis quand l'état est stable, selon nos propres observations. */ @@ -963,6 +981,76 @@ export function recordingUsername(agent: Pick): s return agent.watch.username || null; } +/** + * Rangs proposés par l'interface. La colonne reste un entier libre : affiner + * l'échelle un jour ne demandera pas de migration. + */ +export const PRIORITY_LEVELS = [ + { value: 0, label: 'Basse' }, + { value: 1, label: 'Normale' }, + { value: 2, label: 'Haute' }, + { value: 3, label: 'Critique' }, +] as const; + +export const DEFAULT_PRIORITY = 1; + +export function priorityLabel(value: number): string { + return PRIORITY_LEVELS.find((level) => level.value === value)?.label ?? `Rang ${value}`; +} + +export interface PreemptionVerdict { + allowed: boolean; + /** Pourquoi l'interruption est refusée — destiné à être affiché tel quel. */ + reason?: string; +} + +/** + * `candidate` a-t-il le droit de couper la capture en cours au profit de la sienne ? + * + * Trois refus, dans cet ordre, du plus prudent au plus attendu : + * + * 1. **Capture non identifiée.** Si l'on ne sait pas quel profil la VM + * enregistre, on ne la coupe pas : le risque est de détruire une capture + * dont on ignore la valeur, et l'on n'a de toute façon aucune priorité à + * lui comparer. + * 2. **Interruption non autorisée** sur le profil candidat. + * 3. **Priorité insuffisante** — strictement supérieure exigée, ce qui rend + * les égalités inoffensives : à rang égal, la capture en place l'emporte. + */ +export function preemptionVerdict( + candidate: Pick, + current: Pick | null, +): PreemptionVerdict { + if (!current) { + return { + allowed: false, + reason: "la capture en cours n'a pas pu être rattachée à un profil suivi", + }; + } + // Rien à interrompre : c'est déjà ce profil qui est capturé. Refusé plutôt + // qu'autorisé — l'appelant s'apprêtait à lancer une seconde capture. + if (current.id === candidate.id) { + return { allowed: false, reason: 'sa capture est déjà en cours sur cette VM' }; + } + + const name = current.label ?? current.username; + if (!candidate.preempt) { + return { + allowed: false, + reason: `« ${name} » est en cours de capture et l'interruption n'est pas autorisée sur ce profil`, + }; + } + if (candidate.priority <= current.priority) { + return { + allowed: false, + reason: + `« ${name} » est en cours de capture avec une priorité au moins égale ` + + `(${priorityLabel(current.priority)})`, + }; + } + return { allowed: true }; +} + /** Cette VM enregistre-t-elle bien *ce* profil, et non un autre qui lui est assigné ? */ export function isRecordingTarget( agent: Pick | null | undefined, diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index cedb013..fa102d1 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -121,7 +121,12 @@ export const api = { updateTarget: ( id: string, - body: Partial>, + body: Partial< + Pick< + WatchTarget, + 'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite' | 'priority' | 'preempt' + > + >, ) => request<{ target: WatchTarget }>(`/watchlist/${id}`, { method: 'PATCH', diff --git a/packages/web/src/components/StreamerDetail.tsx b/packages/web/src/components/StreamerDetail.tsx index f01323c..6ec7392 100644 --- a/packages/web/src/components/StreamerDetail.tsx +++ b/packages/web/src/components/StreamerDetail.tsx @@ -1,8 +1,16 @@ import { useEffect, useState } from 'react'; import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared'; +import { PRIORITY_LEVELS, priorityLabel } from '@stream-control/shared'; import { api } from '../api'; import { formatDateTime, formatDuration, formatRelative } from '../format'; -import { autoRecordHint, recordingContext, stateLabel, TIMELINE_RANGES } from '../streamers'; +import { + agentQueue, + autoRecordHint, + preemption, + recordingContext, + stateLabel, + TIMELINE_RANGES, +} from '../streamers'; import { Timeline } from './Timeline'; interface Props { @@ -33,6 +41,8 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null; const context = recordingContext(target, agent, targets); + const verdict = preemption(target, context); + const queue = target.agentId ? agentQueue(target.agentId, targets) : []; const state = stateLabel(target); const isLive = target.state === 'public'; const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null; @@ -210,10 +220,62 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro + + + + + {queue.length > 1 && ( +

+ Ordre sur {agent?.name} :{' '} + {queue.map((entry, index) => ( + + {index > 0 && ' · '} + {index + 1}. {entry.label ?? entry.username} ({priorityLabel(entry.priority)}) + {entry.preempt ? ' ⚡' : ''} + + ))} +

+ )} + {context.busyWith && (

ⓘ {agent?.name} enregistre actuellement {context.busyWith} — ce profil n'est pas - capturé. Une VM ne traite qu'un flux à la fois. + capturé.{' '} + {verdict?.allowed + ? 'Sa priorité lui permet de reprendre la VM.' + : `Il n'en reprendra pas la main : ${verdict?.reason}.`}

)} @@ -229,10 +291,11 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro ) : ( )} )}
- {agent ? agent.name : 'aucun agent'} + + {agent ? agent.name : 'aucun agent'} + {/* Le rang par défaut resterait du bruit : il ne dit rien de plus que + l'absence de réglage. */} + {target.priority !== DEFAULT_PRIORITY && ` · ${priorityLabel(target.priority)}`} + {target.preempt && } +