439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
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 {
|
|
return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function readPort(baseDir: string): number {
|
|
try {
|
|
const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim());
|
|
if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) {
|
|
return raw;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return DEFAULT_PORT;
|
|
}
|
|
|
|
function readHost(baseDir: string): string {
|
|
try {
|
|
const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim();
|
|
if (!raw) {
|
|
return DEFAULT_HOST;
|
|
}
|
|
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
|
|
return raw;
|
|
}
|
|
if (/^[a-z0-9.-]+$/i.test(raw)) {
|
|
return raw;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return DEFAULT_HOST;
|
|
}
|
|
|
|
function readTraceConfig(baseDir: string): SupportTraceConfig {
|
|
const fallback: SupportTraceConfig = {
|
|
enabled: false,
|
|
includeMainLog: true,
|
|
includeAudit: true,
|
|
logDebugRequests: true,
|
|
autoDisableAt: null,
|
|
updatedAt: new Date(0).toISOString()
|
|
};
|
|
try {
|
|
const filePath = path.join(baseDir, "trace_config.json");
|
|
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<SupportTraceConfig>;
|
|
return {
|
|
enabled: Boolean(parsed.enabled),
|
|
includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog),
|
|
includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit),
|
|
logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests),
|
|
autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null,
|
|
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt
|
|
};
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
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.rename.exists)
|
|
+ Number(logSummary.renameBackup.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")),
|
|
rename: getFileSizeInfo(path.join(baseDir, "rename.log")),
|
|
renameBackup: getFileSizeInfo(path.join(baseDir, "rename.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.rename.bytes,
|
|
logSummary.renameBackup.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.");
|
|
} else {
|
|
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.");
|
|
}
|
|
if (!fs.existsSync(traceConfigPath)) {
|
|
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
|
|
}
|
|
if (traceConfig.enabled && !traceConfig.autoDisableAt) {
|
|
warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird.");
|
|
}
|
|
if (traceConfig.enabled && traceConfig.autoDisableAt) {
|
|
notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`);
|
|
}
|
|
|
|
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,
|
|
tokenConfigured: Boolean(token),
|
|
tokenPath,
|
|
aiManifestPath,
|
|
aiManifestPresent: fs.existsSync(aiManifestPath),
|
|
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
|
|
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
|
|
traceEnabled: traceConfig.enabled,
|
|
traceAutoDisableAt: traceConfig.autoDisableAt,
|
|
diskSpace,
|
|
logSummary,
|
|
supportBundle,
|
|
warnings,
|
|
notes,
|
|
localUrls: {
|
|
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`,
|
|
meta: `http://127.0.0.1:${port}/meta?token=${token || "<TOKEN>"}`,
|
|
diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
|
},
|
|
remoteUrlTemplates: {
|
|
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`,
|
|
meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?token=${token || "<TOKEN>"}`,
|
|
diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
|
}
|
|
};
|
|
}
|