Feat: pushover notif
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
133
packages/web/src/components/NotificationsPage.tsx
Normal file
133
packages/web/src/components/NotificationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user