Scanner: ohne Altersgrenze suchen, Filter Anzeigenalter, Alarm-Schlagwörter
- Suche ohne 21-Tage-Grenze (BA ohne veroeffentlichtseit, Adzuna ohne max_days_old); Einstellung Höchstalter entfällt - Dashboard: Filter Anzeigenalter (bis/älter als 21 Tage), Sortierung älteste Anzeige, Filter nur Alarm, Zeitraum heißt jetzt Gefunden - Suchagent: Schlagwörter mit Alarm; Treffer mit alarm = 1 melden sich über den AlarmWatcher (App-weit, jede Minute) mit Ton und Pop-up, Quittieren gilt für alle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b34a1968df
commit
1ad774fdd2
@@ -18,6 +18,7 @@ import { Projects } from './components/projects/Projects';
|
||||
import { History } from './components/history/History';
|
||||
import { ScannerDashboard } from './components/scanner/ScannerDashboard';
|
||||
import { ScannerAgents } from './components/scanner/ScannerAgents';
|
||||
import { AlarmWatcher } from './components/scanner/AlarmWatcher';
|
||||
import { Settings } from './components/settings/Settings';
|
||||
|
||||
/*
|
||||
@@ -197,6 +198,8 @@ export const App: React.FC = () => {
|
||||
{/* Faellige und liegengebliebene Leads melden sich von selbst -
|
||||
unabhaengig davon, welche Ansicht gerade offen ist. */}
|
||||
<ReminderWatcher onChanged={loadShared} />
|
||||
{/* Alarm-Schlagwörter des Scanners: Ton + Pop-up, in jeder Ansicht. */}
|
||||
<AlarmWatcher />
|
||||
</div>
|
||||
</div>
|
||||
</NavigationProvider>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { scannerApi, type ScannerAlarm } from '../../lib/api';
|
||||
import { useNavigation } from '../../lib/navigation';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { PrimaryButton, SecondaryButton } from '../ui/form';
|
||||
import { useToast } from '../Toast';
|
||||
import { AlarmBell, SOURCE_LABELS, formatAge, playAlarmSound } from './scannerUi';
|
||||
|
||||
/*
|
||||
* Alarm des Scanners: Treffer mit Alarm-Schlagwort melden sich mit Ton und
|
||||
* Pop-up - egal, welche Ansicht offen ist (deshalb hängt der Wächter in App,
|
||||
* nicht im Scanner-Dashboard; beim perinco-Scanner hörte man den Ton nur dort).
|
||||
*
|
||||
* Abgefragt wird jede Minute. Der Scanner läuft stündlich, ein Fund meldet
|
||||
* sich also spätestens eine Minute nach dem Lauf. Quittieren gilt für alle
|
||||
* Benutzer (serverseitig `alarm_ack`).
|
||||
*/
|
||||
|
||||
const POLL_MS = 60_000;
|
||||
|
||||
export const AlarmWatcher: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const { setView } = useNavigation();
|
||||
const [alarms, setAlarms] = useState<ScannerAlarm[]>([]);
|
||||
/** Welche Alarme schon geklingelt haben - neue klingeln erneut. */
|
||||
const rung = useRef(new Set<number>());
|
||||
|
||||
const check = useCallback(async () => {
|
||||
try {
|
||||
const { alarms: list } = await scannerApi.alarms();
|
||||
setAlarms(list);
|
||||
if (list.some((alarm) => !rung.current.has(alarm.id))) {
|
||||
list.forEach((alarm) => rung.current.add(alarm.id));
|
||||
playAlarmSound();
|
||||
}
|
||||
} catch {
|
||||
/* kein Netz oder Sitzung abgelaufen - beim nächsten Mal */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void check();
|
||||
const timer = setInterval(() => void check(), POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [check]);
|
||||
|
||||
if (alarms.length === 0) return null;
|
||||
|
||||
const acknowledge = async (goToScanner = false) => {
|
||||
const ids = alarms.map((alarm) => alarm.id);
|
||||
setAlarms([]);
|
||||
try {
|
||||
await scannerApi.ackAlarms(ids);
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Quittieren fehlgeschlagen.', 'error');
|
||||
}
|
||||
if (goToScanner) setView('Scanner');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
size="md"
|
||||
onClose={() => void acknowledge()}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<AlarmBell className="h-5 w-5" />
|
||||
{alarms.length === 1 ? 'Alarm-Treffer im Scanner' : `${alarms.length} Alarm-Treffer im Scanner`}
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton type="button" onClick={() => void acknowledge()}>
|
||||
Gesehen
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" onClick={() => void acknowledge(true)}>
|
||||
Zum Scanner
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ul className="max-h-[50vh] space-y-2 overflow-y-auto">
|
||||
{alarms.map((alarm) => (
|
||||
<li key={alarm.id} className="rounded-lg border border-red-200 bg-red-50/50 p-3">
|
||||
<a
|
||||
href={alarm.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-ink-900 hover:text-brand-700"
|
||||
>
|
||||
{alarm.title} ↗
|
||||
</a>
|
||||
<p className="mt-0.5 text-xs text-ink-500">
|
||||
{[alarm.company, alarm.location, SOURCE_LABELS[alarm.source]].filter(Boolean).join(' · ')} ·{' '}
|
||||
{formatAge(alarm.published_at)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-600">
|
||||
Agent <strong>{alarm.agent_name}</strong> · gefunden:{' '}
|
||||
{alarm.hits.map((hit) => (
|
||||
<span key={hit} className="mr-1 rounded-full border border-red-200 bg-surface px-1.5 py-0.5 text-red-700">
|
||||
{hit}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import { useNavigation } from '../../lib/navigation';
|
||||
import { useToast } from '../Toast';
|
||||
import { ConfirmDialog, Modal } from '../ui/Modal';
|
||||
import { Field, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
|
||||
import { SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi';
|
||||
import { AlarmBell, SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi';
|
||||
|
||||
/*
|
||||
* Scanner -> Suchagenten.
|
||||
@@ -26,6 +26,7 @@ const EMPTY: ScannerAgentInput = {
|
||||
keywords: [],
|
||||
sources: ['ba', 'stepstone', 'karriere'],
|
||||
excludes: [],
|
||||
alarm_keywords: [],
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
@@ -145,6 +146,7 @@ export const ScannerAgents: React.FC = () => {
|
||||
keywords: agent.keywords,
|
||||
sources: agent.sources,
|
||||
excludes: agent.excludes,
|
||||
alarm_keywords: agent.alarm_keywords,
|
||||
is_active: agent.is_active,
|
||||
},
|
||||
})
|
||||
@@ -169,6 +171,19 @@ export const ScannerAgents: React.FC = () => {
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
{agent.alarm_keywords.length > 0 && (
|
||||
<Row label="Schlagwörter mit Alarm">
|
||||
{agent.alarm_keywords.map((term) => (
|
||||
<span
|
||||
key={term}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
|
||||
>
|
||||
<AlarmBell className="h-3 w-3" />
|
||||
{term}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Börsen">
|
||||
{agent.sources.map((id) => (
|
||||
<span
|
||||
@@ -209,7 +224,7 @@ export const ScannerAgents: React.FC = () => {
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={busy || !value?.name.trim() || !value?.keywords.length || !value?.sources.length}
|
||||
disabled={busy || !value?.name.trim() || !(value?.keywords.length || value?.alarm_keywords.length) || !value?.sources.length}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
Speichern
|
||||
@@ -236,6 +251,18 @@ export const ScannerAgents: React.FC = () => {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Schlagwörter mit Alarm"
|
||||
hint="Werden gesucht und gezählt wie Schlagwörter. Wird eines gefunden, gibt es kurz einen Ton und ein Pop-up – in jeder Ansicht des CRM."
|
||||
>
|
||||
<TermInput
|
||||
value={value.alarm_keywords}
|
||||
onChange={(alarm_keywords) => patch({ alarm_keywords })}
|
||||
placeholder="z. B. Interim CFO"
|
||||
tone="alarm"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Ausschlussbegriffe" hint="Kommt einer davon in Titel oder Text vor, wird die Anzeige ignoriert.">
|
||||
<TermInput
|
||||
value={value.excludes}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useToast } from '../Toast';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
|
||||
import {
|
||||
AlarmBell,
|
||||
SOURCE_LABELS,
|
||||
SOURCE_ORDER,
|
||||
STATUS_LABELS,
|
||||
@@ -63,7 +64,10 @@ export const ScannerDashboard: React.FC = () => {
|
||||
const [knownOnly, setKnownOnly] = useState(false);
|
||||
const [showDiscarded, setShowDiscarded] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<'known' | 'score' | 'published'>('known');
|
||||
const [sort, setSort] = useState<'known' | 'score' | 'published' | 'oldest'>('known');
|
||||
/** Alter der Anzeige: alle | bis 21 Tage | älter als 21 Tage */
|
||||
const [age, setAge] = useState<'' | 'recent' | 'old'>('');
|
||||
const [alarmOnly, setAlarmOnly] = useState(false);
|
||||
|
||||
const wasRunning = useRef(false);
|
||||
|
||||
@@ -77,6 +81,8 @@ export const ScannerDashboard: React.FC = () => {
|
||||
known: knownOnly ? 1 : undefined,
|
||||
status: showDiscarded ? 'alle' : undefined,
|
||||
q: search.trim() || undefined,
|
||||
age: age || undefined,
|
||||
alarm: alarmOnly ? 1 : undefined,
|
||||
sort,
|
||||
});
|
||||
setMatches(data.matches);
|
||||
@@ -84,7 +90,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Treffer nicht ladbar.', 'error');
|
||||
}
|
||||
}, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, sort, toast]);
|
||||
}, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, age, alarmOnly, sort, toast]);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
try {
|
||||
@@ -168,7 +174,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-ink-500">
|
||||
Offene Stellen aus Jobbörsen in DACH, höchstens {overview?.schedule.maxAge ?? 21} Tage alt.
|
||||
Offene Stellen aus Jobbörsen in DACH – jedes Alter, gefiltert wird unten nach „Anzeigenalter“.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span
|
||||
@@ -236,7 +242,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-ink-200 p-4">
|
||||
<h2 className="mr-auto text-sm font-bold uppercase tracking-wider text-ink-900">Stellen-Treffer</h2>
|
||||
|
||||
<Filter label="Zeitraum" className="w-44">
|
||||
<Filter label="Gefunden" className="w-44">
|
||||
<select value={hours} onChange={(event) => setHours(Number(event.target.value))} className={inputClass}>
|
||||
{PERIODS.map((period) => (
|
||||
<option key={period.value} value={period.value}>
|
||||
@@ -265,6 +271,13 @@ export const ScannerDashboard: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Anzeigenalter" className="w-44">
|
||||
<select value={age} onChange={(event) => setAge(event.target.value as typeof age)} className={inputClass}>
|
||||
<option value="">alle</option>
|
||||
<option value="recent">bis 21 Tage</option>
|
||||
<option value="old">älter als 21 Tage</option>
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Ab %" className="w-20">
|
||||
<input
|
||||
type="number"
|
||||
@@ -281,6 +294,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
<option value="known">bekannte Firmen zuerst</option>
|
||||
<option value="score">beste Quote</option>
|
||||
<option value="published">neueste Anzeige</option>
|
||||
<option value="oldest">älteste Anzeige</option>
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Suche" className="w-44">
|
||||
@@ -296,6 +310,10 @@ export const ScannerDashboard: React.FC = () => {
|
||||
<input type="checkbox" checked={knownOnly} onChange={(event) => setKnownOnly(event.target.checked)} />
|
||||
nur bekannte Firmen
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={alarmOnly} onChange={(event) => setAlarmOnly(event.target.checked)} />
|
||||
nur Alarm
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={showDiscarded} onChange={(event) => setShowDiscarded(event.target.checked)} />
|
||||
verworfene zeigen
|
||||
@@ -340,7 +358,10 @@ export const ScannerDashboard: React.FC = () => {
|
||||
} ${match.status === 'verworfen' ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<ScoreBox score={match.score} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ScoreBox score={match.score} />
|
||||
{match.alarm && <AlarmBell />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-ink-900">{match.agent_name}</td>
|
||||
<td className="max-w-md px-4 py-3">
|
||||
|
||||
@@ -78,12 +78,58 @@ export const formatAge = (value: string | null) => {
|
||||
|
||||
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
const BELL_ICON =
|
||||
'M12 22a2.5 2.5 0 0 0 2.5-2.5h-5A2.5 2.5 0 0 0 12 22Zm7-5V11a7 7 0 0 0-5.5-6.8V3a1.5 1.5 0 0 0-3 0v1.2A7 7 0 0 0 5 11v6l-2 2v1h18v-1l-2-2Z';
|
||||
|
||||
/** Glocke für Treffer mit Alarm-Schlagwort. */
|
||||
export const AlarmBell: React.FC<{ className?: string }> = ({ className = 'h-4 w-4' }) => (
|
||||
<span title="Alarm-Schlagwort" className="inline-flex text-red-600">
|
||||
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden="true">
|
||||
<path d={BELL_ICON} />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* Kurzer Zweiklang über WebAudio - ohne Audiodatei.
|
||||
*
|
||||
* Browser spielen Ton erst, nachdem auf der Seite einmal geklickt oder getippt
|
||||
* wurde (Autoplay-Regel). Klappt es nicht, bleibt es still; das Pop-up kommt
|
||||
* trotzdem.
|
||||
*/
|
||||
export function playAlarmSound() {
|
||||
try {
|
||||
const AudioContextClass =
|
||||
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
const context = new AudioContextClass();
|
||||
const tone = (frequency: number, start: number) => {
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.value = frequency;
|
||||
gain.gain.setValueAtTime(0.0001, context.currentTime + start);
|
||||
gain.gain.exponentialRampToValueAtTime(0.3, context.currentTime + start + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + start + 0.35);
|
||||
oscillator.connect(gain).connect(context.destination);
|
||||
oscillator.start(context.currentTime + start);
|
||||
oscillator.stop(context.currentTime + start + 0.4);
|
||||
};
|
||||
tone(880, 0);
|
||||
tone(1175, 0.18);
|
||||
// Nicht geschlossene Kontexte zählen gegen das Browserlimit.
|
||||
setTimeout(() => void context.close().catch(() => {}), 1000);
|
||||
} catch {
|
||||
/* kein Ton möglich - das Pop-up reicht */
|
||||
}
|
||||
}
|
||||
|
||||
/** Eingabe von Begriffen als Chips: Enter oder Komma fügt hinzu. */
|
||||
export const TermInput: React.FC<{
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
tone?: 'brand' | 'red';
|
||||
tone?: 'brand' | 'red' | 'alarm';
|
||||
}> = ({ value, onChange, placeholder, tone = 'brand' }) => {
|
||||
const [draft, setDraft] = React.useState('');
|
||||
|
||||
@@ -99,12 +145,17 @@ export const TermInput: React.FC<{
|
||||
};
|
||||
|
||||
const chip =
|
||||
tone === 'red' ? 'border-red-200 bg-red-50 text-red-700' : 'border-brand-200 bg-brand-50 text-brand-700';
|
||||
tone === 'red'
|
||||
? 'border-red-200 bg-red-50 text-red-700'
|
||||
: tone === 'alarm'
|
||||
? 'border-amber-300 bg-amber-50 text-amber-800'
|
||||
: 'border-brand-200 bg-brand-50 text-brand-700';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[42px] flex-wrap items-center gap-1.5 rounded-lg border border-ink-200 bg-surface px-2 py-1.5">
|
||||
{value.map((term) => (
|
||||
<span key={term} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${chip}`}>
|
||||
{tone === 'alarm' && <AlarmBell className="h-3 w-3" />}
|
||||
{term}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,8 +7,8 @@ import { WEEKDAY_LABELS } from '../scanner/scannerUi';
|
||||
/*
|
||||
* Reiter "Scanner" in den Einstellungen (nur Administratoren).
|
||||
*
|
||||
* Wann und wie oft der Scanner selbst sucht, wie alt Anzeigen höchstens sein
|
||||
* dürfen, und der kostenlose API-Schlüssel für Adzuna. Die
|
||||
* Wann und wie oft der Scanner selbst sucht, und der kostenlose API-Schlüssel
|
||||
* für Adzuna. Die
|
||||
* Schlüssel kommen - wie der OpenAI-Schlüssel - nie zurück zum Browser.
|
||||
*/
|
||||
|
||||
@@ -108,17 +108,10 @@ export const ScannerSettings: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Höchstes Alter einer Anzeige" hint="In Tagen. Älteres wird verworfen.">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={schedule.maxAge}
|
||||
onChange={(event) => set({ maxAge: Number(event.target.value) || 21 })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-ink-400">
|
||||
Gesucht wird ohne Altersgrenze – das Alter der Anzeigen filtert das Dashboard („Anzeigenalter“).
|
||||
</p>
|
||||
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
|
||||
+25
-3
@@ -479,6 +479,8 @@ export interface ScannerAgent {
|
||||
keywords: string[];
|
||||
sources: ScannerSourceId[];
|
||||
excludes: string[];
|
||||
/** Schlagwörter, deren Fund mit Ton und Pop-up gemeldet wird. */
|
||||
alarm_keywords: string[];
|
||||
is_active: boolean;
|
||||
matches: number;
|
||||
}
|
||||
@@ -491,8 +493,21 @@ export interface ScannerSchedule {
|
||||
to: string;
|
||||
/** Minuten */
|
||||
interval: number;
|
||||
/** höchstes Alter einer Anzeige in Tagen */
|
||||
maxAge: number;
|
||||
}
|
||||
|
||||
export interface ScannerAlarm {
|
||||
id: number;
|
||||
score: number;
|
||||
hits: string[];
|
||||
created_at: string;
|
||||
agent_name: string;
|
||||
source: ScannerSourceId;
|
||||
title: string;
|
||||
company: string | null;
|
||||
location: string | null;
|
||||
url: string;
|
||||
published_at: string | null;
|
||||
company_id: number | null;
|
||||
}
|
||||
|
||||
export interface ScannerSource {
|
||||
@@ -525,6 +540,8 @@ export interface ScannerMatch {
|
||||
score: number;
|
||||
hits: string[];
|
||||
status: ScannerMatchStatus;
|
||||
/** Alarm-Schlagwort getroffen */
|
||||
alarm: boolean;
|
||||
created_at: string;
|
||||
agent_id: number;
|
||||
agent_name: string;
|
||||
@@ -549,7 +566,10 @@ export interface ScannerSecrets {
|
||||
adzunaKey: string | null;
|
||||
}
|
||||
|
||||
export type ScannerAgentInput = Pick<ScannerAgent, 'name' | 'keywords' | 'sources' | 'excludes' | 'is_active'>;
|
||||
export type ScannerAgentInput = Pick<
|
||||
ScannerAgent,
|
||||
'name' | 'keywords' | 'alarm_keywords' | 'sources' | 'excludes' | 'is_active'
|
||||
>;
|
||||
|
||||
const put = <T>(path: string, body: unknown) =>
|
||||
apiRequest<T>(path, { method: 'PUT', body: JSON.stringify(body) });
|
||||
@@ -563,6 +583,8 @@ export const scannerApi = {
|
||||
matches: (params: Record<string, unknown> = {}) =>
|
||||
get<{ total: number; matches: ScannerMatch[] }>(`/scanner/matches${query(params)}`),
|
||||
setStatus: (id: number, status: ScannerMatchStatus) => patch<{ ok: true }>(`/scanner/matches/${id}`, { status }),
|
||||
alarms: () => get<{ alarms: ScannerAlarm[] }>('/scanner/alarms'),
|
||||
ackAlarms: (ids: number[]) => post<{ ok: true }>('/scanner/alarms/ack', { ids }),
|
||||
scan: () => post<{ started: true }>('/scanner/scan'),
|
||||
settings: () => get<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings'),
|
||||
saveSettings: (changes: Partial<ScannerSchedule> & Partial<Record<keyof ScannerSecrets, string>>) =>
|
||||
|
||||
Reference in New Issue
Block a user