Feat : Streamer watch list
Some checks failed
release / build (push) Successful in 28s
release / verify-windows (push) Failing after 1m13s

This commit is contained in:
jeanotx32
2026-08-11 19:24:31 +02:00
parent b85a535dd8
commit e87afc49c0
13 changed files with 936 additions and 48 deletions

View File

@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { AgentAction, AgentView } from '@stream-control/shared';
import type { AgentAction, AgentView, WatchTarget } from '@stream-control/shared';
import { api, getToken } from './api';
import { useRealtime } from './useRealtime';
import { Login } from './components/Login';
import { AgentCard } from './components/AgentCard';
import { AgentSettings } from './components/AgentSettings';
import { LogPanel } from './components/LogPanel';
import { WatchlistPanel } from './components/WatchlistPanel';
interface Toast {
message: string;
@@ -19,13 +20,51 @@ export function App() {
const [toast, setToast] = useState<Toast | null>(null);
const [newAgentToken, setNewAgentToken] = useState<{ name: string; token: string } | null>(null);
const [notificationsEnabled, setNotificationsEnabled] = useState(
() => typeof Notification !== 'undefined' && Notification.permission === 'granted',
);
const onUnauthorized = useCallback(() => setAuthenticated(false), []);
const { agents, logs, connected } = useRealtime(authenticated, onUnauthorized);
const notify = useCallback((message: string, tone: 'info' | 'error' = 'info') => {
setToast({ message, tone });
}, []);
/** Un profil surveillé vient de passer en direct. */
const onLive = useCallback((target: WatchTarget) => {
const name = target.label ?? target.username;
setToast({ message: `${name} est en direct`, tone: 'info' });
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
const notification = new Notification(`${name} est en direct`, {
body: 'Ouvre le dashboard pour lancer l\'enregistrement.',
// Un même streamer ne doit pas empiler les notifications.
tag: `stream-control-${target.id}`,
});
notification.onclick = () => {
window.focus();
notification.close();
};
}
}, []);
const { agents, logs, targets, connected } = useRealtime(authenticated, onUnauthorized, onLive);
const enableNotifications = useCallback(async () => {
if (typeof Notification === 'undefined') {
notify('Ce navigateur ne gère pas les notifications', 'error');
return;
}
const permission = await Notification.requestPermission();
setNotificationsEnabled(permission === 'granted');
notify(
permission === 'granted'
? 'Notifications activées'
: 'Notifications refusées — à réautoriser dans les préférences du site',
permission === 'granted' ? 'info' : 'error',
);
}, [notify]);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(null), 4000);
@@ -150,6 +189,14 @@ export function App() {
</div>
)}
<WatchlistPanel
targets={targets}
agents={agents}
notify={notify}
notificationsEnabled={notificationsEnabled}
onEnableNotifications={() => void enableNotifications()}
/>
<main className="grid">
{agents.length === 0 && (
<div className="empty">

View File

@@ -1,4 +1,4 @@
import type { AgentAction, AgentView, LogEntry } from '@stream-control/shared';
import type { AgentAction, AgentView, LogEntry, WatchTarget } from '@stream-control/shared';
const TOKEN_KEY = 'stream-control.session';
@@ -97,6 +97,32 @@ export const api = {
enrollment: () =>
request<{ enabled: boolean; token: string | null; serverUrl: string }>('/enrollment'),
// --- Veille ---------------------------------------------------------------
watchlist: () => request<{ targets: WatchTarget[] }>('/watchlist'),
addTarget: (url: string, agentId: string | null) =>
request<{ target: WatchTarget }>('/watchlist', {
method: 'POST',
body: JSON.stringify({ url, agentId }),
}),
updateTarget: (id: string, body: Partial<Pick<WatchTarget, 'label' | 'agentId' | 'notify'>>) =>
request<{ target: WatchTarget }>(`/watchlist/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
removeTarget: (id: string) => request<{ ok: true }>(`/watchlist/${id}`, { method: 'DELETE' }),
checkTarget: (id: string) =>
request<{ target: WatchTarget }>(`/watchlist/${id}/check`, { method: 'POST' }),
recordTarget: (id: string) =>
request<{ ok: boolean }>(`/watchlist/${id}/record`, { method: 'POST' }),
stopTarget: (id: string) => request<{ ok: boolean }>(`/watchlist/${id}/stop`, { method: 'POST' }),
};
/** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */

View File

@@ -0,0 +1,200 @@
import { useState, type FormEvent } from 'react';
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared';
import { api } from '../api';
import { formatRelative } from '../format';
const STATE_LABELS: Record<StreamState, { text: string; tone: string }> = {
public: { text: 'en direct', tone: 'rec' },
private: { text: 'show privé', tone: 'warn' },
offline: { text: 'hors-ligne', tone: 'offline' },
unknown: { text: '…', tone: 'offline' },
};
interface Props {
targets: WatchTarget[];
agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void;
notificationsEnabled: boolean;
onEnableNotifications: () => void;
}
export function WatchlistPanel({
targets,
agents,
notify,
notificationsEnabled,
onEnableNotifications,
}: Props) {
const [url, setUrl] = useState('');
const [busy, setBusy] = useState(false);
async function add(event: FormEvent) {
event.preventDefault();
if (!url.trim()) return;
setBusy(true);
try {
const { target } = await api.addTarget(url.trim(), null);
notify(`« ${target.username} » ajouté à la veille`);
setUrl('');
} catch (err) {
notify(err instanceof Error ? err.message : 'Ajout impossible', 'error');
} finally {
setBusy(false);
}
}
const live = targets.filter((target) => target.state === 'public').length;
return (
<section className="watchlist">
<header className="watchlist-head">
<h3>Veille</h3>
<span className="muted small">
{targets.length} profil(s) · {live} en direct
</span>
<div className="spacer" />
{!notificationsEnabled && (
<button className="ghost" onClick={onEnableNotifications}>
🔔 Activer les notifications
</button>
)}
</header>
<form className="row watchlist-add" onSubmit={add}>
<input
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://fr.stripchat.com/pseudo — ou simplement le pseudo"
aria-label="Lien du profil à surveiller"
/>
<button className="primary" type="submit" disabled={busy || !url.trim()}>
Surveiller
</button>
</form>
{targets.length === 0 ? (
<p className="muted small">
Colle le lien d'un profil : tu seras prévenu dès qu'il passe en direct, et tu
pourras lancer l'enregistrement d'un clic.
</p>
) : (
<div className="target-list">
{targets.map((target) => (
<TargetRow key={target.id} target={target} agents={agents} notify={notify} />
))}
</div>
)}
</section>
);
}
function TargetRow({
target,
agents,
notify,
}: {
target: WatchTarget;
agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void;
}) {
const [busy, setBusy] = useState(false);
const state = STATE_LABELS[target.state];
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const recording = agent?.status.recording ?? false;
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);
}
}
return (
<div className={`target tone-${state.tone}`}>
<span className={`badge ${state.tone}`}>{state.text}</span>
<a className="target-name" href={target.url} target="_blank" rel="noreferrer">
{target.label ?? target.username}
</a>
<select
className="target-agent"
value={target.agentId ?? ''}
disabled={busy}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { agentId: event.target.value || null }))
}
>
<option value=""> aucun agent </option>
{agents.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
</option>
))}
</select>
<span className="small muted target-meta">
{target.lastError
? `sonde en échec : ${target.lastError}`
: target.lastCheckedAt
? `sondé ${formatRelative(target.lastCheckedAt)}`
: 'pas encore sondé'}
</span>
<div className="target-actions">
{recording ? (
<button
className="danger"
disabled={busy || !agent?.online}
onClick={() => void run(() => api.stopTarget(target.id), 'Enregistrement arrêté')}
>
Arrêter
</button>
) : (
<button
className="primary"
disabled={busy || !agent?.online || target.state !== 'public'}
title={
!target.agentId
? 'Assigne un agent'
: !agent?.online
? 'Agent hors-ligne'
: target.state !== 'public'
? 'Le streamer n\'est pas en direct'
: 'Lancer l\'enregistrement'
}
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
>
Enregistrer
</button>
)}
<button
className="icon"
disabled={busy}
title="Sonder maintenant"
onClick={() => void run(() => api.checkTarget(target.id))}
>
</button>
<button
className="icon"
disabled={busy}
title="Retirer de la veille"
onClick={() => {
if (confirm(`Retirer « ${target.username} » de la veille ?`)) {
void run(() => api.removeTarget(target.id));
}
}}
>
</button>
</div>
</div>
);
}

View File

@@ -401,6 +401,85 @@ fieldset.group > legend {
white-space: nowrap;
}
/* --- Veille --- */
.watchlist {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 18px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.watchlist-head {
display: flex;
align-items: center;
gap: 10px;
}
.watchlist-add {
max-width: 620px;
}
.target-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.target {
display: grid;
grid-template-columns: 96px minmax(120px, 1fr) 160px minmax(0, 1.2fr) auto;
gap: 10px;
align-items: center;
padding: 8px 10px;
background: var(--panel-2);
border-left: 3px solid transparent;
border-radius: 8px;
}
.target.tone-rec {
border-left-color: var(--rec);
}
.target.tone-warn {
border-left-color: var(--warn);
}
.target-name {
color: var(--text);
font-weight: 600;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.target-name:hover {
text-decoration: underline;
}
.target-agent {
width: 100%;
}
.target-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.target-actions {
display: flex;
gap: 6px;
align-items: center;
}
@media (max-width: 900px) {
.target {
grid-template-columns: 96px 1fr;
row-gap: 6px;
}
.target-meta {
grid-column: 1 / -1;
}
.target-actions {
grid-column: 1 / -1;
}
}
/* --- Journal --- */
.logs {

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import type { AgentView, LogEntry, ServerToDashboard } from '@stream-control/shared';
import type { AgentView, LogEntry, ServerToDashboard, WatchTarget } from '@stream-control/shared';
import { dashboardSocketUrl } from './api';
const MAX_LOGS = 400;
@@ -7,6 +7,7 @@ const MAX_LOGS = 400;
export interface RealtimeState {
agents: AgentView[];
logs: LogEntry[];
targets: WatchTarget[];
connected: boolean;
}
@@ -14,16 +15,27 @@ export interface RealtimeState {
* Maintient une connexion au flux `/ws/dashboard` avec reconnexion automatique
* et applique les mises à jour incrémentales d'agents et de journal.
*/
export function useRealtime(enabled: boolean, onUnauthorized: () => void): RealtimeState {
export function useRealtime(
enabled: boolean,
onUnauthorized: () => void,
onLive?: (target: WatchTarget) => void,
): RealtimeState {
const [agents, setAgents] = useState<AgentView[]>([]);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [targets, setTargets] = useState<WatchTarget[]>([]);
const [connected, setConnected] = useState(false);
const retryRef = useRef(1000);
// Gardé dans une ref : la connexion ne doit pas être relancée à chaque
// nouvelle identité de callback.
const onLiveRef = useRef(onLive);
onLiveRef.current = onLive;
useEffect(() => {
if (!enabled) {
setAgents([]);
setLogs([]);
setTargets([]);
setConnected(false);
return;
}
@@ -46,6 +58,22 @@ export function useRealtime(enabled: boolean, onUnauthorized: () => void): Realt
case 'snapshot':
setAgents(message.agents);
setLogs(message.logs);
setTargets(message.targets);
break;
case 'target':
setTargets((current) => {
const index = current.findIndex((target) => target.id === message.target.id);
if (index === -1) return [...current, message.target];
const next = [...current];
next[index] = message.target;
return next;
});
break;
case 'target.removed':
setTargets((current) => current.filter((target) => target.id !== message.targetId));
break;
case 'target.live':
onLiveRef.current?.(message.target);
break;
case 'agent':
setAgents((current) => {
@@ -84,5 +112,5 @@ export function useRealtime(enabled: boolean, onUnauthorized: () => void): Realt
};
}, [enabled, onUnauthorized]);
return { agents, logs, connected };
return { agents, logs, targets, connected };
}