Include desktop app logs in diagnostics (#1914)

* fix(diagnostics): include desktop app logs

Settings diagnostics now collect the Electron main-process log separately from daemon.log so desktop startup and environment-probe failures are included in app reports.

* refactor(desktop): share log tail reader

* fix(diagnostics): surface desktop app log read errors

* refactor(diagnostics): tighten desktop report API
This commit is contained in:
Mohamed Boudra
2026-07-06 17:50:45 +02:00
committed by GitHub
parent 7c1da0077b
commit f21c997b17
10 changed files with 322 additions and 48 deletions
@@ -9,7 +9,6 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
import { useToast } from "@/contexts/toast-context";
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import {
formatAppDiagnosticHeader,
formatDiagnosticSection,
@@ -17,6 +16,7 @@ import {
formatServerInfoSection,
redactAppDiagnosticReport,
} from "@/diagnostics/app-diagnostic-report";
import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report";
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
@@ -261,35 +261,6 @@ export function AppDiagnosticSheet({
);
}
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
try {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
return {
status: "done",
sections: [
formatDiagnosticSection("Desktop", [
{ label: "Daemon status", value: status.status },
{ label: "Desktop managed", value: String(status.desktopManaged) },
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
{ label: "Daemon version", value: status.version ?? "unknown" },
{ label: "Daemon home", value: status.home || "unknown" },
{ label: "Log path", value: logs.logPath || "unknown" },
{ label: "Error", value: status.error ?? "none" },
]),
[
"Desktop daemon log tail",
logs.contents ? indentBlock(logs.contents) : " No log lines found",
].join("\n"),
],
};
} catch (error) {
return {
status: "failed",
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
};
}
}
async function collectHostDiagnosticSections(
host: HostProfile,
snapshot: HostRuntimeSnapshot | null,
@@ -338,14 +309,6 @@ async function collectHostDiagnosticSections(
}
}
function indentBlock(value: string): string {
return value
.split("\n")
.filter(Boolean)
.map((line) => ` ${line}`)
.join("\n");
}
function toMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -28,6 +28,11 @@ export interface DesktopDaemonLogs {
contents: string;
}
export interface DesktopAppLogs {
logPath: string;
contents: string;
}
export interface DesktopPairingOffer {
relayEnabled: boolean;
url: string | null;
@@ -144,6 +149,17 @@ export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs"));
}
export async function getDesktopAppLogs(): Promise<DesktopAppLogs> {
const raw = await invokeDesktopCommand("desktop_app_logs");
if (!isRecord(raw)) {
throw new Error("Unexpected desktop app logs response.");
}
return {
logPath: toStringOrNull(raw.logPath) ?? "",
contents: typeof raw.contents === "string" ? raw.contents : "",
};
}
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
}
@@ -82,6 +82,7 @@ describe("app diagnostics report", () => {
const host = makeHost();
const redacted = redactAppDiagnosticReport(
[
"Desktop app log tail",
"secret.example.test:6767",
"relay.secret.test:443",
"daemon-public-key-secret",
@@ -0,0 +1,93 @@
import { describe, expect, test } from "vitest";
import {
collectDesktopDiagnosticSections,
type DesktopDiagnosticSources,
} from "./desktop-diagnostic-report";
function makeSources(): DesktopDiagnosticSources {
return {
getStatus: async () => ({
serverId: "server-1",
status: "running",
listen: "127.0.0.1:6767",
hostname: "host",
pid: 4242,
home: "/paseo/home",
version: "1.2.3",
desktopManaged: true,
error: null,
}),
getDaemonLogs: async () => ({
logPath: "/paseo/home/daemon.log",
contents: "daemon line one\ndaemon line two",
}),
getAppLogs: async () => ({
logPath: "/logs/Paseo/main.log",
contents: "[login-shell-env] start\n[login-shell-env] failed",
}),
};
}
describe("desktop diagnostic report", () => {
test("starts desktop diagnostic requests together", async () => {
const calls: string[] = [];
let releaseAppLogs: () => void = () => {};
const appLogGate = new Promise<void>((resolve) => {
releaseAppLogs = resolve;
});
const sources: DesktopDiagnosticSources = {
...makeSources(),
getStatus: async () => {
calls.push("status");
return makeSources().getStatus();
},
getDaemonLogs: async () => {
calls.push("daemonLogs");
return makeSources().getDaemonLogs();
},
getAppLogs: async () => {
calls.push("appLogs");
await appLogGate;
return makeSources().getAppLogs();
},
};
const resultPromise = collectDesktopDiagnosticSections(sources);
expect(calls).toEqual(["status", "daemonLogs", "appLogs"]);
releaseAppLogs();
await expect(resultPromise).resolves.toMatchObject({ status: "done" });
});
test("includes the Electron main-process log after the daemon log", async () => {
const result = await collectDesktopDiagnosticSections(makeSources());
const report = result.sections.join("\n\n");
expect(result.status).toBe("done");
expect(report).toContain(" Log path: /paseo/home/daemon.log");
expect(report).toContain(" App log path: /logs/Paseo/main.log");
expect(report).toContain("Desktop daemon log tail\n daemon line one\n daemon line two");
expect(report).toContain(
"Desktop app log tail\n [login-shell-env] start\n [login-shell-env] failed",
);
expect(report.indexOf("Desktop app log tail")).toBeGreaterThan(
report.indexOf("Desktop daemon log tail"),
);
});
test("keeps daemon diagnostics when the Electron app log fails", async () => {
const sources = {
...makeSources(),
getAppLogs: async () => {
throw new Error("app log unavailable");
},
};
const result = await collectDesktopDiagnosticSections(sources);
const report = result.sections.join("\n\n");
expect(result.status).toBe("failed");
expect(report).toContain("Desktop daemon log tail\n daemon line one\n daemon line two");
expect(report).toContain("Desktop app log tail\n Error: app log unavailable");
});
});
@@ -0,0 +1,106 @@
import {
getDesktopAppLogs,
getDesktopDaemonLogs,
getDesktopDaemonStatus,
type DesktopAppLogs,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon";
import { formatDiagnosticSection } from "./app-diagnostic-report";
type DesktopDiagnosticStatus = "done" | "failed";
export interface DesktopDiagnosticCollectionResult {
sections: string[];
status: DesktopDiagnosticStatus;
}
export interface DesktopDiagnosticSources {
getStatus: () => Promise<DesktopDaemonStatus>;
getDaemonLogs: () => Promise<DesktopDaemonLogs>;
getAppLogs: () => Promise<DesktopAppLogs>;
}
const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = {
getStatus: getDesktopDaemonStatus,
getDaemonLogs: getDesktopDaemonLogs,
getAppLogs: getDesktopAppLogs,
};
export async function collectDesktopDiagnosticSections(
sources: DesktopDiagnosticSources = DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES,
): Promise<DesktopDiagnosticCollectionResult> {
const sections: string[] = [];
let failed = false;
const [daemonResult, appLogsResult] = await Promise.allSettled([
Promise.all([sources.getStatus(), sources.getDaemonLogs()]),
sources.getAppLogs(),
]);
if (daemonResult.status === "fulfilled") {
const [status, daemonLogs] = daemonResult.value;
const appLogs = appLogsResult.status === "fulfilled" ? appLogsResult.value : null;
sections.unshift(...formatDesktopDaemonSections({ status, daemonLogs, appLogs }));
} else {
failed = true;
sections.unshift(
formatDiagnosticSection("Desktop", [
{ label: "Error", value: toMessage(daemonResult.reason) },
]),
);
}
if (appLogsResult.status === "fulfilled") {
sections.push(formatLogTailSection("Desktop app log tail", appLogsResult.value.contents));
} else {
failed = true;
sections.push(
formatDiagnosticSection("Desktop app log tail", [
{ label: "Error", value: toMessage(appLogsResult.reason) },
]),
);
}
return {
status: failed ? "failed" : "done",
sections,
};
}
function formatDesktopDaemonSections(input: {
status: DesktopDaemonStatus;
daemonLogs: DesktopDaemonLogs;
appLogs: DesktopAppLogs | null;
}): string[] {
const { status, daemonLogs, appLogs } = input;
return [
formatDiagnosticSection("Desktop", [
{ label: "Daemon status", value: status.status },
{ label: "Desktop managed", value: String(status.desktopManaged) },
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
{ label: "Daemon version", value: status.version ?? "unknown" },
{ label: "Daemon home", value: status.home || "unknown" },
{ label: "Log path", value: daemonLogs.logPath || "unknown" },
{ label: "App log path", value: appLogs?.logPath || "unavailable" },
{ label: "Error", value: status.error ?? "none" },
]),
formatLogTailSection("Desktop daemon log tail", daemonLogs.contents),
];
}
function formatLogTailSection(title: string, contents: string): string {
return [title, contents ? indentBlock(contents) : " No log lines found"].join("\n");
}
function indentBlock(value: string): string {
return value
.split("\n")
.filter(Boolean)
.map((line) => ` ${line}`)
.join("\n");
}
function toMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}