Add support self-check diagnostics

This commit is contained in:
Sucukdeluxe 2026-03-09 03:00:36 +01:00
parent 28452373bf
commit 7027e11cbd
7 changed files with 414 additions and 10 deletions

View File

@ -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. 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: Available endpoints after restart:
- `GET /health` - `GET /health`
- `GET /meta` - `GET /meta`
- `GET /debug/setup` - `GET /debug/setup`
- `GET /self-check`
- `GET /host/diagnostics` - `GET /host/diagnostics`
- `GET /status` - `GET /status`
- `GET /settings` - `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/stats?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20" 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/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/audit?token=YOUR_TOKEN&lines=200"
Invoke-RestMethod "http://SERVER:9868/logs/trace?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&note=support&durationMinutes=120" Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1&note=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" 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 ## Troubleshooting

View File

@ -33,6 +33,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/health", description: "Basic health, uptime, and memory information." }, { 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: "/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: "/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: "/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: "/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/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." },
@ -238,7 +239,7 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
"Read debug_token.txt and debug_port.txt from this runtime folder.", "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.", "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.", "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.", "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." "If a full handoff is needed, download /support/bundle as a ZIP."
], ],
@ -274,6 +275,7 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
remoteHostHint remoteHostHint
}, },
setupCheckEndpoint: "/debug/setup", setupCheckEndpoint: "/debug/setup",
selfCheckEndpoint: "/self-check",
askUserFor: [ askUserFor: [
"Server IP or DNS name, if remote access is required and not already known." "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() traceLog: getTraceLogPath()
}, },
supportChecks: { supportChecks: {
setup: "/debug/setup" setup: "/debug/setup",
selfCheck: "/self-check"
}, },
logPaths: { logPaths: {
main: getLogFilePath(), main: getLogFilePath(),
@ -488,7 +491,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
if (pathname === "/debug/setup") { if (pathname === "/debug/setup" || pathname === "/self-check") {
jsonResponse(res, 200, getDebugSetupCheck(runtimeBaseDir)); jsonResponse(res, 200, getDebugSetupCheck(runtimeBaseDir));
return; return;
} }

View File

@ -1,10 +1,42 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; 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_PORT = 9868;
const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_HOST = "127.0.0.1";
const AI_MANIFEST_FILE = "debug_ai_manifest.json"; 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 { function readToken(baseDir: string): string {
try { 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 { export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
const host = readHost(baseDir); const host = readHost(baseDir);
const port = readPort(baseDir); const port = readPort(baseDir);
const token = readToken(baseDir); const token = readToken(baseDir);
const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths);
const tokenPath = path.join(baseDir, "debug_token.txt"); const tokenPath = path.join(baseDir, "debug_token.txt");
const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE); const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE);
const traceConfigPath = path.join(baseDir, "trace_config.json"); const traceConfigPath = path.join(baseDir, "trace_config.json");
const traceLogPath = path.join(baseDir, "trace.log"); const traceLogPath = path.join(baseDir, "trace.log");
const traceConfig = readTraceConfig(baseDir); const traceConfig = readTraceConfig(baseDir);
const sessionLogPath = getSessionLogPath();
const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host); const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host);
const warnings: string[] = []; const warnings: string[] = [];
const notes: 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) { if (!token) {
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht."); warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
} }
if (localOnly) { 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 { } 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)) { if (!fs.existsSync(aiManifestPath)) {
warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei neu geschrieben wird."); 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) { if (traceConfig.enabled && traceConfig.autoDisableAt) {
notes.push(`Support-Trace aktiv bis ${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 { return {
status: warnings.length > 0 ? "warn" : "ok",
enabled: Boolean(token), enabled: Boolean(token),
runtimeBaseDir: baseDir,
host, host,
port, port,
localOnly, localOnly,
@ -117,6 +413,9 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null, traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
traceEnabled: traceConfig.enabled, traceEnabled: traceConfig.enabled,
traceAutoDisableAt: traceConfig.autoDisableAt, traceAutoDisableAt: traceConfig.autoDisableAt,
diskSpace,
logSummary,
supportBundle,
warnings, warnings,
notes, notes,
localUrls: { localUrls: {

View File

@ -72,6 +72,7 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B
const snapshot = manager.getSnapshot(); const snapshot = manager.getSnapshot();
const packageIds = Object.keys(snapshot.session.packages); const packageIds = Object.keys(snapshot.session.packages);
const itemIds = Object.keys(snapshot.session.items); const itemIds = Object.keys(snapshot.session.items);
const debugSetup = getDebugSetupCheck(baseDir);
addJson(zip, "overview/meta.json", { addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION, appVersion: APP_VERSION,
@ -90,7 +91,8 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime 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", { addJson(zip, "overview/history.json", {
total: history.length, total: history.length,
entries: history.map((entry) => summarizeHistoryEntry(entry)) entries: history.map((entry) => summarizeHistoryEntry(entry))

View File

@ -141,8 +141,18 @@ interface ConfiguredAccountEntry {
} }
function buildDebugSetupDetails(setup: DebugSetupCheckResult): string { 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[] = [ const lines: string[] = [
`Status: ${setup.status === "ok" ? "OK" : "Warnung"}`,
`Debug-Server aktiv: ${setup.enabled ? "ja" : "nein"}`, `Debug-Server aktiv: ${setup.enabled ? "ja" : "nein"}`,
`Runtime-Ordner: ${setup.runtimeBaseDir}`,
`Host: ${setup.host}`, `Host: ${setup.host}`,
`Port: ${setup.port}`, `Port: ${setup.port}`,
`Token-Datei: ${setup.tokenPath}`, `Token-Datei: ${setup.tokenPath}`,
@ -150,6 +160,25 @@ function buildDebugSetupDetails(setup: DebugSetupCheckResult): string {
`Trace aktiv: ${setup.traceEnabled ? "ja" : "nein"}`, `Trace aktiv: ${setup.traceEnabled ? "ja" : "nein"}`,
`Trace-Auto-Ende: ${setup.traceAutoDisableAt || "nicht gesetzt"}`, `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:", "Lokale URLs:",
setup.localUrls.health, setup.localUrls.health,
setup.localUrls.meta, setup.localUrls.meta,

View File

@ -346,8 +346,37 @@ export interface SupportTraceConfig {
updatedAt: string; 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 { export interface DebugSetupCheckResult {
status: "ok" | "warn";
enabled: boolean; enabled: boolean;
runtimeBaseDir: string;
host: string; host: string;
port: number; port: number;
localOnly: boolean; localOnly: boolean;
@ -359,6 +388,25 @@ export interface DebugSetupCheckResult {
traceLogPath: string | null; traceLogPath: string | null;
traceEnabled: boolean; traceEnabled: boolean;
traceAutoDisableAt: string | null; 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[]; warnings: string[];
notes: string[]; notes: string[];
localUrls: { localUrls: {

View File

@ -342,6 +342,7 @@ describe("debug-server", () => {
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>"); expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>");
expect(manifest.quickstart?.[1]).toContain("server IP"); expect(manifest.quickstart?.[1]).toContain("server IP");
expect(manifest.setupCheckEndpoint).toBe("/debug/setup"); expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
expect(manifest.selfCheckEndpoint).toBe("/self-check");
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt"); expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true); expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
expect(JSON.stringify(manifest)).not.toContain(fixture.token); expect(JSON.stringify(manifest)).not.toContain(fixture.token);
@ -353,6 +354,7 @@ describe("debug-server", () => {
expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath()); expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath());
expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath()); expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath());
expect(metaPayload.supportChecks?.setup).toBe("/debug/setup"); 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 () => { 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<string, any>; const payload = await response.json() as Record<string, any>;
expect(payload.enabled).toBe(true); 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.host).toBe("0.0.0.0");
expect(payload.localOnly).toBe(false); expect(payload.localOnly).toBe(false);
expect(payload.tokenConfigured).toBe(true); expect(payload.tokenConfigured).toBe(true);
expect(payload.aiManifestPresent).toBe(true); expect(payload.aiManifestPresent).toBe(true);
expect(payload.traceEnabled).toBe(true); expect(payload.traceEnabled).toBe(true);
expect(payload.traceAutoDisableAt).toBeTruthy(); 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("<SERVER_IP_OR_DNS>"); expect(payload.remoteUrlTemplates?.health).toContain("<SERVER_IP_OR_DNS>");
expect(Array.isArray(payload.notes)).toBe(true); 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<string, any>;
expect(payload.status).toBe("ok");
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
});
it("serves package details and package log by package query", async () => { it("serves package details and package log by package query", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
@ -469,6 +489,7 @@ describe("debug-server", () => {
expect(entries).toContain("overview/settings.json"); expect(entries).toContain("overview/settings.json");
expect(entries).toContain("overview/accounts.json"); expect(entries).toContain("overview/accounts.json");
expect(entries).toContain("overview/debug-setup.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("overview/trace-config.json");
expect(entries).toContain("logs/audit.log"); expect(entries).toContain("logs/audit.log");
expect(entries).toContain("logs/trace.log"); expect(entries).toContain("logs/trace.log");