Add support bundle and trace tooling

This commit is contained in:
Sucukdeluxe
2026-03-09 02:15:32 +01:00
parent db6e7d81a4
commit f5f7f14104
14 changed files with 819 additions and 13 deletions
+39 -1
View File
@@ -34,10 +34,13 @@ import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session
import { MegaWebFallback } from "./mega-web-fallback";
import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveHistory, saveSession, saveSettings } from "./storage";
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { startDebugServer, stopDebugServer } from "./debug-server";
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { SupportTraceConfig } from "../shared/types";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@@ -77,6 +80,7 @@ export class AppController {
initPackageLogs(this.storagePaths.baseDir);
initItemLogs(this.storagePaths.baseDir);
initAuditLog(this.storagePaths.baseDir);
initTraceLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths);
const session = loadSession(this.storagePaths);
this.megaWebFallback = new MegaWebFallback(() => ({
@@ -180,8 +184,29 @@ export class AppController {
return getAuditLogPath();
}
public getTraceLogPath(): string | null {
return getTraceLogPath();
}
public getTraceConfig(): SupportTraceConfig {
return getTraceConfig();
}
public rotateDebugToken(): { path: string; token: string } {
const rotated = rotateDebugToken(this.storagePaths.baseDir);
this.audit("WARN", "Debug-Token rotiert", { path: rotated.path });
return rotated;
}
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
logAuditEvent(level, message, fields);
logTraceEvent(level, "audit", message, fields);
}
public setTraceEnabled(enabled: boolean, note = ""): SupportTraceConfig {
const next = setTraceEnabled(enabled, note);
this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note });
return next;
}
public updateSettings(partial: Partial<AppSettings>): AppSettings {
@@ -473,6 +498,18 @@ export class AppController {
return encryptBackup(payload);
}
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
this.audit("INFO", "Support-Bundle exportiert");
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
});
return {
buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir),
defaultFileName: getSupportBundleDefaultFileName()
};
}
public importBackup(data: Buffer): { restored: boolean; message: string } {
let parsed: Record<string, unknown>;
try {
@@ -569,6 +606,7 @@ export class AppController {
shutdownPackageLogs();
shutdownItemLogs();
this.audit("INFO", "App beendet");
shutdownTraceLog();
shutdownAuditLog();
logger.info("App beendet");
}
+159 -6
View File
@@ -1,6 +1,7 @@
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log";
import { logger, getLogFilePath } from "./logger";
@@ -9,6 +10,8 @@ import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, updateTraceConfig } from "./trace-log";
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager";
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
@@ -32,9 +35,11 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/log", queryExample: "lines=100&grep=keyword", description: "Legacy alias for the main application log tail." },
{ method: "GET", path: "/logs/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." },
{ method: "GET", path: "/logs/audit", queryExample: "lines=100&grep=keyword", description: "Reads the audit log for support-relevant UI and admin actions." },
{ method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." },
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
{ method: "GET", path: "/trace/config", queryExample: "enable=1&note=support", description: "Reads or updates the support trace configuration." },
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
@@ -43,6 +48,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." },
{ method: "GET", path: "/items", queryExample: "status=downloading&package=Release", description: "Lists items and supports status/package filters." },
{ method: "GET", path: "/session", queryExample: "package=Release", description: "Returns session-wide or package-scoped item state." },
{ method: "GET", path: "/support/bundle", description: "Downloads a ZIP support bundle with logs, diagnostics, and redacted state." },
{ method: "GET", path: "/diagnostics", queryExample: "package=Release&lines=150", description: "Returns a combined support snapshot with logs, status, settings, accounts, stats, history, and host diagnostics." }
];
@@ -69,6 +75,10 @@ function getAiManifestPath(baseDir: string = runtimeBaseDir): string {
return path.join(baseDir, AI_MANIFEST_FILE);
}
function getDebugTokenPath(baseDir: string = runtimeBaseDir): string {
return path.join(baseDir, "debug_token.txt");
}
function loadToken(baseDir: string): string {
const tokenPath = path.join(baseDir, "debug_token.txt");
try {
@@ -132,6 +142,23 @@ function jsonResponse(res: http.ServerResponse, status: number, data: unknown):
res.end(body);
}
function binaryResponse(
res: http.ServerResponse,
status: number,
body: Buffer,
contentType: string,
fileName?: string
): void {
res.writeHead(status, {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {})
});
res.end(body);
}
function normalizeLinesParam(rawValue: string | null, fallback: number): number {
const parsed = Number(rawValue || String(fallback));
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -161,6 +188,31 @@ function filterLines(lines: string[], grep: string): string[] {
return lines.filter((line) => line.toLowerCase().includes(pattern));
}
function toBooleanQuery(value: string | null): boolean | null {
if (value === null) {
return null;
}
if (/^(1|true|yes|on)$/i.test(value)) {
return true;
}
if (/^(0|false|no|off)$/i.test(value)) {
return false;
}
return null;
}
function sanitizeRequestUrlForTrace(rawUrl: string): string {
try {
const url = new URL(rawUrl || "/", "http://localhost");
if (url.searchParams.has("token")) {
url.searchParams.set("token", "***");
}
return `${url.pathname}${url.search}`;
} catch {
return String(rawUrl || "/");
}
}
function formatEndpointSummary(endpoint: DebugEndpointDescriptor): string {
return `${endpoint.method} ${endpoint.path}${endpoint.queryExample ? `?${endpoint.queryExample}` : ""}`;
}
@@ -184,7 +236,8 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
"Read debug_token.txt and debug_port.txt from this runtime folder.",
"If remote access is needed, ask the user only for the server IP or DNS name.",
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /status, /packages, /items, /settings, /accounts, /stats, or /history."
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.",
"If a full handoff is needed, download /support/bundle as a ZIP."
],
auth: {
required: true,
@@ -200,6 +253,8 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
tokenFile: path.join(baseDir, "debug_token.txt"),
mainLogFile: getLogFilePath(),
auditLogFile: getAuditLogPath(),
traceLogFile: getTraceLogPath(),
traceConfigFile: getTraceConfigPath(),
sessionLogFile: getSessionLogPath(),
packageLogDir: path.join(baseDir, "package-logs"),
itemLogDir: path.join(baseDir, "item-logs"),
@@ -233,6 +288,19 @@ function writeAiManifest(baseDir: string): void {
}
}
export function rotateDebugToken(baseDir: string = runtimeBaseDir): { path: string; token: string } {
const token = crypto.randomBytes(24).toString("hex");
const tokenPath = getDebugTokenPath(baseDir);
fs.writeFileSync(tokenPath, `${token}\n`, "utf8");
if (baseDir === runtimeBaseDir) {
authToken = token;
writeAiManifest(baseDir);
}
logger.info(`Debug-Server Token rotiert: ${tokenPath}`);
logTraceEvent("INFO", "support", "Debug-Token rotiert", { tokenPath });
return { path: tokenPath, token };
}
function summarizeItem(item: DownloadItem): Record<string, unknown> {
return {
id: item.id,
@@ -348,6 +416,16 @@ function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> {
}
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
const url = new URL(req.url || "/", "http://localhost");
const pathname = url.pathname;
const traceConfig = getTraceConfig();
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("INFO", "debug-http", "Request", {
method: req.method || "GET",
url: sanitizeRequestUrlForTrace(req.url || "/")
});
}
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
@@ -359,13 +437,16 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
}
if (!checkAuth(req)) {
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
method: req.method || "GET",
url: sanitizeRequestUrlForTrace(req.url || "/")
});
}
jsonResponse(res, 401, { error: "Unauthorized" });
return;
}
const url = new URL(req.url || "/", "http://localhost");
const pathname = url.pathname;
if (pathname === "/health") {
jsonResponse(res, 200, {
status: "ok",
@@ -385,11 +466,15 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
port: bindPort
},
supportFiles: {
aiManifest: getAiManifestPath()
aiManifest: getAiManifestPath(),
traceConfig: getTraceConfigPath(),
traceLog: getTraceLogPath()
},
logPaths: {
main: getLogFilePath(),
session: getSessionLogPath()
audit: getAuditLogPath(),
session: getSessionLogPath(),
trace: getTraceLogPath()
},
endpoints: getEndpointSummaries()
});
@@ -422,6 +507,21 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
if (pathname === "/logs/trace") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const logPath = getTraceLogPath();
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
jsonResponse(res, 200, {
path: logPath,
configPath: getTraceConfigPath(),
config: getTraceConfig(),
lines,
count: lines.length
});
return;
}
if (pathname === "/logs/session") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
@@ -435,6 +535,39 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
if (pathname === "/trace/config") {
const patch: Record<string, unknown> = {};
const enabled = toBooleanQuery(url.searchParams.get("enable"));
const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog"));
const includeAudit = toBooleanQuery(url.searchParams.get("includeAudit"));
const logDebugRequests = toBooleanQuery(url.searchParams.get("logDebugRequests"));
if (enabled !== null) {
patch.enabled = enabled;
}
if (includeMainLog !== null) {
patch.includeMainLog = includeMainLog;
}
if (includeAudit !== null) {
patch.includeAudit = includeAudit;
}
if (logDebugRequests !== null) {
patch.logDebugRequests = logDebugRequests;
}
const note = String(url.searchParams.get("note") || "").trim();
const config = Object.keys(patch).length > 0
? updateTraceConfig({ ...patch, ...(note ? { updatedAt: new Date().toISOString() } : {}) })
: getTraceConfig();
if (Object.keys(patch).length > 0) {
logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note });
}
jsonResponse(res, 200, {
path: getTraceConfigPath(),
logPath: getTraceLogPath(),
config
});
return;
}
if (pathname === "/logs/package") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
@@ -626,6 +759,21 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
if (pathname === "/support/bundle") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const fileName = getSupportBundleDefaultFileName();
const body = buildSupportBundle(manager, runtimeBaseDir);
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
fileName,
sizeBytes: body.length
});
binaryResponse(res, 200, body, "application/zip", fileName);
return;
}
if (pathname === "/diagnostics") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
@@ -673,6 +821,11 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
path: getAuditLogPath(),
lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep)
},
trace: {
path: getTraceLogPath(),
config: getTraceConfig(),
lines: filterLines(readLogTailFromFile(getTraceLogPath(), lineCount), grep)
},
session: {
path: sessionLogPath,
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
+22 -4
View File
@@ -9,7 +9,8 @@ const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
const rotateCheckAtByFile = new Map<string, number>();
type LogListener = (line: string) => void;
let logListener: LogListener | null = null;
const logListeners = new Set<LogListener>();
let legacyLogListener: LogListener | null = null;
let pendingLines: string[] = [];
let pendingChars = 0;
@@ -18,7 +19,24 @@ let flushInFlight = false;
let exitHookAttached = false;
export function setLogListener(listener: LogListener | null): void {
logListener = listener;
if (legacyLogListener) {
logListeners.delete(legacyLogListener);
}
legacyLogListener = listener;
if (listener) {
logListeners.add(listener);
}
}
export function addLogListener(listener: LogListener): void {
logListeners.add(listener);
}
export function removeLogListener(listener: LogListener): void {
logListeners.delete(listener);
if (legacyLogListener === listener) {
legacyLogListener = null;
}
}
export function configureLogger(baseDir: string): void {
@@ -195,8 +213,8 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
pendingLines.push(line);
pendingChars += line.length;
if (logListener) {
try { logListener(line); } catch { /* ignore */ }
for (const listener of logListeners) {
try { listener(line); } catch { /* ignore */ }
}
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
+45
View File
@@ -490,11 +490,32 @@ function registerIpcHandlers(): void {
return { saved: true };
});
ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
const exported = controller.exportSupportBundle();
const options = {
defaultPath: exported.defaultFileName,
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
};
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) {
return { saved: false };
}
await fs.promises.writeFile(result.filePath, exported.buffer);
return { saved: true, filePath: result.filePath };
});
ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => {
const logPath = getLogFilePath();
await shell.openPath(logPath);
});
ipcMain.handle(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => {
const logPath = controller.getAuditLogPath();
if (logPath) {
await shell.openPath(logPath);
}
});
ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => {
const logPath = controller.getSessionLogPath();
if (logPath) {
@@ -502,6 +523,13 @@ function registerIpcHandlers(): void {
}
});
ipcMain.handle(IPC_CHANNELS.OPEN_TRACE_LOG, async () => {
const logPath = controller.getTraceLogPath();
if (logPath) {
await shell.openPath(logPath);
}
});
ipcMain.handle(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => {
validateString(packageId, "packageId");
const logPath = controller.getPackageLogPath(packageId);
@@ -510,6 +538,23 @@ function registerIpcHandlers(): void {
}
});
ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig());
ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string) => {
if (typeof enabled !== "boolean") {
throw new Error("enabled muss ein Boolean sein");
}
if (note !== undefined) {
validateString(note, "note");
}
return controller.setTraceEnabled(enabled, note);
});
ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => {
const rotated = controller.rotateDebugToken();
return { path: rotated.path };
});
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
validateString(itemId, "itemId");
const logPath = controller.getItemLogPath(itemId);
+138
View File
@@ -0,0 +1,138 @@
import fs from "node:fs";
import path from "node:path";
import AdmZip from "adm-zip";
import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log";
import { getLogFilePath } from "./logger";
import { getPackageLogPath } from "./package-log";
import { getSessionLogPath } from "./session-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager";
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
function safeReadJson(filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
} catch {
return null;
}
}
function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
}
function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): void {
if (!sourcePath || !fs.existsSync(sourcePath)) {
return;
}
zip.addLocalFile(sourcePath, path.posix.dirname(zipPath), path.posix.basename(zipPath));
}
function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): void {
if (!fs.existsSync(dirPath)) {
return;
}
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const zipPath = path.posix.join(zipRoot, entry.name);
if (entry.isDirectory()) {
addDirectoryIfExists(zip, fullPath, zipPath);
continue;
}
zip.addLocalFile(fullPath, path.posix.dirname(zipPath), path.posix.basename(zipPath));
}
}
function formatTimestampForFileName(date: Date): string {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
const s = String(date.getSeconds()).padStart(2, "0");
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
}
export function getSupportBundleDefaultFileName(): string {
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
}
export function buildSupportBundle(manager: DownloadManager, baseDir: string): Buffer {
const zip = new AdmZip();
const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths);
const history = loadHistory(storagePaths);
const snapshot = manager.getSnapshot();
const packageIds = Object.keys(snapshot.session.packages);
const itemIds = Object.keys(snapshot.session.items);
addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION,
generatedAt: new Date().toISOString(),
runtimeBaseDir: baseDir,
packageCount: packageIds.length,
itemCount: itemIds.length
});
addJson(zip, "overview/status.json", snapshot.session);
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
addJson(zip, "overview/stats.json", {
...buildStatsPayload(snapshot),
allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime
}
});
addJson(zip, "overview/history.json", {
total: history.length,
entries: history.map((entry) => summarizeHistoryEntry(entry))
});
addJson(zip, "overview/packages.json", {
count: packageIds.length,
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean)
});
addJson(zip, "overview/items.json", {
count: itemIds.length,
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
});
addJson(zip, "overview/host-diagnostics.json", getWindowsHostDiagnostics());
addJson(zip, "overview/trace-config.json", getTraceConfig());
addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
addFileIfExists(zip, storagePaths.configFile, "runtime/rd_downloader_config.json");
addFileIfExists(zip, storagePaths.sessionFile, "runtime/rd_session_state.json");
addFileIfExists(zip, storagePaths.historyFile, "runtime/rd_history.json");
addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
addDirectoryIfExists(zip, path.join(baseDir, "package-logs"), "logs/package-logs");
addDirectoryIfExists(zip, path.join(baseDir, "item-logs"), "logs/item-logs");
for (const packageId of packageIds) {
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
}
for (const itemId of itemIds) {
addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
}
const aiManifest = safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
if (aiManifest) {
addJson(zip, "overview/ai-manifest.json", aiManifest);
}
return zip.toBuffer();
}
+217
View File
@@ -0,0 +1,217 @@
import fs from "node:fs";
import path from "node:path";
import { addLogListener, removeLogListener } from "./logger";
import type { SupportTraceConfig } from "../shared/types";
type TraceLevel = "INFO" | "WARN" | "ERROR";
const TRACE_LOG_FLUSH_INTERVAL_MS = 200;
const TRACE_CONFIG_FILE = "trace_config.json";
const DEFAULT_TRACE_CONFIG: SupportTraceConfig = {
enabled: false,
includeMainLog: true,
includeAudit: true,
logDebugRequests: true,
updatedAt: new Date(0).toISOString()
};
let traceLogPath: string | null = null;
let traceConfigPath: string | null = null;
let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG };
let pendingLines: string[] = [];
let flushTimer: NodeJS.Timeout | null = null;
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function flushPending(): void {
if (!traceLogPath || pendingLines.length === 0) {
return;
}
const chunk = pendingLines.join("");
pendingLines = [];
try {
fs.appendFileSync(traceLogPath, chunk, "utf8");
} catch {
// ignore
}
}
function scheduleFlush(): void {
if (flushTimer) {
return;
}
flushTimer = setTimeout(() => {
flushTimer = null;
flushPending();
}, TRACE_LOG_FLUSH_INTERVAL_MS);
}
function appendTraceLine(line: string): void {
if (!traceLogPath) {
return;
}
pendingLines.push(line);
scheduleFlush();
}
function normalizeTraceConfig(raw: unknown): SupportTraceConfig {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { ...DEFAULT_TRACE_CONFIG };
}
const value = raw as Partial<SupportTraceConfig>;
return {
enabled: Boolean(value.enabled),
includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog),
includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit),
logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests),
updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim()
? value.updatedAt
: DEFAULT_TRACE_CONFIG.updatedAt
};
}
function loadTraceConfig(): SupportTraceConfig {
if (!traceConfigPath) {
return { ...DEFAULT_TRACE_CONFIG };
}
try {
const parsed = JSON.parse(fs.readFileSync(traceConfigPath, "utf8")) as unknown;
return normalizeTraceConfig(parsed);
} catch {
return { ...DEFAULT_TRACE_CONFIG };
}
}
function persistTraceConfig(): void {
if (!traceConfigPath) {
return;
}
try {
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
} catch {
// ignore
}
}
const mainLogListener = (line: string): void => {
if (!traceConfig.enabled || !traceConfig.includeMainLog) {
return;
}
appendTraceLine(line);
};
export function initTraceLog(baseDir: string): void {
traceLogPath = path.join(baseDir, "trace.log");
traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE);
try {
fs.mkdirSync(baseDir, { recursive: true });
if (!fs.existsSync(traceLogPath)) {
fs.writeFileSync(traceLogPath, "", "utf8");
}
traceConfig = loadTraceConfig();
persistTraceConfig();
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
traceLogPath = null;
traceConfigPath = null;
traceConfig = { ...DEFAULT_TRACE_CONFIG };
return;
}
addLogListener(mainLogListener);
}
export function getTraceLogPath(): string | null {
if (!traceLogPath) {
return null;
}
return fs.existsSync(traceLogPath) ? traceLogPath : null;
}
export function getTraceConfigPath(): string | null {
if (!traceConfigPath) {
return null;
}
return fs.existsSync(traceConfigPath) ? traceConfigPath : null;
}
export function getTraceConfig(): SupportTraceConfig {
return { ...traceConfig };
}
export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTraceConfig {
traceConfig = normalizeTraceConfig({
...traceConfig,
...patch,
updatedAt: new Date().toISOString()
});
persistTraceConfig();
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig)}\n`);
return getTraceConfig();
}
export function setTraceEnabled(enabled: boolean, note = ""): SupportTraceConfig {
const next = updateTraceConfig({ enabled });
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note })}\n`);
return next;
}
export function logTraceEvent(
level: TraceLevel,
category: string,
message: string,
fields?: Record<string, unknown>
): void {
if (!traceConfig.enabled) {
return;
}
if (category === "audit" && !traceConfig.includeAudit) {
return;
}
appendTraceLine(`${new Date().toISOString()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
}
export function shutdownTraceLog(): void {
removeLogListener(mainLogListener);
if (!traceLogPath) {
return;
}
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushPending();
try {
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
// ignore
}
traceLogPath = null;
traceConfigPath = null;
traceConfig = { ...DEFAULT_TRACE_CONFIG };
}