Feature: Push-Benachrichtigungen (ntfy/Webhook) bei Paket fertig/fehlgeschlagen + Run-Ende

Headless-Server: Paket-Ausgaenge waren bisher nur per RDP+Log sichtbar. Neues
Modul notify.ts schickt einen fire-and-forget POST (ntfy-kompatibel: Title/
Priority/Tags als Header, Nachricht als Body) an eine konfigurierbare URL —
mit der kostenlosen ntfy-App aufs Handy, ohne Account/Port/Firewall (outbound).

- Settings: notifyUrl + 3 Ereignis-Toggles (Default aus) in Allgemein.
- Hook 1: Post-Processing-Ende (Paket completed/failed nach Entpacken).
- Hook 2: refreshPackageStatus fuer den Alle-Items-fehlgeschlagen-Fall (Link
  tot -> Paket erreicht das Post-Processing nie; ohne diesen Hook schwiege
  ausgerechnet der haeufigste Fehlerfall).
- Hook 3: finishRun mit Run-Summary (X/Y erfolgreich, Dauer, Schnitt).
- Dedup-Set pro Paket+Run, Lifecycle gespiegelt an historyRecordedPackages
  (Run-Start-Clear, Retry-Deletes, removePackageFromSession). Guard
  session.running || runPackageIds.has(id): nachlaufendes Entpacken nach
  Run-Ende benachrichtigt noch, Startup-Recovery nach App-Neustart nicht
  (sonst Doppel-Push fuer laengst fertige Pakete).
- 5s-Timeout, Fehler nur als logger.warn — blockiert nie den Download-Pfad.
- 9 Unit-Tests fuer notify.ts.
This commit is contained in:
Sucukdeluxe
2026-06-09 20:50:58 +02:00
parent be4d54a6b5
commit e753ea1296
7 changed files with 176 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { logger } from "./logger";
export interface NotifyPayload {
title: string;
message: string;
priority?: "default" | "high";
tags?: string;
}
const NOTIFY_TIMEOUT_MS = 5000;
export function isNotifyUrlValid(url: string): boolean {
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
}
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
const headers: Record<string, string> = {
"Title": payload.title,
"Content-Type": "text/plain; charset=utf-8"
};
if (payload.priority && payload.priority !== "default") {
headers["Priority"] = payload.priority;
}
if (payload.tags) {
headers["Tags"] = payload.tags;
}
return {
url: String(url || "").trim(),
init: { method: "POST", headers, body: payload.message }
};
}
export async function sendNotification(url: string, payload: NotifyPayload, fetchFn: typeof fetch = fetch): Promise<boolean> {
if (!isNotifyUrlValid(url)) {
return false;
}
try {
const request = buildNotifyRequest(url, payload);
const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) });
if (!response.ok) {
logger.warn(`Benachrichtigung fehlgeschlagen (HTTP ${response.status}): ${payload.title}`);
return false;
}
return true;
} catch (error) {
logger.warn(`Benachrichtigung fehlgeschlagen: ${String(error)}`);
return false;
}
}