fix(support): expose notification health aggregates

This commit is contained in:
Sucukdeluxe
2026-08-22 08:00:25 +02:00
parent 16679e73b5
commit 1b8f6fe4b6
7 changed files with 219 additions and 47 deletions
+13 -3
View File
@@ -70,7 +70,7 @@ import { getDebugSetupCheck } from "./debug-setup";
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildAccountSummary, buildNotificationSupportPayload, diffAccountSummary, type NotificationSupportPayload } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
@@ -239,7 +239,7 @@ export class AppController {
} catch (err) {
logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`);
}
startDebugServer(this.manager, this.storagePaths.baseDir);
startDebugServer(this.manager, this.storagePaths.baseDir, () => this.getNotificationSupportPayload());
this.runtimeStatsTimer = setInterval(() => {
this.manager.persistRuntimeStats();
this.settings = this.manager.getSettings();
@@ -1173,7 +1173,10 @@ export class AppController {
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
});
return {
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, {
hostDiagnosticsMode: "cached",
notificationStatus: this.getNotificationSupportPayload()
}),
defaultFileName: getSupportBundleDefaultFileName()
};
}
@@ -1182,6 +1185,13 @@ export class AppController {
return getSupportBundleDefaultFileName();
}
public getNotificationSupportPayload(): NotificationSupportPayload {
return buildNotificationSupportPayload(
this.notificationOutbox.getStatus(),
this.downloadHealthMonitor.getState()
);
}
public importBackup(data: Buffer, passphrase?: string): { restored: boolean; relaunch: boolean; message: string } {
let parsed: Record<string, unknown>;
try {
+21 -3
View File
@@ -12,7 +12,7 @@ import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { getRenameLogPath } from "./rename-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, normalizeNotificationSupportPayload, summarizeHistoryEntry, type NotificationSupportPayload } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
import { getConversionLogPath } from "./conversion-trace";
@@ -57,6 +57,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
{ method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." },
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
{ method: "GET", path: "/notifications", description: "Returns safe notification delivery and incident aggregates." },
{ method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." },
{ method: "GET", path: "/status", description: "Returns a live high-level status overview." },
{ method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." },
@@ -74,6 +75,7 @@ let bindPort = DEFAULT_PORT;
let runtimeBaseDir = "";
let allowlist: string[] = [];
let requestLimits = new Map<string, { startedAt: number; count: number }>();
let notificationStatusProvider: (() => NotificationSupportPayload) | null = null;
export interface DebugServerRuntimeStatus {
running: boolean;
@@ -96,6 +98,10 @@ function readSupportHistory() {
return loadHistory(getStoragePaths());
}
function readNotificationStatus(): NotificationSupportPayload {
return normalizeNotificationSupportPayload(notificationStatusProvider?.());
}
function extractDebugClientIp(req: http.IncomingMessage): string {
const forwarded = req.headers["x-forwarded-for"];
const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
@@ -926,6 +932,11 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
if (pathname === "/notifications") {
jsonResponse(res, 200, readNotificationStatus());
return;
}
if (pathname === "/history") {
const entries = readSupportHistory();
const limit = normalizeLinesParam(url.searchParams.get("limit"), 50);
@@ -1046,7 +1057,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
const fileName = getSupportBundleDefaultFileName();
buildSupportBundle(manager, runtimeBaseDir)
buildSupportBundle(manager, runtimeBaseDir, { notificationStatus: readNotificationStatus() })
.then((body) => {
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
fileName,
@@ -1089,6 +1100,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
status: buildStatusPayload(snapshot),
settings: buildRedactedSettingsPayload(readSupportSettings()),
stats: buildStatsPayload(snapshot),
notifications: readNotificationStatus(),
accounts: buildAccountSummary(readSupportSettings()),
providers: getProviderRuntimeSnapshot(),
history: {
@@ -1184,9 +1196,14 @@ function openServerSocket(): Promise<void> {
});
}
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
export function startDebugServer(
mgr: DownloadManager,
baseDir: string,
readCurrentNotificationStatus?: () => NotificationSupportPayload
): void {
runtimeBaseDir = baseDir;
manager = mgr;
notificationStatusProvider = readCurrentNotificationStatus || null;
void openServerSocket();
}
@@ -1256,6 +1273,7 @@ export function clearDebugToken(): void {
}
export function stopDebugServer(): void {
notificationStatusProvider = null;
if (server) {
server.close();
try {
+4 -1
View File
@@ -13,7 +13,7 @@ import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-rename-log";
import { getSessionLogPath } from "./session-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, normalizeNotificationSupportPayload, summarizeHistoryEntry, type NotificationSupportPayload } from "./support-data";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager";
@@ -101,6 +101,7 @@ type HostDiagnosticsMode = "full" | "cached" | "none";
interface BuildSupportBundleOptions {
hostDiagnosticsMode?: HostDiagnosticsMode;
notificationStatus?: NotificationSupportPayload;
}
function createDeferredHostDiagnostics(reason: string): unknown {
@@ -145,6 +146,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
const packageIds = Object.keys(snapshot.session.packages);
const itemIds = Object.keys(snapshot.session.items);
const debugSetup = getDebugSetupCheck(baseDir);
const notificationStatus = normalizeNotificationSupportPayload(options.notificationStatus);
addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION,
@@ -164,6 +166,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
}
});
addJson(zip, "overview/notifications.json", notificationStatus);
addJson(zip, "overview/debug-setup.json", debugSetup);
addJson(zip, "overview/self-check.json", debugSetup);
addJson(zip, "overview/history.json", {
+22 -1
View File
@@ -16,6 +16,27 @@ export interface NotificationSupportPayload {
incidentAgeMs: number | null;
}
export function normalizeNotificationSupportPayload(
value?: Partial<NotificationSupportPayload> | null
): NotificationSupportPayload {
const rawQueued = value?.queued;
const rawLastSuccessAt = value?.lastSuccessAt;
const rawIncidentAgeMs = value?.incidentAgeMs;
const queued = typeof rawQueued === "number" && Number.isFinite(rawQueued)
? Math.max(0, Math.floor(rawQueued))
: 0;
const lastSuccessAt = typeof rawLastSuccessAt === "number" && Number.isFinite(rawLastSuccessAt) && rawLastSuccessAt > 0
? Math.floor(rawLastSuccessAt)
: null;
const incidentType = value?.incidentType === "scheduler" || value?.incidentType === "no_data"
? value.incidentType
: null;
const incidentAgeMs = incidentType && typeof rawIncidentAgeMs === "number" && Number.isFinite(rawIncidentAgeMs) && rawIncidentAgeMs >= 0
? Math.floor(rawIncidentAgeMs)
: null;
return { queued, lastSuccessAt, incidentType, incidentAgeMs };
}
export function buildNotificationSupportPayload(
outbox: Pick<NotificationOutboxStatus, "queued" | "lastSuccessAt">,
health: Pick<DownloadHealthState, "incidentType" | "incidentStartedAt">,
@@ -34,7 +55,7 @@ export function buildNotificationSupportPayload(
const incidentAgeMs = incidentType && incidentStartedAt > 0
? Math.max(0, Math.floor(Number.isFinite(now) ? now : Date.now()) - incidentStartedAt)
: null;
return { queued, lastSuccessAt, incidentType, incidentAgeMs };
return normalizeNotificationSupportPayload({ queued, lastSuccessAt, incidentType, incidentAgeMs });
}
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
+52 -2
View File
@@ -54,6 +54,7 @@ import { createStoragePaths, saveHistory, saveSettings } from "../src/main/stora
import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
import type { DownloadManager } from "../src/main/download-manager";
import type { NotificationSupportPayload } from "../src/main/support-data";
import type { UiSnapshot } from "../src/shared/types";
const tempDirs: string[] = [];
@@ -326,7 +327,16 @@ async function createFixture() {
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
} as unknown as DownloadManager;
startDebugServer(manager, baseDir);
const notificationStatus: NotificationSupportPayload & Record<string, unknown> = {
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000,
events: [{ payload: "PRIVATE_DEBUG_EVENT_PAYLOAD" }],
url: "https://private.example.test/webhook",
mention: "@private"
};
startDebugServer(manager, baseDir, () => notificationStatus);
const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health`);
await new Promise((resolve) => setTimeout(resolve, 300));
@@ -334,7 +344,8 @@ async function createFixture() {
return {
baseUrl,
token,
baseDir
baseDir,
notificationStatus
};
}
@@ -359,6 +370,36 @@ afterEach(() => {
});
describe("debug-server", () => {
it("serves the exact safe notification DTO in its endpoint and diagnostics", async () => {
const fixture = await createFixture();
const response = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, unknown>;
expect(payload).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
fixture.notificationStatus.queued = 9;
fixture.notificationStatus.lastSuccessAt = 1_700_000_060_000;
const currentResponse = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
const currentPayload = await currentResponse.json() as Record<string, unknown>;
expect(currentPayload).toEqual({
queued: 9,
lastSuccessAt: 1_700_000_060_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
const diagnosticsResponse = await authedFetch(`${fixture.baseUrl}/diagnostics`, fixture.token);
const diagnostics = await diagnosticsResponse.json() as Record<string, any>;
expect(diagnostics.notifications).toEqual(currentPayload);
expect(JSON.stringify([payload, currentPayload, diagnostics.notifications])).not.toMatch(/PRIVATE_|https:\/\/private|@private|events|payload/i);
});
it("serves diagnostics with main, session, and package log tails", async () => {
const fixture = await createFixture();
const response = await authedFetch(`${fixture.baseUrl}/diagnostics?package=server-package&lines=20`, fixture.token);
@@ -585,6 +626,7 @@ describe("debug-server", () => {
expect(entries).toContain("overview/debug-setup.json");
expect(entries).toContain("overview/self-check.json");
expect(entries).toContain("overview/trace-config.json");
expect(entries).toContain("overview/notifications.json");
expect(entries).toContain("logs/audit.log");
expect(entries).toContain("logs/rename.log");
expect(entries).toContain("logs/trace.log");
@@ -593,6 +635,14 @@ describe("debug-server", () => {
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
expect(entries).not.toContain("runtime/debug_token.txt");
const notifications = JSON.parse(zip.getEntry("overview/notifications.json")?.getData().toString("utf8") || "null");
expect(notifications).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
expect(buffer.toString("utf8")).not.toMatch(/PRIVATE_DEBUG_EVENT_PAYLOAD|https:\/\/private|@private/);
});
it("rejects unauthenticated requests", async () => {
+35
View File
@@ -4,7 +4,9 @@ import path from "node:path";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest";
import { buildSupportBundle } from "../src/main/support-bundle";
import { createStoragePaths } from "../src/main/storage";
import type { DownloadManager } from "../src/main/download-manager";
import type { NotificationSupportPayload } from "../src/main/support-data";
const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
@@ -71,4 +73,37 @@ describe("buildSupportBundle (async, non-blocking)", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
expect(timerFired).toBe(true);
});
it("writes only the safe notification aggregate and excludes its runtime files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-notifications-"));
tempDirs.push(root);
const paths = createStoragePaths(root);
fs.writeFileSync(paths.notificationOutboxFile, "PRIVATE_OUTBOX_RUNTIME_PAYLOAD", "utf8");
fs.writeFileSync(paths.notificationHealthFile, "PRIVATE_HEALTH_RUNTIME_PAYLOAD", "utf8");
const notificationStatus: NotificationSupportPayload & Record<string, unknown> = {
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 45_000,
events: [{ payload: "PRIVATE_EVENT_PAYLOAD" }],
url: "https://private.example.test/webhook",
mention: "@private"
};
const buffer = await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none", notificationStatus });
const zip = new AdmZip(buffer);
const entry = zip.getEntry("overview/notifications.json");
const payload = JSON.parse(entry?.getData().toString("utf8") || "null");
const serialized = buffer.toString("utf8");
expect(payload).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 45_000
});
expect(zip.getEntries().map((item) => item.entryName)).not.toContain(path.basename(paths.notificationOutboxFile));
expect(zip.getEntries().map((item) => item.entryName)).not.toContain(path.basename(paths.notificationHealthFile));
expect(serialized).not.toMatch(/PRIVATE_|https:\/\/private|@private/);
});
});
+36 -1
View File
@@ -2,7 +2,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import AdmZip from "adm-zip";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { AppController } from "../src/main/app-controller";
import { defaultSettings } from "../src/main/constants";
import { buildAccountSummary, buildNotificationSupportPayload, buildStatsPayload } from "../src/main/support-data";
import { buildSupportBundle } from "../src/main/support-bundle";
@@ -11,6 +12,40 @@ import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accoun
import { createVisualFixture } from "./visual/fixtures";
describe("Real-Debrid support summary", () => {
it("projects the current private AppController notification state into the safe DTO", () => {
const controller = Object.create(AppController.prototype) as AppController;
const internals = controller as unknown as {
notificationOutbox: { getStatus: () => Record<string, unknown> };
downloadHealthMonitor: { getState: () => Record<string, unknown> };
};
internals.notificationOutbox = {
getStatus: () => ({
queued: 4,
lastSuccessAt: 1_700_000_000_000,
lastFailureAt: 1_700_000_010_000,
events: [{ payload: "PRIVATE_CONTROLLER_EVENT" }]
})
};
internals.downloadHealthMonitor = {
getState: () => ({
incidentType: "scheduler",
incidentStartedAt: 1_700_000_020_000,
runFingerprint: "PRIVATE_CONTROLLER_FINGERPRINT"
})
};
vi.spyOn(Date, "now").mockReturnValue(1_700_000_050_000);
const payload = controller.getNotificationSupportPayload();
expect(payload).toEqual({
queued: 4,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 30_000
});
expect(JSON.stringify(payload)).not.toMatch(/PRIVATE_|event|payload|fingerprint|lastFailure/i);
vi.restoreAllMocks();
});
it("projects only safe notification delivery and incident aggregates", () => {
const payload = buildNotificationSupportPayload(
{