Feat : Prio order for auto stream record
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user