From 7027e11cbd35f6f9a578a1dab467bb927af4465f Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 9 Mar 2026 03:00:36 +0100 Subject: [PATCH] Add support self-check diagnostics --- README.md | 6 +- src/main/debug-server.ts | 9 +- src/main/debug-setup.ts | 307 ++++++++++++++++++++++++++++++++++++- src/main/support-bundle.ts | 4 +- src/renderer/App.tsx | 29 ++++ src/shared/types.ts | 48 ++++++ tests/debug-server.test.ts | 21 +++ 7 files changed, 414 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index eb0741a..d97e3c7 100644 --- a/README.md +++ b/README.md @@ -208,13 +208,14 @@ After startup, the app also writes `debug_ai_manifest.json` into the same runtim If you want extra support detail during a flaky or hard-to-reproduce issue, the app also maintains a `trace.log` plus `trace_config.json`. You can enable or disable the support trace from the app menu or remotely via the debug API. By default, the support trace now auto-disables again after 2 hours so it does not stay enabled forever by accident. -The app menu under `Hilfe` also includes a `Debug-Setup prüfen` action. It verifies the current host/port/token/AI-manifest/trace setup locally and shows the exact local and remote URLs that support tooling can use. +The app menu under `Hilfe` also includes a `Debug-Setup prüfen` action. It verifies the current host/port/token/AI-manifest/trace setup locally and now also reports free disk space, current support-log sizes, and an estimated support-bundle size. Available endpoints after restart: - `GET /health` - `GET /meta` - `GET /debug/setup` +- `GET /self-check` - `GET /host/diagnostics` - `GET /status` - `GET /settings` @@ -249,6 +250,7 @@ Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN" Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN" Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20" Invoke-RestMethod "http://SERVER:9868/debug/setup?token=YOUR_TOKEN" +Invoke-RestMethod "http://SERVER:9868/self-check?token=YOUR_TOKEN" Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200" Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200" Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1¬e=support&durationMinutes=120" @@ -258,7 +260,7 @@ Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN" Invoke-WebRequest "http://SERVER:9868/support/bundle?token=YOUR_TOKEN" -OutFile ".\\rd-support-bundle.zip" ``` -This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, trace data, package/session/item logs, host-side Windows crash hints, and even a full ZIP support bundle can be inspected remotely. +This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, trace data, package/session/item logs, host-side Windows crash hints, disk space, support-log volume, support-bundle size estimates, and even a full ZIP support bundle can be inspected remotely. ## Troubleshooting diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index c6ccad7..3785bc8 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -33,6 +33,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ { method: "GET", path: "/health", description: "Basic health, uptime, and memory information." }, { method: "GET", path: "/meta", description: "Lists runtime metadata and all available endpoints." }, { method: "GET", path: "/debug/setup", description: "Checks whether the local debug setup is configured for support." }, + { method: "GET", path: "/self-check", description: "Extended support self-check with disk space, log sizes, and support bundle estimate." }, { method: "GET", path: "/host/diagnostics", description: "Returns Windows host crash and dump diagnostics." }, { 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." }, @@ -238,7 +239,7 @@ function buildAiManifest(baseDir: string): Record { "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 /debug/setup to quickly verify whether token, host, manifest, and trace files are in a good support state.", + "Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.", "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." ], @@ -274,6 +275,7 @@ function buildAiManifest(baseDir: string): Record { remoteHostHint }, setupCheckEndpoint: "/debug/setup", + selfCheckEndpoint: "/self-check", askUserFor: [ "Server IP or DNS name, if remote access is required and not already known." ], @@ -475,7 +477,8 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi traceLog: getTraceLogPath() }, supportChecks: { - setup: "/debug/setup" + setup: "/debug/setup", + selfCheck: "/self-check" }, logPaths: { main: getLogFilePath(), @@ -488,7 +491,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } - if (pathname === "/debug/setup") { + if (pathname === "/debug/setup" || pathname === "/self-check") { jsonResponse(res, 200, getDebugSetupCheck(runtimeBaseDir)); return; } diff --git a/src/main/debug-setup.ts b/src/main/debug-setup.ts index e431ca3..ca16990 100644 --- a/src/main/debug-setup.ts +++ b/src/main/debug-setup.ts @@ -1,10 +1,42 @@ import fs from "node:fs"; import path from "node:path"; -import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; +import { execFileSync } from "node:child_process"; +import { getSessionLogPath } from "./session-log"; +import { createStoragePaths, loadSettings } from "./storage"; +import type { + DebugSetupCheckResult, + SupportBundleEstimate, + SupportDirectorySizeInfo, + SupportDiskSpaceInfo, + SupportFileSizeInfo, + SupportTraceConfig +} from "../shared/types"; const DEFAULT_PORT = 9868; const DEFAULT_HOST = "127.0.0.1"; const AI_MANIFEST_FILE = "debug_ai_manifest.json"; +const LOW_FREE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_BYTES || 20 * 1024 * 1024 * 1024); +const LOW_FREE_PERCENT_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT || 5); +const LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024); +const LARGE_LOG_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_LOG_BYTES || 250 * 1024 * 1024); +const LARGE_BUNDLE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_BUNDLE_BYTES || 150 * 1024 * 1024); +const BUNDLE_OVERVIEW_SLACK_BYTES = 256 * 1024; + +function formatByteCount(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) { + return "0 B"; + } + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} function readToken(baseDir: string): string { try { @@ -69,26 +101,257 @@ function readTraceConfig(baseDir: string): SupportTraceConfig { } } +function getFileSizeInfo(filePath: string | null): SupportFileSizeInfo { + if (!filePath) { + return { path: null, exists: false, bytes: 0 }; + } + try { + const stat = fs.statSync(filePath); + return { + path: filePath, + exists: true, + bytes: stat.size + }; + } catch { + return { + path: filePath, + exists: false, + bytes: 0 + }; + } +} + +function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): SupportDirectorySizeInfo { + if (!fs.existsSync(dirPath)) { + return { + path: dirPath, + exists: false, + fileCount: 0, + bytes: 0 + }; + } + + let bytes = 0; + let fileCount = 0; + const queue = [dirPath]; + while (queue.length > 0) { + const current = queue.pop(); + if (!current) { + continue; + } + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + queue.push(fullPath); + continue; + } + if (skipPath && path.resolve(fullPath) === path.resolve(skipPath)) { + continue; + } + try { + bytes += fs.statSync(fullPath).size; + fileCount += 1; + } catch { + // ignore unreadable files + } + } + } + + return { + path: dirPath, + exists: true, + fileCount, + bytes + }; +} + +function resolveExistingPath(targetPath: string): string { + let current = path.resolve(targetPath); + while (!fs.existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return current; +} + +function getWindowsDiskSpaceInfo(existingPath: string): SupportDiskSpaceInfo | null { + if (process.platform !== "win32") { + return null; + } + const root = path.parse(existingPath).root.replace(/[\\/]+$/g, ""); + const driveName = root.replace(":", ""); + if (!/^[A-Za-z]$/.test(driveName)) { + return null; + } + try { + const raw = execFileSync( + "powershell", + [ + "-NoProfile", + "-Command", + `$drive = Get-PSDrive -Name '${driveName}'; if ($drive) { [pscustomobject]@{ FreeSpace = [int64]$drive.Free; Size = [int64]($drive.Used + $drive.Free) } | ConvertTo-Json -Compress }` + ], + { + encoding: "utf8", + windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000 + } + ).trim(); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as { FreeSpace?: number | string; Size?: number | string }; + const totalBytes = Number(parsed.Size); + const freeBytes = Number(parsed.FreeSpace); + const freePercent = Number.isFinite(totalBytes) && totalBytes > 0 + ? Math.round((freeBytes / totalBytes) * 1000) / 10 + : null; + return { + path: existingPath, + totalBytes: Number.isFinite(totalBytes) ? totalBytes : null, + freeBytes: Number.isFinite(freeBytes) ? freeBytes : null, + freePercent + }; + } catch { + return null; + } +} + +function getDiskSpaceInfo(targetPath: string): SupportDiskSpaceInfo { + const existingPath = resolveExistingPath(targetPath); + try { + const stat = fs.statfsSync(existingPath); + const totalBytes = Number(stat.blocks) * Number(stat.bsize); + const freeBytes = Number(stat.bavail) * Number(stat.bsize); + const freePercent = totalBytes > 0 + ? Math.round((freeBytes / totalBytes) * 1000) / 10 + : null; + return { + path: existingPath, + totalBytes, + freeBytes, + freePercent + }; + } catch { + const windowsFallback = getWindowsDiskSpaceInfo(existingPath); + if (windowsFallback) { + return windowsFallback; + } + return { + path: existingPath, + totalBytes: null, + freeBytes: null, + freePercent: null + }; + } +} + +function getSupportBundleEstimate( + baseDir: string, + logSummary: DebugSetupCheckResult["logSummary"] +): SupportBundleEstimate { + const storagePaths = createStoragePaths(baseDir); + const staticFiles = [ + path.join(baseDir, AI_MANIFEST_FILE), + path.join(baseDir, "debug_host.txt"), + path.join(baseDir, "debug_port.txt"), + storagePaths.configFile, + storagePaths.sessionFile, + storagePaths.historyFile, + path.join(baseDir, "trace_config.json") + ].map((filePath) => getFileSizeInfo(filePath)); + + const staticBytes = staticFiles.reduce((sum, entry) => sum + entry.bytes, 0); + const duplicatedLiveLogBytes = logSummary.session.bytes + logSummary.packageLogs.bytes + logSummary.itemLogs.bytes; + const estimatedEntries = 10 + + staticFiles.filter((entry) => entry.exists).length + + Number(logSummary.main.exists) + + Number(logSummary.mainBackup.exists) + + Number(logSummary.audit.exists) + + Number(logSummary.auditBackup.exists) + + Number(logSummary.session.exists) + + Number(logSummary.trace.exists) + + Number(logSummary.traceBackup.exists) + + logSummary.sessionLogs.fileCount + + logSummary.packageLogs.fileCount + + logSummary.itemLogs.fileCount + + logSummary.packageLogs.fileCount + + logSummary.itemLogs.fileCount; + + return { + estimatedBytes: staticBytes + logSummary.totalBytes + duplicatedLiveLogBytes + BUNDLE_OVERVIEW_SLACK_BYTES, + estimatedEntries, + duplicatedLiveLogBytes, + note: "Schätzwert vor ZIP-Komprimierung; aktueller Session-Log sowie Live-Paket-/Item-Logs werden im Bundle zusätzlich gespiegelt." + }; +} + export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult { const host = readHost(baseDir); const port = readPort(baseDir); const token = readToken(baseDir); + const storagePaths = createStoragePaths(baseDir); + const settings = loadSettings(storagePaths); const tokenPath = path.join(baseDir, "debug_token.txt"); const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE); const traceConfigPath = path.join(baseDir, "trace_config.json"); const traceLogPath = path.join(baseDir, "trace.log"); const traceConfig = readTraceConfig(baseDir); + const sessionLogPath = getSessionLogPath(); const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host); const warnings: string[] = []; const notes: string[] = []; + const logSummary: DebugSetupCheckResult["logSummary"] = { + main: getFileSizeInfo(path.join(baseDir, "rd_downloader.log")), + mainBackup: getFileSizeInfo(path.join(baseDir, "rd_downloader.log.old")), + audit: getFileSizeInfo(path.join(baseDir, "audit.log")), + auditBackup: getFileSizeInfo(path.join(baseDir, "audit.log.old")), + session: getFileSizeInfo(sessionLogPath), + trace: getFileSizeInfo(traceLogPath), + traceBackup: getFileSizeInfo(path.join(baseDir, "trace.log.old")), + sessionLogs: getDirectorySizeInfo(path.join(baseDir, "session-logs"), sessionLogPath), + packageLogs: getDirectorySizeInfo(path.join(baseDir, "package-logs")), + itemLogs: getDirectorySizeInfo(path.join(baseDir, "item-logs")), + totalBytes: 0 + }; + logSummary.totalBytes = [ + logSummary.main.bytes, + logSummary.mainBackup.bytes, + logSummary.audit.bytes, + logSummary.auditBackup.bytes, + logSummary.session.bytes, + logSummary.trace.bytes, + logSummary.traceBackup.bytes, + logSummary.sessionLogs.bytes, + logSummary.packageLogs.bytes, + logSummary.itemLogs.bytes + ].reduce((sum, value) => sum + value, 0); + + const diskSpace: DebugSetupCheckResult["diskSpace"] = { + runtime: getDiskSpaceInfo(baseDir), + output: getDiskSpaceInfo(settings.outputDir), + extract: getDiskSpaceInfo(settings.extractDir) + }; + const supportBundle = getSupportBundleEstimate(baseDir, logSummary); + if (!token) { warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht."); } if (localOnly) { - warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen."); + warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen."); } else { - notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein."); + notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein."); } if (!fs.existsSync(aiManifestPath)) { warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei neu geschrieben wird."); @@ -102,10 +365,43 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult { if (traceConfig.enabled && traceConfig.autoDisableAt) { notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`); } - notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen."); + + for (const entry of [ + { label: "Runtime", info: diskSpace.runtime }, + { label: "Download-Ziel", info: diskSpace.output }, + { label: "Entpack-Ziel", info: diskSpace.extract } + ]) { + if (entry.info.freeBytes === null || entry.info.totalBytes === null) { + warnings.push(`${entry.label}: Freier Speicherplatz konnte nicht gelesen werden (${entry.info.path}).`); + continue; + } + const lowByAbsolute = entry.info.freeBytes < LOW_FREE_BYTES_THRESHOLD; + const lowByPercent = entry.info.freePercent !== null + && entry.info.freePercent < LOW_FREE_PERCENT_THRESHOLD + && entry.info.freeBytes < LOW_FREE_PERCENT_BYTES_GUARD; + if (lowByAbsolute || lowByPercent) { + warnings.push(`${entry.label}: wenig freier Speicherplatz (${formatByteCount(entry.info.freeBytes)} frei auf ${entry.info.path}).`); + } + } + + if (logSummary.totalBytes >= LARGE_LOG_BYTES_THRESHOLD) { + warnings.push(`Support-Logs sind bereits recht groß (${formatByteCount(logSummary.totalBytes)}). Rotation greift, aber ein Bundle wird entsprechend umfangreicher.`); + } else { + notes.push(`Aktuelle Support-Logmenge: ${formatByteCount(logSummary.totalBytes)}.`); + } + + if (supportBundle.estimatedBytes >= LARGE_BUNDLE_BYTES_THRESHOLD) { + warnings.push(`Support-Bundle wird voraussichtlich groß (${formatByteCount(supportBundle.estimatedBytes)} vor ZIP-Komprimierung).`); + } else { + notes.push(`Support-Bundle-Schätzung: etwa ${formatByteCount(supportBundle.estimatedBytes)}.`); + } + + notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen."); return { + status: warnings.length > 0 ? "warn" : "ok", enabled: Boolean(token), + runtimeBaseDir: baseDir, host, port, localOnly, @@ -117,6 +413,9 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult { traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null, traceEnabled: traceConfig.enabled, traceAutoDisableAt: traceConfig.autoDisableAt, + diskSpace, + logSummary, + supportBundle, warnings, notes, localUrls: { diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 504432a..ab3f4d6 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -72,6 +72,7 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B const snapshot = manager.getSnapshot(); const packageIds = Object.keys(snapshot.session.packages); const itemIds = Object.keys(snapshot.session.items); + const debugSetup = getDebugSetupCheck(baseDir); addJson(zip, "overview/meta.json", { appVersion: APP_VERSION, @@ -90,7 +91,8 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime } }); - addJson(zip, "overview/debug-setup.json", getDebugSetupCheck(baseDir)); + addJson(zip, "overview/debug-setup.json", debugSetup); + addJson(zip, "overview/self-check.json", debugSetup); addJson(zip, "overview/history.json", { total: history.length, entries: history.map((entry) => summarizeHistoryEntry(entry)) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 45617a2..b99d5d6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -141,8 +141,18 @@ interface ConfiguredAccountEntry { } function buildDebugSetupDetails(setup: DebugSetupCheckResult): string { + const formatDiskLine = (label: string, value: DebugSetupCheckResult["diskSpace"]["runtime"]): string => { + if (value.freeBytes === null || value.totalBytes === null) { + return `${label}: unbekannt (${value.path})`; + } + return `${label}: ${humanSize(value.freeBytes)} frei von ${humanSize(value.totalBytes)} (${value.freePercent ?? "?"}% frei) | ${value.path}`; + }; + + const formatFileLine = (label: string, bytes: number): string => `${label}: ${humanSize(bytes)}`; const lines: string[] = [ + `Status: ${setup.status === "ok" ? "OK" : "Warnung"}`, `Debug-Server aktiv: ${setup.enabled ? "ja" : "nein"}`, + `Runtime-Ordner: ${setup.runtimeBaseDir}`, `Host: ${setup.host}`, `Port: ${setup.port}`, `Token-Datei: ${setup.tokenPath}`, @@ -150,6 +160,25 @@ function buildDebugSetupDetails(setup: DebugSetupCheckResult): string { `Trace aktiv: ${setup.traceEnabled ? "ja" : "nein"}`, `Trace-Auto-Ende: ${setup.traceAutoDisableAt || "nicht gesetzt"}`, "", + "Freier Speicherplatz:", + formatDiskLine("Runtime", setup.diskSpace.runtime), + formatDiskLine("Download-Ziel", setup.diskSpace.output), + formatDiskLine("Entpack-Ziel", setup.diskSpace.extract), + "", + "Support-Logs:", + formatFileLine("Gesamt", setup.logSummary.totalBytes), + formatFileLine("Hauptlog", setup.logSummary.main.bytes + setup.logSummary.mainBackup.bytes), + formatFileLine("Audit", setup.logSummary.audit.bytes + setup.logSummary.auditBackup.bytes), + formatFileLine("Trace", setup.logSummary.trace.bytes + setup.logSummary.traceBackup.bytes), + `${formatFileLine("Session-Logs", setup.logSummary.session.bytes + setup.logSummary.sessionLogs.bytes)} | Dateien: ${setup.logSummary.sessionLogs.fileCount}`, + `${formatFileLine("Paket-Logs", setup.logSummary.packageLogs.bytes)} | Dateien: ${setup.logSummary.packageLogs.fileCount}`, + `${formatFileLine("Item-Logs", setup.logSummary.itemLogs.bytes)} | Dateien: ${setup.logSummary.itemLogs.fileCount}`, + "", + "Support-Bundle:", + `${formatFileLine("Schätzwert", setup.supportBundle.estimatedBytes)} | Einträge: ${setup.supportBundle.estimatedEntries}`, + formatFileLine("Doppelte Live-Log-Spiegelung", setup.supportBundle.duplicatedLiveLogBytes), + setup.supportBundle.note, + "", "Lokale URLs:", setup.localUrls.health, setup.localUrls.meta, diff --git a/src/shared/types.ts b/src/shared/types.ts index d641a9c..27017d6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -346,8 +346,37 @@ export interface SupportTraceConfig { updatedAt: string; } +export interface SupportFileSizeInfo { + path: string | null; + exists: boolean; + bytes: number; +} + +export interface SupportDirectorySizeInfo { + path: string; + exists: boolean; + fileCount: number; + bytes: number; +} + +export interface SupportDiskSpaceInfo { + path: string; + totalBytes: number | null; + freeBytes: number | null; + freePercent: number | null; +} + +export interface SupportBundleEstimate { + estimatedBytes: number; + estimatedEntries: number; + duplicatedLiveLogBytes: number; + note: string; +} + export interface DebugSetupCheckResult { + status: "ok" | "warn"; enabled: boolean; + runtimeBaseDir: string; host: string; port: number; localOnly: boolean; @@ -359,6 +388,25 @@ export interface DebugSetupCheckResult { traceLogPath: string | null; traceEnabled: boolean; traceAutoDisableAt: string | null; + diskSpace: { + runtime: SupportDiskSpaceInfo; + output: SupportDiskSpaceInfo; + extract: SupportDiskSpaceInfo; + }; + logSummary: { + totalBytes: number; + main: SupportFileSizeInfo; + mainBackup: SupportFileSizeInfo; + audit: SupportFileSizeInfo; + auditBackup: SupportFileSizeInfo; + session: SupportFileSizeInfo; + trace: SupportFileSizeInfo; + traceBackup: SupportFileSizeInfo; + sessionLogs: SupportDirectorySizeInfo; + packageLogs: SupportDirectorySizeInfo; + itemLogs: SupportDirectorySizeInfo; + }; + supportBundle: SupportBundleEstimate; warnings: string[]; notes: string[]; localUrls: { diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index 4c465ce..2029a59 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -342,6 +342,7 @@ describe("debug-server", () => { expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain(""); expect(manifest.quickstart?.[1]).toContain("server IP"); expect(manifest.setupCheckEndpoint).toBe("/debug/setup"); + expect(manifest.selfCheckEndpoint).toBe("/self-check"); expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt"); expect(manifest.endpoints?.some((entry: Record) => entry.path === "/diagnostics")).toBe(true); expect(JSON.stringify(manifest)).not.toContain(fixture.token); @@ -353,6 +354,7 @@ describe("debug-server", () => { expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath()); expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath()); expect(metaPayload.supportChecks?.setup).toBe("/debug/setup"); + expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check"); }); it("serves a debug setup check with trace expiry details", async () => { @@ -362,16 +364,34 @@ describe("debug-server", () => { const payload = await response.json() as Record; expect(payload.enabled).toBe(true); + expect(payload.status).toBe("ok"); + expect(payload.runtimeBaseDir).toBe(fixture.baseDir); expect(payload.host).toBe("0.0.0.0"); expect(payload.localOnly).toBe(false); expect(payload.tokenConfigured).toBe(true); expect(payload.aiManifestPresent).toBe(true); expect(payload.traceEnabled).toBe(true); expect(payload.traceAutoDisableAt).toBeTruthy(); + expect(payload.diskSpace?.runtime?.freeBytes).toBeGreaterThan(0); + expect(payload.diskSpace?.output?.freeBytes).toBeGreaterThan(0); + expect(payload.diskSpace?.extract?.freeBytes).toBeGreaterThan(0); + expect(payload.logSummary?.totalBytes).toBeGreaterThan(0); + expect(payload.logSummary?.packageLogs?.fileCount).toBe(1); + expect(payload.logSummary?.itemLogs?.fileCount).toBe(1); + expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0); expect(payload.remoteUrlTemplates?.health).toContain(""); expect(Array.isArray(payload.notes)).toBe(true); }); + it("serves the self-check alias", async () => { + const fixture = await createFixture(); + const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`); + expect(response.ok).toBe(true); + const payload = await response.json() as Record; + expect(payload.status).toBe("ok"); + expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0); + }); + it("serves package details and package log by package query", async () => { const fixture = await createFixture(); @@ -469,6 +489,7 @@ describe("debug-server", () => { expect(entries).toContain("overview/settings.json"); expect(entries).toContain("overview/accounts.json"); expect(entries).toContain("overview/debug-setup.json"); + expect(entries).toContain("overview/self-check.json"); expect(entries).toContain("overview/trace-config.json"); expect(entries).toContain("logs/audit.log"); expect(entries).toContain("logs/trace.log");