Feat : Prio order for auto stream record
All checks were successful
release / build (push) Successful in 27s
release / verify-windows (push) Successful in 1m20s

This commit is contained in:
jeanotx32
2026-08-12 16:01:18 +02:00
parent 96b364bd66
commit eae694d49a
12 changed files with 442 additions and 72 deletions

View File

@@ -121,7 +121,12 @@ export const api = {
updateTarget: (
id: string,
body: Partial<Pick<WatchTarget, 'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite'>>,
body: Partial<
Pick<
WatchTarget,
'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite' | 'priority' | 'preempt'
>
>,
) =>
request<{ target: WatchTarget }>(`/watchlist/${id}`, {
method: 'PATCH',

View File

@@ -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
</span>
</label>
<label className="field">
<span>Priorité</span>
<select
value={target.priority}
disabled={busy}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { priority: Number(event.target.value) }))
}
>
{PRIORITY_LEVELS.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={target.preempt}
disabled={busy}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { preempt: event.target.checked }))
}
/>
<span>
Peut interrompre un enregistrement en cours
<span className="muted small">
{' — '}
uniquement face à une priorité strictement inférieure sur la même VM. La capture
interrompue est close proprement, son fichier reste.
</span>
</span>
</label>
{queue.length > 1 && (
<p className="muted small queue-order">
Ordre sur {agent?.name} :{' '}
{queue.map((entry, index) => (
<span key={entry.id} className={entry.id === target.id ? 'queue-self' : undefined}>
{index > 0 && ' · '}
{index + 1}. {entry.label ?? entry.username} ({priorityLabel(entry.priority)})
{entry.preempt ? ' ⚡' : ''}
</span>
))}
</p>
)}
{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.
capturé.{' '}
{verdict?.allowed
? 'Sa priorité lui permet de reprendre la VM.'
: `Il n'en reprendra pas la main : ${verdict?.reason}.`}
</p>
)}
@@ -229,10 +291,11 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
) : (
<button
className="primary"
disabled={busy || !agent?.online || !isLive || Boolean(context.busyWith)}
disabled={busy || !agent?.online || !isLive || verdict?.allowed === false}
title={verdict?.allowed === false ? verdict.reason : undefined}
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
>
● Enregistrer
{verdict?.allowed ? `● Enregistrer (interrompt ${context.busyWith})` : '● Enregistrer'}
</button>
)}
<button

View File

