fix(support): expose notification health aggregates
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user