Add remote host and item diagnostics

This commit is contained in:
Sucukdeluxe
2026-03-09 01:21:11 +01:00
parent 90a06d2926
commit 56ce7c2aea
13 changed files with 1387 additions and 79 deletions
+7
View File
@@ -28,6 +28,7 @@ import { configureLogger, getLogFilePath, logger } from "./logger";
import { AllDebridWebFallback } from "./all-debrid-web";
import { BestDebridWebFallback } from "./bestdebrid-web";
import { RealDebridWebFallback } from "./realdebrid-web";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
import { MegaWebFallback } from "./mega-web-fallback";
@@ -72,6 +73,7 @@ export class AppController {
configureLogger(this.storagePaths.baseDir);
initSessionLog(this.storagePaths.baseDir);
initPackageLogs(this.storagePaths.baseDir);
initItemLogs(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths);
const session = loadSession(this.storagePaths);
this.megaWebFallback = new MegaWebFallback(() => ({
@@ -484,6 +486,10 @@ export class AppController {
return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId);
}
public getItemLogPath(itemId: string): string | null {
return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId);
}
public shutdown(): void {
stopDebugServer();
abortActiveUpdateDownload();
@@ -494,6 +500,7 @@ export class AppController {
this.bestDebridWebFallback.dispose();
shutdownSessionLog();
shutdownPackageLogs();
shutdownItemLogs();
logger.info("App beendet");
}
+349 -79
View File
@@ -1,15 +1,25 @@
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { APP_VERSION } from "./constants";
import { logger, getLogFilePath } from "./logger";
import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager";
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
const DEFAULT_PORT = 9868;
const DEFAULT_HOST = "127.0.0.1";
const MAX_LOG_LINES = 10000;
let server: http.Server | null = null;
let manager: DownloadManager | null = null;
let authToken = "";
let bindHost = DEFAULT_HOST;
let bindPort = DEFAULT_PORT;
let runtimeBaseDir = "";
function loadToken(baseDir: string): string {
const tokenPath = path.join(baseDir, "debug_token.txt");
@@ -33,6 +43,25 @@ function getPort(baseDir: string): number {
return DEFAULT_PORT;
}
function getHost(baseDir: string): string {
const hostPath = path.join(baseDir, "debug_host.txt");
try {
const raw = fs.readFileSync(hostPath, "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 checkAuth(req: http.IncomingMessage): boolean {
if (!authToken) {
return false;
@@ -55,10 +84,20 @@ function jsonResponse(res: http.ServerResponse, status: number, data: unknown):
res.end(body);
}
function readLogTail(lines: number): string[] {
const logPath = getLogFilePath();
function normalizeLinesParam(rawValue: string | null, fallback: number): number {
const parsed = Number(rawValue || String(fallback));
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.max(1, Math.min(Math.floor(parsed), MAX_LOG_LINES));
}
function readLogTailFromFile(filePath: string | null, lines: number): string[] {
if (!filePath) {
return ["(Log-Datei nicht gefunden)"];
}
try {
const content = fs.readFileSync(logPath, "utf8");
const content = fs.readFileSync(filePath, "utf8");
const allLines = content.split("\n").filter((l) => l.trim().length > 0);
return allLines.slice(-Math.min(lines, MAX_LOG_LINES));
} catch {
@@ -66,11 +105,134 @@ function readLogTail(lines: number): string[] {
}
}
function filterLines(lines: string[], grep: string): string[] {
const pattern = String(grep || "").trim().toLowerCase();
if (!pattern) {
return lines;
}
return lines.filter((line) => line.toLowerCase().includes(pattern));
}
function summarizeItem(item: DownloadItem): Record<string, unknown> {
return {
id: item.id,
packageId: item.packageId,
fileName: item.fileName,
status: item.status,
fullStatus: item.fullStatus,
provider: item.provider,
providerLabel: item.providerLabel || "",
progress: item.progressPercent,
speedMBs: +(item.speedBps / 1024 / 1024).toFixed(2),
downloadedMB: +(item.downloadedBytes / 1024 / 1024).toFixed(1),
totalMB: item.totalBytes ? +(item.totalBytes / 1024 / 1024).toFixed(1) : null,
retries: item.retries,
lastError: item.lastError,
targetPath: item.targetPath,
updatedAt: item.updatedAt
};
}
function summarizePackage(snapshot: UiSnapshot, pkg: PackageEntry, includeItems: boolean): Record<string, unknown> {
const ids = new Set(pkg.itemIds);
const packageItems = Object.values(snapshot.session.items).filter((item) => ids.has(item.id));
const byStatus: Record<string, number> = {};
for (const item of packageItems) {
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
}
return {
id: pkg.id,
name: pkg.name,
status: pkg.status,
enabled: pkg.enabled,
cancelled: pkg.cancelled,
outputDir: pkg.outputDir,
extractDir: pkg.extractDir,
postProcessLabel: pkg.postProcessLabel || "",
itemCount: pkg.itemIds.length,
itemCounts: byStatus,
updatedAt: pkg.updatedAt,
items: includeItems ? packageItems.map((item) => summarizeItem(item)) : undefined
};
}
function findPackage(snapshot: UiSnapshot, query: string): PackageEntry | null {
const needle = String(query || "").trim().toLowerCase();
if (!needle) {
return null;
}
return Object.values(snapshot.session.packages).find((pkg) =>
pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle)
) || null;
}
function findItem(snapshot: UiSnapshot, query: string): DownloadItem | null {
const needle = String(query || "").trim().toLowerCase();
if (!needle) {
return null;
}
return Object.values(snapshot.session.items).find((item) =>
item.id.toLowerCase() === needle || item.fileName.toLowerCase().includes(needle)
) || null;
}
function getPackageLogPathForQuery(snapshot: UiSnapshot, query: string): { pkg: PackageEntry | null; logPath: string | null } {
const pkg = findPackage(snapshot, query);
if (pkg) {
const livePath = manager?.getPackageLogPath(pkg.id) || null;
return { pkg, logPath: livePath || getPersistedPackageLogPath(pkg.id) };
}
const directPath = getPersistedPackageLogPath(String(query || "").trim());
return { pkg: null, logPath: directPath };
}
function getItemLogPathForQuery(snapshot: UiSnapshot, query: string): { item: DownloadItem | null; logPath: string | null } {
const item = findItem(snapshot, query);
if (item) {
const livePath = manager?.getItemLogPath(item.id) || null;
return { item, logPath: livePath || getPersistedItemLogPath(item.id) };
}
const directPath = getPersistedItemLogPath(String(query || "").trim());
return { item: null, logPath: directPath };
}
function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> {
const items = Object.values(snapshot.session.items);
const packages = Object.values(snapshot.session.packages);
const byStatus: Record<string, number> = {};
for (const item of items) {
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
}
const activeItems = items
.filter((item) => item.status === "downloading" || item.status === "validating")
.map((item) => summarizeItem(item));
const failedItems = items
.filter((item) => item.status === "failed")
.map((item) => summarizeItem(item));
return {
running: snapshot.session.running,
paused: snapshot.session.paused,
speed: snapshot.speedText,
eta: snapshot.etaText,
itemCounts: byStatus,
totalItems: items.length,
totalPackages: packages.length,
packages: packages.map((pkg) => summarizePackage(snapshot, pkg, false)),
activeItems,
failedItems: failedItems.length > 0 ? failedItems : undefined
};
}
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Authorization"
"Access-Control-Allow-Headers": "Authorization",
"Access-Control-Allow-Methods": "GET,OPTIONS"
});
res.end();
return;
@@ -87,77 +249,144 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
if (pathname === "/health") {
jsonResponse(res, 200, {
status: "ok",
appVersion: APP_VERSION,
uptime: Math.floor(process.uptime()),
memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024)
});
return;
}
if (pathname === "/log") {
const count = Math.min(Number(url.searchParams.get("lines") || "100"), MAX_LOG_LINES);
if (pathname === "/meta") {
jsonResponse(res, 200, {
appVersion: APP_VERSION,
runtimeBaseDir,
debugServer: {
host: bindHost,
port: bindPort
},
logPaths: {
main: getLogFilePath(),
session: getSessionLogPath()
},
endpoints: [
"GET /health",
"GET /meta",
"GET /host/diagnostics",
"GET /log?lines=100&grep=keyword",
"GET /logs/main?lines=100&grep=keyword",
"GET /logs/session?lines=100&grep=keyword",
"GET /logs/package?package=Release&lines=100&grep=keyword",
"GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword",
"GET /status",
"GET /packages?package=Release&includeItems=1",
"GET /items?status=downloading&package=Release",
"GET /session?package=Release",
"GET /diagnostics?package=Release&lines=150"
]
});
return;
}
if (pathname === "/host/diagnostics") {
jsonResponse(res, 200, getWindowsHostDiagnostics());
return;
}
if (pathname === "/log" || pathname === "/logs/main") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
let lines = readLogTail(count);
if (grep) {
const pattern = grep.toLowerCase();
lines = lines.filter((l) => l.toLowerCase().includes(pattern));
}
const lines = filterLines(readLogTailFromFile(getLogFilePath(), count), grep);
jsonResponse(res, 200, { lines, count: lines.length });
return;
}
if (pathname === "/logs/session") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const logPath = getSessionLogPath();
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
jsonResponse(res, 200, {
path: logPath,
lines,
count: lines.length
});
return;
}
if (pathname === "/logs/package") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const packageQuery = url.searchParams.get("package") || url.searchParams.get("packageId") || "";
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const resolved = getPackageLogPathForQuery(snapshot, packageQuery);
if (!resolved.logPath) {
jsonResponse(res, 404, { error: "Package log not found", package: packageQuery });
return;
}
const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep);
jsonResponse(res, 200, {
package: resolved.pkg ? summarizePackage(snapshot, resolved.pkg, false) : undefined,
path: resolved.logPath,
lines,
count: lines.length
});
return;
}
if (pathname === "/logs/item") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const itemQuery = url.searchParams.get("item") || url.searchParams.get("itemId") || "";
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const resolved = getItemLogPathForQuery(snapshot, itemQuery);
if (!resolved.logPath) {
jsonResponse(res, 404, { error: "Item log not found", item: itemQuery });
return;
}
const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep);
jsonResponse(res, 200, {
item: resolved.item ? summarizeItem(resolved.item) : undefined,
path: resolved.logPath,
lines,
count: lines.length
});
return;
}
if (pathname === "/status") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const items = Object.values(snapshot.session.items);
const packages = Object.values(snapshot.session.packages);
jsonResponse(res, 200, buildStatusPayload(snapshot));
return;
}
const byStatus: Record<string, number> = {};
for (const item of items) {
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
if (pathname === "/packages") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const packageQuery = url.searchParams.get("package") || "";
const includeItems = /^(1|true|yes)$/i.test(String(url.searchParams.get("includeItems") || ""));
let packages = Object.values(snapshot.session.packages);
if (packageQuery) {
const needle = packageQuery.toLowerCase();
packages = packages.filter((pkg) => pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle));
}
const activeItems = items
.filter((i) => i.status === "downloading" || i.status === "validating")
.map((i) => ({
id: i.id,
fileName: i.fileName,
status: i.status,
fullStatus: i.fullStatus,
provider: i.provider,
progress: i.progressPercent,
speedMBs: +(i.speedBps / 1024 / 1024).toFixed(2),
downloadedMB: +(i.downloadedBytes / 1024 / 1024).toFixed(1),
totalMB: i.totalBytes ? +(i.totalBytes / 1024 / 1024).toFixed(1) : null,
retries: i.retries,
lastError: i.lastError
}));
const failedItems = items
.filter((i) => i.status === "failed")
.map((i) => ({
fileName: i.fileName,
lastError: i.lastError,
retries: i.retries,
provider: i.provider
}));
jsonResponse(res, 200, {
running: snapshot.session.running,
paused: snapshot.session.paused,
speed: snapshot.speedText,
eta: snapshot.etaText,
itemCounts: byStatus,
totalItems: items.length,
packages: packages.map((p) => ({
name: p.name,
status: p.status,
items: p.itemIds.length
})),
activeItems,
failedItems: failedItems.length > 0 ? failedItems : undefined
count: packages.length,
packages: packages.map((pkg) => summarizePackage(snapshot, pkg, includeItems))
});
return;
}
@@ -175,9 +404,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
items = items.filter((i) => i.status === filter);
}
if (pkg) {
const pkgLower = pkg.toLowerCase();
const matchedPkg = Object.values(snapshot.session.packages)
.find((p) => p.name.toLowerCase().includes(pkgLower));
const matchedPkg = findPackage(snapshot, pkg);
if (matchedPkg) {
const ids = new Set(matchedPkg.itemIds);
items = items.filter((i) => ids.has(i.id));
@@ -185,18 +412,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
}
jsonResponse(res, 200, {
count: items.length,
items: items.map((i) => ({
fileName: i.fileName,
status: i.status,
fullStatus: i.fullStatus,
provider: i.provider,
progress: i.progressPercent,
speedMBs: +(i.speedBps / 1024 / 1024).toFixed(2),
downloadedMB: +(i.downloadedBytes / 1024 / 1024).toFixed(1),
totalMB: i.totalBytes ? +(i.totalBytes / 1024 / 1024).toFixed(1) : null,
retries: i.retries,
lastError: i.lastError
}))
items: items.map((i) => summarizeItem(i))
});
return;
}
@@ -209,16 +425,14 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
const snapshot = manager.getSnapshot();
const pkg = url.searchParams.get("package");
if (pkg) {
const pkgLower = pkg.toLowerCase();
const matchedPkg = Object.values(snapshot.session.packages)
.find((p) => p.name.toLowerCase().includes(pkgLower));
const matchedPkg = findPackage(snapshot, pkg);
if (matchedPkg) {
const ids = new Set(matchedPkg.itemIds);
const pkgItems = Object.values(snapshot.session.items)
.filter((i) => ids.has(i.id));
jsonResponse(res, 200, {
package: matchedPkg,
items: pkgItems
package: summarizePackage(snapshot, matchedPkg, false),
items: pkgItems.map((item) => summarizeItem(item))
});
return;
}
@@ -238,19 +452,74 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
if (pathname === "/diagnostics") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const lineCount = normalizeLinesParam(url.searchParams.get("lines"), 150);
const grep = url.searchParams.get("grep") || "";
const packageQuery = url.searchParams.get("package") || "";
const mainLogPath = getLogFilePath();
const sessionLogPath = getSessionLogPath();
const selectedPackage = packageQuery ? findPackage(snapshot, packageQuery) : null;
const packageLogPath = selectedPackage
? manager.getPackageLogPath(selectedPackage.id) || getPersistedPackageLogPath(selectedPackage.id)
: null;
jsonResponse(res, 200, {
meta: {
appVersion: APP_VERSION,
serverTime: new Date().toISOString(),
runtimeBaseDir,
debugServer: {
host: bindHost,
port: bindPort
}
},
status: buildStatusPayload(snapshot),
host: getWindowsHostDiagnostics(),
selectedPackage: selectedPackage ? summarizePackage(snapshot, selectedPackage, true) : undefined,
logs: {
main: {
path: mainLogPath,
lines: filterLines(readLogTailFromFile(mainLogPath, lineCount), grep)
},
session: {
path: sessionLogPath,
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
},
package: selectedPackage ? {
path: packageLogPath,
lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep)
} : undefined
}
});
return;
}
jsonResponse(res, 404, {
error: "Not found",
endpoints: [
"GET /health",
"GET /meta",
"GET /host/diagnostics",
"GET /log?lines=100&grep=keyword",
"GET /logs/main?lines=100&grep=keyword",
"GET /logs/session?lines=100&grep=keyword",
"GET /logs/package?package=Release&lines=100&grep=keyword",
"GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword",
"GET /status",
"GET /packages?package=Release&includeItems=1",
"GET /items?status=downloading&package=Bloodline",
"GET /session?package=Criminal"
"GET /session?package=Criminal",
"GET /diagnostics?package=Criminal&lines=150"
]
});
}
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
runtimeBaseDir = baseDir;
authToken = loadToken(baseDir);
if (!authToken) {
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
@@ -258,11 +527,12 @@ export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
}
manager = mgr;
const port = getPort(baseDir);
bindPort = getPort(baseDir);
bindHost = getHost(baseDir);
server = http.createServer(handleRequest);
server.listen(port, "127.0.0.1", () => {
logger.info(`Debug-Server gestartet auf Port ${port}`);
server.listen(bindPort, bindHost, () => {
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`);
});
server.on("error", (err) => {
logger.warn(`Debug-Server Fehler: ${String(err)}`);
+30
View File
@@ -52,6 +52,7 @@ import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, Meg
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { logger } from "./logger";
import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log";
import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log";
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage";
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
@@ -1327,6 +1328,14 @@ export class DownloadManager extends EventEmitter {
return getPersistedPackageLogPath(packageId);
}
public getItemLogPath(itemId: string): string | null {
const item = this.session.items[itemId];
if (item) {
return this.ensureItemLogForItem(item);
}
return getPersistedItemLogPath(itemId);
}
private ensurePackageLogForPackage(pkg: PackageEntry): string | null {
return ensurePackageLog({
packageId: pkg.id,
@@ -1336,6 +1345,17 @@ export class DownloadManager extends EventEmitter {
});
}
private ensureItemLogForItem(item: DownloadItem): string | null {
const pkg = this.session.packages[item.packageId];
return ensureItemLog({
itemId: item.id,
packageId: item.packageId,
packageName: pkg?.name || "",
fileName: item.fileName,
targetPath: item.targetPath
});
}
private logPackage(packageId: string, level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
writePackageLogEvent(packageId, level, message, fields);
}
@@ -1358,6 +1378,7 @@ export class DownloadManager extends EventEmitter {
if (pkg) {
this.ensurePackageLogForPackage(pkg);
}
this.ensureItemLogForItem(item);
this.logPackage(item.packageId, level, message, {
packageName: pkg?.name || "",
itemId: item.id,
@@ -1366,6 +1387,15 @@ export class DownloadManager extends EventEmitter {
targetPath: item.targetPath,
...fields
});
writeItemLogEvent(item.id, level, message, {
packageId: item.packageId,
packageName: pkg?.name || "",
itemId: item.id,
fileName: item.fileName,
status: item.status,
targetPath: item.targetPath,
...fields
});
}
public setSettings(next: AppSettings): void {
+221
View File
@@ -0,0 +1,221 @@
import fs from "node:fs";
import path from "node:path";
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
const ITEM_LOG_RETENTION_DAYS = 30;
type ItemLogLevel = "INFO" | "WARN" | "ERROR";
export interface ItemLogMeta {
itemId: string;
packageId: string;
packageName: string;
fileName: string;
targetPath: string;
}
let itemLogsDir: string | null = null;
const knownLogPaths = new Map<string, string>();
const pendingLinesByItem = new Map<string, string[]>();
const initializedThisProcess = new Set<string>();
let flushTimer: NodeJS.Timeout | null = null;
function normalizeItemId(itemId: string): string {
return String(itemId || "").trim();
}
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 getItemLogFilePath(itemId: string): string | null {
const normalized = normalizeItemId(itemId);
if (!normalized || !itemLogsDir) {
return null;
}
const existing = knownLogPaths.get(normalized);
if (existing) {
return existing;
}
const logPath = path.join(itemLogsDir, `item_${normalized}.txt`);
knownLogPaths.set(normalized, logPath);
return logPath;
}
function flushPending(): void {
for (const [itemId, lines] of pendingLinesByItem.entries()) {
if (lines.length === 0) {
continue;
}
const logPath = getItemLogFilePath(itemId);
if (!logPath) {
continue;
}
const chunk = lines.join("");
pendingLinesByItem.set(itemId, []);
try {
fs.appendFileSync(logPath, chunk, "utf8");
} catch {
// ignore write errors
}
}
}
function scheduleFlush(): void {
if (flushTimer) {
return;
}
flushTimer = setTimeout(() => {
flushTimer = null;
flushPending();
}, ITEM_LOG_FLUSH_INTERVAL_MS);
}
async function cleanupOldItemLogs(dir: string): Promise<void> {
try {
const files = await fs.promises.readdir(dir);
const cutoff = Date.now() - ITEM_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
for (const file of files) {
if (!file.startsWith("item_") || !file.endsWith(".txt")) {
continue;
}
const filePath = path.join(dir, file);
try {
const stat = await fs.promises.stat(filePath);
if (stat.mtimeMs < cutoff) {
await fs.promises.unlink(filePath);
}
} catch {
// ignore locked/missing files
}
}
} catch {
// ignore missing dir
}
}
function appendLine(itemId: string, line: string): void {
const normalized = normalizeItemId(itemId);
if (!normalized) {
return;
}
const lines = pendingLinesByItem.get(normalized) || [];
lines.push(line);
pendingLinesByItem.set(normalized, lines);
scheduleFlush();
}
export function initItemLogs(baseDir: string): void {
itemLogsDir = path.join(baseDir, "item-logs");
try {
fs.mkdirSync(itemLogsDir, { recursive: true });
} catch {
itemLogsDir = null;
return;
}
void cleanupOldItemLogs(itemLogsDir);
}
export function ensureItemLog(meta: ItemLogMeta): string | null {
const itemId = normalizeItemId(meta.itemId);
const logPath = getItemLogFilePath(itemId);
if (!logPath) {
return null;
}
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
if (!fs.existsSync(logPath)) {
fs.writeFileSync(logPath, "", "utf8");
}
if (!initializedThisProcess.has(itemId)) {
initializedThisProcess.add(itemId);
const startedAt = new Date().toISOString();
fs.appendFileSync(
logPath,
`=== Item-Log Start: ${startedAt} | itemId=${itemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
"utf8"
);
fs.appendFileSync(
logPath,
`${new Date().toISOString()} [INFO] Item-Kontext initialisiert${formatFields({
packageId: meta.packageId,
packageName: meta.packageName,
fileName: meta.fileName,
targetPath: meta.targetPath
})}\n`,
"utf8"
);
}
} catch {
return null;
}
return logPath;
}
export function logItemEvent(
itemId: string,
level: ItemLogLevel,
message: string,
fields?: Record<string, unknown>
): void {
const logPath = getItemLogFilePath(itemId);
if (!logPath) {
return;
}
const line = `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`;
appendLine(itemId, line);
}
export function getItemLogPath(itemId: string): string | null {
const logPath = getItemLogFilePath(itemId);
if (!logPath) {
return null;
}
return fs.existsSync(logPath) ? logPath : null;
}
export function shutdownItemLogs(): void {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushPending();
for (const itemId of knownLogPaths.keys()) {
const logPath = getItemLogFilePath(itemId);
if (!logPath) {
continue;
}
try {
fs.appendFileSync(logPath, `=== Item-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
// ignore
}
}
pendingLinesByItem.clear();
knownLogPaths.clear();
initializedThisProcess.clear();
itemLogsDir = null;
}
+8
View File
@@ -510,6 +510,14 @@ function registerIpcHandlers(): void {
}
});
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
validateString(itemId, "itemId");
const logPath = controller.getItemLogPath(itemId);
if (logPath) {
await shell.openPath(logPath);
}
});
ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => {
await controller.openRealDebridLoginWindow();
});
+322
View File
@@ -0,0 +1,322 @@
import fs from "node:fs";
import { spawnSync } from "node:child_process";
export interface WindowsHostEvent {
timeCreated: string;
id: number;
providerName: string;
levelDisplayName: string;
message: string;
bugcheckCode?: string;
bugcheckCodeHex?: string;
reportId?: string;
}
export interface WindowsHostDumpFile {
name: string;
fullName: string;
length: number;
lastWriteTime: string;
}
export interface WindowsCrashControlInfo {
crashDumpEnabled: number | null;
minidumpDir: string;
dumpFile: string;
overwrite: number | null;
logEvent: number | null;
autoReboot: number | null;
}
export interface WindowsHostDiagnostics {
collectedAt: string;
supported: boolean;
platform: string;
crashControl: WindowsCrashControlInfo | null;
recentKernelPower: WindowsHostEvent[];
recentWerKernel: WindowsHostEvent[];
recentKernelDump: WindowsHostEvent[];
recentAppCrashes: WindowsHostEvent[];
recentMinidumps: WindowsHostDumpFile[];
assessmentHints: string[];
errors: string[];
}
const CACHE_TTL_MS = 15_000;
let cachedAt = 0;
let cachedValue: WindowsHostDiagnostics | null = null;
function createEmptyDiagnostics(): WindowsHostDiagnostics {
return {
collectedAt: new Date().toISOString(),
supported: process.platform === "win32",
platform: process.platform,
crashControl: null,
recentKernelPower: [],
recentWerKernel: [],
recentKernelDump: [],
recentAppCrashes: [],
recentMinidumps: [],
assessmentHints: [],
errors: []
};
}
function runPowerShellJson(script: string): unknown {
const result = spawnSync(
process.env.ComSpec && process.env.ComSpec.toLowerCase().includes("pwsh") ? process.env.ComSpec : "powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
{
encoding: "utf8",
timeout: 20_000,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"]
}
);
if (result.status !== 0) {
const errorText = String(result.stderr || result.stdout || "").trim() || `PowerShell exited with code ${result.status}`;
throw new Error(errorText);
}
const text = String(result.stdout || "").trim();
if (!text) {
return null;
}
return JSON.parse(text) as unknown;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function asNumber(value: unknown): number | null {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeEvent(value: unknown): WindowsHostEvent | null {
const record = asRecord(value);
if (!record) {
return null;
}
return {
timeCreated: asString(record.TimeCreated),
id: asNumber(record.Id) || 0,
providerName: asString(record.ProviderName),
levelDisplayName: asString(record.LevelDisplayName),
message: asString(record.Message),
bugcheckCode: asString(record.BugcheckCode),
bugcheckCodeHex: asString(record.BugcheckCodeHex),
reportId: asString(record.ReportId)
};
}
function normalizeDumpFile(value: unknown): WindowsHostDumpFile | null {
const record = asRecord(value);
if (!record) {
return null;
}
return {
name: asString(record.Name),
fullName: asString(record.FullName),
length: asNumber(record.Length) || 0,
lastWriteTime: asString(record.LastWriteTime)
};
}
function normalizeCrashControl(value: unknown): WindowsCrashControlInfo | null {
const record = asRecord(value);
if (!record) {
return null;
}
return {
crashDumpEnabled: asNumber(record.CrashDumpEnabled),
minidumpDir: asString(record.MinidumpDir),
dumpFile: asString(record.DumpFile),
overwrite: asNumber(record.Overwrite),
logEvent: asNumber(record.LogEvent),
autoReboot: asNumber(record.AutoReboot)
};
}
function pushHints(diagnostics: WindowsHostDiagnostics): void {
if (diagnostics.recentKernelPower.some((entry) => String(entry.bugcheckCode || "").trim() === "0")) {
diagnostics.assessmentHints.push("Kernel-Power 41 mit BugcheckCode 0 deutet eher auf Freeze, Watchdog oder harten Reset als auf einen sauber erfassten klassischen BSOD hin.");
}
if (diagnostics.recentWerKernel.some((entry) => /watchdog/i.test(entry.message))) {
diagnostics.assessmentHints.push("WER-Kernel meldet WATCHDOG-Live-Dumps. Das spricht eher fuer Kernel-, Treiber- oder Hardware-Stalls als fuer einen normalen User-Mode-App-Crash.");
}
if (diagnostics.recentAppCrashes.length === 0) {
diagnostics.assessmentHints.push("Keine passenden Application-Error- oder Windows-Error-Reporting-Eintraege fuer den Downloader/Electron in den letzten Tagen gefunden.");
}
if (diagnostics.recentMinidumps.length === 0) {
diagnostics.assessmentHints.push("Keine aktuellen Minidumps gefunden. Falls der Server erneut abstuerzt, sollte geprueft werden, ob Windows den Dump wirklich schreiben darf.");
}
}
function loadFromPowerShell(): WindowsHostDiagnostics {
const script = String.raw`
$ErrorActionPreference = "SilentlyContinue"
function Convert-EventRecord($eventRecord) {
$map = @{}
try {
[xml]$xml = $eventRecord.ToXml()
foreach ($node in $xml.Event.EventData.Data) {
if ($node.Name) {
$map[$node.Name] = [string]$node.'#text'
}
}
} catch {
}
$reportId = ""
if ([string]$eventRecord.Message -match "ReportId\s+([^,\r\n]+)") {
$reportId = $Matches[1]
}
[PSCustomObject]@{
TimeCreated = if ($eventRecord.TimeCreated) { $eventRecord.TimeCreated.ToUniversalTime().ToString("o") } else { "" }
Id = [int]$eventRecord.Id
ProviderName = [string]$eventRecord.ProviderName
LevelDisplayName = [string]$eventRecord.LevelDisplayName
Message = [string]$eventRecord.Message
BugcheckCode = if ($map.ContainsKey("BugcheckCode")) { [string]$map["BugcheckCode"] } else { "" }
BugcheckCodeHex = if ($map.ContainsKey("BugcheckCode") -and [int64]$map["BugcheckCode"] -gt 0) { ("0x{0:X}" -f [int64]$map["BugcheckCode"]) } else { "" }
ReportId = $reportId
}
}
$startTime = (Get-Date).AddDays(-7)
$crashControl = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl"
$kernelPower = @(
Get-WinEvent -FilterHashtable @{ LogName = "System"; Id = 41; StartTime = $startTime } -MaxEvents 5 |
ForEach-Object { Convert-EventRecord $_ }
)
$werKernel = @(
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-WerKernel/Operational"; StartTime = $startTime } -MaxEvents 30 |
Where-Object { $_.Message -match "WATCHDOG|dump|bugcheck|blue|memory" } |
Select-Object -First 10 |
ForEach-Object { Convert-EventRecord $_ }
)
$kernelDump = @(
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-Kernel-Dump/Operational"; StartTime = $startTime } -MaxEvents 20 |
Select-Object -First 10 |
ForEach-Object { Convert-EventRecord $_ }
)
$appCrashes = @(
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 |
Where-Object {
($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and
($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js")
} |
Select-Object -First 10 |
ForEach-Object { Convert-EventRecord $_ }
)
$dumpFiles = @()
foreach ($dir in @("C:\Windows\Minidump", "C:\Windows\Minidumps")) {
if (Test-Path $dir) {
$dumpFiles += Get-ChildItem -Path $dir -File |
Sort-Object LastWriteTime -Descending |
Select-Object -First 10 |
ForEach-Object {
[PSCustomObject]@{
Name = $_.Name
FullName = $_.FullName
Length = [int64]$_.Length
LastWriteTime = $_.LastWriteTimeUtc.ToString("o")
}
}
}
}
[PSCustomObject]@{
CrashControl = [PSCustomObject]@{
CrashDumpEnabled = if ($null -ne $crashControl.CrashDumpEnabled) { [int]$crashControl.CrashDumpEnabled } else { $null }
MinidumpDir = [string]$crashControl.MinidumpDir
DumpFile = [string]$crashControl.DumpFile
Overwrite = if ($null -ne $crashControl.Overwrite) { [int]$crashControl.Overwrite } else { $null }
LogEvent = if ($null -ne $crashControl.LogEvent) { [int]$crashControl.LogEvent } else { $null }
AutoReboot = if ($null -ne $crashControl.AutoReboot) { [int]$crashControl.AutoReboot } else { $null }
}
RecentKernelPower = @($kernelPower)
RecentWerKernel = @($werKernel)
RecentKernelDump = @($kernelDump)
RecentAppCrashes = @($appCrashes)
RecentMinidumps = @($dumpFiles)
} | ConvertTo-Json -Depth 6 -Compress
`;
const raw = runPowerShellJson(script);
const parsed = asRecord(raw);
const diagnostics = createEmptyDiagnostics();
diagnostics.crashControl = normalizeCrashControl(parsed?.CrashControl ?? null);
diagnostics.recentKernelPower = Array.isArray(parsed?.RecentKernelPower) ? parsed!.RecentKernelPower.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
diagnostics.recentWerKernel = Array.isArray(parsed?.RecentWerKernel) ? parsed!.RecentWerKernel.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
diagnostics.recentKernelDump = Array.isArray(parsed?.RecentKernelDump) ? parsed!.RecentKernelDump.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
diagnostics.recentAppCrashes = Array.isArray(parsed?.RecentAppCrashes) ? parsed!.RecentAppCrashes.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
diagnostics.recentMinidumps = Array.isArray(parsed?.RecentMinidumps) ? parsed!.RecentMinidumps.map(normalizeDumpFile).filter(Boolean) as WindowsHostDumpFile[] : [];
diagnostics.collectedAt = new Date().toISOString();
pushHints(diagnostics);
return diagnostics;
}
export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiagnostics {
if (!forceRefresh && cachedValue && Date.now() - cachedAt < CACHE_TTL_MS) {
return cachedValue;
}
const diagnostics = createEmptyDiagnostics();
if (process.platform !== "win32") {
diagnostics.assessmentHints.push("Windows-Host-Diagnose ist nur unter Windows verfuegbar.");
cachedAt = Date.now();
cachedValue = diagnostics;
return diagnostics;
}
try {
const loaded = loadFromPowerShell();
cachedAt = Date.now();
cachedValue = loaded;
return loaded;
} catch (error) {
diagnostics.errors.push(String(error instanceof Error ? error.message : error));
diagnostics.assessmentHints.push("Host-Diagnose konnte nicht vollstaendig geladen werden.");
cachedAt = Date.now();
cachedValue = diagnostics;
return diagnostics;
}
}
export function resetWindowsHostDiagnosticsCache(): void {
cachedAt = 0;
cachedValue = null;
}
export function hasRecentWindowsMinidumps(): boolean {
for (const dir of ["C:\\Windows\\Minidump", "C:\\Windows\\Minidumps"]) {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
if (entries.some((entry) => entry.isFile())) {
return true;
}
} catch {
// ignore
}
}
return false;
}