@@ -1,8 +1,9 @@
import { useMemo, useState, type FormEvent } from 'react';
import type { AgentView, WatchTarget } from '@stream-control/shared';
import type { AgentView, PreemptionVerdict, WatchTarget } from '@stream-control/shared';
import { DEFAULT_PRIORITY, priorityLabel } from '@stream-control/shared';
import { api } from '../api';
import { formatDateTime, formatDuration, formatRelative } from '../format';
import { recordingContext, stateLabel, type RecordingContext } from '../streamers';
import { preemption, recordingContext, stateLabel, type RecordingContext } from '../streamers';
import { StreamerDetail } from './StreamerDetail';
interface Props {
@@ -49,9 +50,9 @@ export function StreamersPage({
}
/**
* En direct d'abord, puis show privé, puis le reste ; à statut égal, les
* favoris devant. L'ordre du statut prime : un direct en cours appelle une
* décision, un favori hors-ligne non.
* En direct d'abord, puis show privé, puis le reste ; à statut égal les
* favoris devant, puis la priorité. L'ordre du statut prime : un direct en
* cours appelle une décision, un favori hors-ligne non.
*/
const ordered = useMemo(
() =>
@@ -61,6 +62,7 @@ export function StreamersPage({
return (
rank(a) - rank(b) ||
Number(b.favorite) - Number(a.favorite) ||
b.priority - a.priority ||
a.username.localeCompare(b.username)
);
}),
@@ -174,6 +176,7 @@ function StreamerCard({
const state = stateLabel(target);
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const context = recordingContext(target, agent, targets);
const verdict = preemption(target, context);
const isLive = target.state === 'public';
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
@@ -293,17 +296,23 @@ function StreamerCard({
) : (
<button
className="primary"
disabled={busy || !agent?.online || !isLive || Boolean(context.busyWith)}
title={recordTitle(target, agent, context)}
disabled={busy || !agent?.online || !isLive || verdict?.allowed === false}
title={recordTitle(target, agent, context, verdict)}
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
>
Enregistrer
{verdict?.allowed ? '● Enregistrer ⚡' : '● Enregistrer'}
</button>
)}
<div className="spacer" />
<span className="muted small">{agent ? agent.name : 'aucun agent'}</span>
<span className="muted small">
{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 && <span title="Peut interrompre une capture de rang inférieur"> </span>}
</span>
</div>
<footer className="card-foot muted small">
@@ -326,12 +335,14 @@ function recordTitle(
target: WatchTarget,
agent: AgentView | null,
context: RecordingContext,
verdict: PreemptionVerdict | null,
): 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 (verdict?.allowed) {
return `Interrompt la capture de ${context.busyWith} sur ${agent.name} — priorité supérieure`;
}
if (verdict) return `${agent.name} déjà pris : ${verdict.reason}`;
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"

View File

@@ -1,5 +1,10 @@
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared';
import { isRecordingTarget } from '@stream-control/shared';
import type {
AgentView,
PreemptionVerdict,
StreamState,
WatchTarget,
} from '@stream-control/shared';
import { isRecordingTarget, preemptionVerdict } from '@stream-control/shared';
/** Logique d'affichage partagée entre la fiche compacte et la fiche détaillée. */
@@ -36,6 +41,8 @@ export interface RecordingContext {
self: boolean;
/** La VM capture, mais autre chose : ce que l'on peut en nommer. */
busyWith: string | null;
/** Le profil suivi que la VM capture, quand il est identifiable. */
current: WatchTarget | null;
}
export function recordingContext(
@@ -43,17 +50,50 @@ export function recordingContext(
agent: AgentView | null,
targets: WatchTarget[],
): RecordingContext {
if (!agent?.status.recording) return { self: false, busyWith: null };
if (isRecordingTarget(agent, target)) return { self: true, busyWith: null };
if (!agent?.status.recording) return { self: false, busyWith: null, current: null };
if (isRecordingTarget(agent, target)) return { self: true, busyWith: null, current: target };
// 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' };
if (!username) {
return { self: false, busyWith: 'une source non identifiée', current: null };
}
const other = targets.find((candidate) => candidate.username === username);
return { self: false, busyWith: other ? (other.label ?? other.username) : `@${username}` };
const other = targets.find((candidate) => candidate.username === username) ?? null;
return {
self: false,
busyWith: other ? (other.label ?? other.username) : `@${username}`,
current: other,
};
}
/**
* Verdict d'interruption pour ce profil, ou `null` s'il n'y a rien à couper.
*
* Même règle que côté serveur, appelée depuis le paquet partagé : le bouton
* doit annoncer ce qui va se passer — « interrompt Bob » — plutôt que de le
* faire découvrir par un échec.
*/
export function preemption(
target: WatchTarget,
context: RecordingContext,
): PreemptionVerdict | null {
if (!context.busyWith) return null;
return preemptionVerdict(target, context.current);
}
/**
* Les profils en automatisme sur une VM, du plus prioritaire au moins.
*
* C'est la seule file qui compte : deux VM distinctes ne se disputent rien, et
* un classement global mêlerait des profils qui ne se croiseront jamais.
*/
export function agentQueue(agentId: string, targets: WatchTarget[]): WatchTarget[] {
return targets
.filter((target) => target.agentId === agentId && target.autoRecord)
.sort((a, b) => b.priority - a.priority || a.username.localeCompare(b.username));
}
/**
@@ -70,7 +110,13 @@ export function autoRecordHint(
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 (context.busyWith) {
return preemption(target, context)?.allowed
? ` — prioritaire : interrompra ${context.busyWith}`
: ` — en attente : ${agent.name} enregistre ${context.busyWith}`;
}
if (target.state === 'public') return ' — armé, direct en cours';
return ' — armé, en attente du prochain direct';
}

View File

@@ -734,6 +734,18 @@ fieldset.group > legend {
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
}
.queue-order {
margin: 0;
padding: 8px 10px;
background: var(--panel-2);
border-radius: 8px;
line-height: 1.7;
}
.queue-self {
color: var(--text);
font-weight: 600;
}
.session-list {
display: flex;
flex-direction: column;