Feat: pushover notif
All checks were successful
release / build (push) Successful in 43s
release / verify-windows (push) Successful in 2m50s

This commit is contained in:
jeanotx32
2026-08-12 11:30:36 +02:00
parent d14369a914
commit 399f54f290
11 changed files with 410 additions and 2 deletions

View File

@@ -7,6 +7,7 @@ import { AgentCard } from './components/AgentCard';
import { AgentSettings } from './components/AgentSettings';
import { AgentHistory } from './components/AgentHistory';
import { LogPanel } from './components/LogPanel';
import { NotificationsPage } from './components/NotificationsPage';
import { StreamersPage } from './components/StreamersPage';
import { useHashRoute } from './useHashRoute';
@@ -185,6 +186,12 @@ export function App() {
>
Journal
</button>
<button
className={route === 'notifications' ? 'tab active' : 'tab'}
onClick={() => setRoute('notifications')}
>
Notifications
</button>
</nav>
<div className="topbar-actions">
@@ -228,6 +235,8 @@ export function App() {
{route === 'logs' ? (
<LogPanel logs={logs} />
) : route === 'notifications' ? (
<NotificationsPage notify={notify} />
) : route === 'streamers' ? (
<StreamersPage
targets={targets}

View File

@@ -1,4 +1,10 @@
import type { AgentAction, AgentView, LogEntry, WatchTarget } from '@stream-control/shared';
import type {
AgentAction,
AgentView,
LogEntry,
PushoverSettings,
WatchTarget,
} from '@stream-control/shared';
const TOKEN_KEY = 'stream-control.session';
@@ -130,6 +136,22 @@ export const api = {
request<{ ok: boolean }>(`/watchlist/${id}/record`, { method: 'POST' }),
stopTarget: (id: string) => request<{ ok: boolean }>(`/watchlist/${id}/stop`, { method: 'POST' }),
// --- Notifications ----------------------------------------------------------
pushoverSettings: () => request<{ settings: PushoverSettings }>('/settings/pushover'),
updatePushoverSettings: (body: PushoverSettings) =>
request<{ settings: PushoverSettings }>('/settings/pushover', {
method: 'PUT',
body: JSON.stringify(body),
}),
testPushover: (body: Pick<PushoverSettings, 'userKey' | 'appToken'>) =>
request<{ ok: true }>('/settings/pushover/test', {
method: 'POST',
body: JSON.stringify(body),
}),
};
/** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import type { PushoverSettings } from '@stream-control/shared';
import { api } from '../api';
interface Props {
notify: (message: string, tone?: 'info' | 'error') => void;
}
const EMPTY: PushoverSettings = { enabled: false, userKey: '', appToken: '' };
/**
* Réglages Pushover — un canal qui atteint l'opérateur même dashboard fermé,
* à la différence de la notification navigateur déjà en place sur l'onglet
* « Streamers » (qui exige un onglet ouvert et le focus accordé une fois).
*/
export function NotificationsPage({ notify }: Props) {
const [settings, setSettings] = useState<PushoverSettings>(EMPTY);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
api
.pushoverSettings()
.then(({ settings }) => setSettings(settings))
.catch((err) => notify(err instanceof Error ? err.message : 'Lecture impossible', 'error'))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function patch(update: Partial<PushoverSettings>) {
setSettings((current) => ({ ...current, ...update }));
}
async function save() {
setSaving(true);
try {
const { settings: saved } = await api.updatePushoverSettings(settings);
setSettings(saved);
notify('Réglages Pushover enregistrés');
} catch (err) {
notify(err instanceof Error ? err.message : 'Enregistrement impossible', 'error');
} finally {
setSaving(false);
}
}
async function test() {
setTesting(true);
try {
await api.testPushover({ userKey: settings.userKey, appToken: settings.appToken });
notify('Notification de test envoyée — vérifie ton téléphone');
} catch (err) {
notify(err instanceof Error ? err.message : 'Envoi impossible', 'error');
} finally {
setTesting(false);
}
}
// Le test exige des identifiants dans le champ, masqués ou non : un envoi
// sur des champs vides échouerait toujours côté Pushover, autant l'éviter ici.
const canTest = Boolean(settings.userKey) && Boolean(settings.appToken);
if (loading) return <main className="page"><p className="muted">Chargement</p></main>;
return (
<main className="page notifications-page">
<h2>Notifications</h2>
<p className="muted">
Alerte l'opérateur — sur son téléphone, dashboard fermé compris — quand un streamer
surveillé passe en direct. Complète la notification navigateur déjà proposée sur
l'onglet « Streamers », qui exige un onglet ouvert.
</p>
<fieldset className="group">
<legend>Pushover</legend>
<label className="checkbox">
<input
type="checkbox"
checked={settings.enabled}
onChange={(event) => patch({ enabled: event.target.checked })}
/>
<span>Activer les notifications Pushover</span>
</label>
<label className="field">
<span>Clé utilisateur (User Key)</span>
<input
value={settings.userKey}
onChange={(event) => patch({ userKey: event.target.value })}
onFocus={() => {
// Le champ masqué ne se ré-édite pas caractère par caractère :
// il se remplace en entier, comme le mot de passe OBS ailleurs.
if (settings.userKey === '********') patch({ userKey: '' });
}}
placeholder="visible sur la page d'accueil de ton compte pushover.net"
autoComplete="off"
/>
</label>
<label className="field">
<span>Jeton d'application (API Token)</span>
<input
value={settings.appToken}
onChange={(event) => patch({ appToken: event.target.value })}
onFocus={() => {
if (settings.appToken === '********') patch({ appToken: '' });
}}
placeholder="créé sur pushover.net/apps/build"
autoComplete="off"
/>
<span className="muted small">
Une seule application suffit pour tous les usages de Stream Control ; crée-la sur{' '}
<a href="https://pushover.net/apps/build" target="_blank" rel="noreferrer">
pushover.net/apps/build
</a>
.
</span>
</label>
<div className="row">
<button className="primary" disabled={saving} onClick={() => void save()}>
{saving ? 'Enregistrement' : 'Enregistrer'}
</button>
<button className="ghost" disabled={testing || !canTest} onClick={() => void test()}>
{testing ? 'Envoi' : 'Envoyer un test'}
</button>
</div>
</fieldset>
</main>
);
}

View File

@@ -578,6 +578,10 @@ fieldset.group > legend {
flex-wrap: wrap;
}
.notifications-page fieldset.group {
max-width: 480px;
}
.streamer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
export const ROUTES = ['agents', 'streamers', 'logs'] as const;
export const ROUTES = ['agents', 'streamers', 'logs', 'notifications'] as const;
export type Route = (typeof ROUTES)[number];
function currentRoute(): Route {