From 7a86bc178f794cb31ae92c5831e8f021b32188d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 10 Aug 2026 13:48:38 +0200 Subject: [PATCH 1/5] feat(ui): add server connectivity diagnostics Add a shared Settings Info card for the effective CodeNomad listener mode, bind host, local and remote URLs, workspace root, and candidate network addresses. Keep the Remote Access shortcut capability-gated so the same view remains useful in Electron, Tauri, and remote web sessions without exposing mutable server arguments. Clarify that per-workspace PID and port details belong to the OpenCode process, extend downloadable diagnostics with connectivity data and a privacy notice, and use the existing clipboard fallback for insecure remote contexts. Failed metadata requests now release the shared pending request so refresh can recover. Add focused formatter and retry regression tests, run them in PR validation, and translate all new labels across the nine supported locales. --- .github/workflows/pr-build.yml | 2 + .../info-settings-diagnostics.test.ts | 55 ++++++ .../settings/info-settings-diagnostics.ts | 41 +++++ .../settings/info-settings-section.tsx | 168 ++++++++++++------ .../ui/src/lib/i18n/messages/de/instance.ts | 2 +- .../ui/src/lib/i18n/messages/de/settings.ts | 10 ++ .../ui/src/lib/i18n/messages/en/instance.ts | 2 +- .../ui/src/lib/i18n/messages/en/settings.ts | 10 ++ .../ui/src/lib/i18n/messages/es/instance.ts | 2 +- .../ui/src/lib/i18n/messages/es/settings.ts | 10 ++ .../ui/src/lib/i18n/messages/fr/instance.ts | 2 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 10 ++ .../ui/src/lib/i18n/messages/he/instance.ts | 2 +- .../ui/src/lib/i18n/messages/he/settings.ts | 10 ++ .../ui/src/lib/i18n/messages/ja/instance.ts | 2 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 10 ++ .../ui/src/lib/i18n/messages/ne/instance.ts | 2 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 10 ++ .../ui/src/lib/i18n/messages/ru/instance.ts | 2 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 10 ++ .../src/lib/i18n/messages/zh-Hans/instance.ts | 2 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 10 ++ packages/ui/src/lib/server-meta.test.ts | 35 ++++ packages/ui/src/lib/server-meta.ts | 10 +- .../src/styles/components/settings-info.css | 2 + 25 files changed, 355 insertions(+), 66 deletions(-) create mode 100644 packages/ui/src/components/settings/info-settings-diagnostics.test.ts create mode 100644 packages/ui/src/components/settings/info-settings-diagnostics.ts create mode 100644 packages/ui/src/lib/server-meta.test.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 05318b3a1..86f5d91ba 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -105,11 +105,13 @@ jobs: run: >- node --import tsx --test packages/ui/src/components/session-list-visibility.test.ts + packages/ui/src/components/settings/info-settings-diagnostics.test.ts packages/ui/src/components/unified-picker-path.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts packages/ui/src/lib/hooks/use-foreground-refresh.test.ts packages/ui/src/lib/launch-errors.test.ts packages/ui/src/lib/message-selection-position.test.ts + packages/ui/src/lib/server-meta.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts diff --git a/packages/ui/src/components/settings/info-settings-diagnostics.test.ts b/packages/ui/src/components/settings/info-settings-diagnostics.test.ts new file mode 100644 index 000000000..8c4635f45 --- /dev/null +++ b/packages/ui/src/components/settings/info-settings-diagnostics.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import type { ServerMeta } from "../../../../server/src/api-types" +import { buildDiagnosticReport } from "./info-settings-diagnostics" + +const meta: ServerMeta = { + localUrl: "http://127.0.0.1:9899", + remoteUrl: "https://192.168.1.20:9898", + eventsUrl: "http://127.0.0.1:9899/api/events", + host: "0.0.0.0", + listeningMode: "all", + localPort: 9899, + remotePort: 9898, + hostLabel: "0.0.0.0", + workspaceRoot: "/home/user/projects", + addresses: [ + { ip: "192.168.1.20", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.20:9898" }, + { ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" }, + ], + serverVersion: "1.2.3", + ui: { version: "1.2.3", source: "bundled" }, +} + +describe("buildDiagnosticReport", () => { + it("includes effective connectivity details and candidate addresses", () => { + const report = buildDiagnosticReport( + meta, + "Linux x86_64", + { host: "tauri", platform: "desktop", windowContext: "local" }, + new Date("2026-08-10T12:00:00.000Z"), + ) + + assert.match(report, /Generated: 2026-08-10T12:00:00\.000Z/) + assert.match(report, /Listening mode: all/) + assert.match(report, /Bind host: 0\.0\.0\.0/) + assert.match(report, /Local URL: http:\/\/127\.0\.0\.1:9899/) + assert.match(report, /Remote URL: https:\/\/192\.168\.1\.20:9898/) + assert.match(report, /Candidate addresses: 2/) + assert.match(report, /ipv4\/external: https:\/\/192\.168\.1\.20:9898/) + }) + + it("uses explicit fallbacks when server metadata is unavailable", () => { + const report = buildDiagnosticReport( + null, + "Unknown", + { host: "web", platform: "web", windowContext: "remote" }, + new Date("2026-08-10T12:00:00.000Z"), + ) + + assert.match(report, /Server version: unknown/) + assert.match(report, /Remote URL: none/) + assert.match(report, /Candidate addresses: 0/) + }) +}) diff --git a/packages/ui/src/components/settings/info-settings-diagnostics.ts b/packages/ui/src/components/settings/info-settings-diagnostics.ts new file mode 100644 index 000000000..3657297c8 --- /dev/null +++ b/packages/ui/src/components/settings/info-settings-diagnostics.ts @@ -0,0 +1,41 @@ +import type { ServerMeta } from "../../../../server/src/api-types" + +export interface DiagnosticRuntime { + host: string + platform: string + windowContext: string +} + +export function buildDiagnosticReport( + meta: ServerMeta | null, + osDisplay: string, + runtime: DiagnosticRuntime, + generatedAt = new Date(), +): string { + const lines = [ + "CodeNomad Diagnostic Report", + "============================", + `Generated: ${generatedAt.toISOString()}`, + `Server version: ${meta?.serverVersion ?? "unknown"}`, + `UI version: ${meta?.ui?.version ?? "unknown"} (source: ${meta?.ui?.source ?? "unknown"})`, + `Runtime: ${runtime.host}`, + `Platform: ${runtime.platform}`, + `Window context: ${runtime.windowContext}`, + `OS: ${osDisplay}`, + `Listening mode: ${meta?.listeningMode ?? "unknown"}`, + `Bind host: ${meta?.host ?? "unknown"}`, + `Local URL: ${meta?.localUrl ?? "unknown"}`, + `Local port: ${meta?.localPort ?? "unknown"}`, + `Remote URL: ${meta?.remoteUrl ?? "none"}`, + `Remote port: ${meta?.remotePort ?? "none"}`, + `Workspace root: ${meta?.workspaceRoot ?? "unknown"}`, + `Candidate addresses: ${meta?.addresses.length ?? 0}`, + ] + + for (const address of meta?.addresses ?? []) { + lines.push(`- ${address.family}/${address.scope}: ${address.remoteUrl}`) + } + + lines.push("") + return lines.join("\n") +} diff --git a/packages/ui/src/components/settings/info-settings-section.tsx b/packages/ui/src/components/settings/info-settings-section.tsx index cc1f19371..fa22a4e8c 100644 --- a/packages/ui/src/components/settings/info-settings-section.tsx +++ b/packages/ui/src/components/settings/info-settings-section.tsx @@ -1,9 +1,11 @@ -import { createEffect, createMemo, createResource, createSignal, onCleanup, type Component } from "solid-js" -import { Info } from "lucide-solid" +import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show, type Component } from "solid-js" +import { Info, Network } from "lucide-solid" +import { copyToClipboard } from "../../lib/clipboard" import { useI18n } from "../../lib/i18n" import { getServerMeta } from "../../lib/server-meta" -import { runtimeEnv } from "../../lib/runtime-env" -import type { ServerMeta } from "../../../../server/src/api-types" +import { canOpenRemoteWindows, runtimeEnv } from "../../lib/runtime-env" +import { openSettings } from "../../stores/settings-screen" +import { buildDiagnosticReport } from "./info-settings-diagnostics" interface UserAgentData { platform?: string @@ -62,36 +64,6 @@ async function resolveArchitecture(): Promise { } } -function buildDiagnosticReport( - meta: ServerMeta | null, - osDisplay: string, -): string { - const lines: string[] = [] - lines.push("CodeNomad Diagnostic Report") - lines.push("============================") - lines.push(`Generated: ${new Date().toISOString()}`) - lines.push(`Server version: ${meta?.serverVersion ?? "unknown"}`) - lines.push(`UI version: ${meta?.ui?.version ?? "unknown"} (source: ${meta?.ui?.source ?? "unknown"})`) - lines.push(`Runtime: ${runtimeEnv.host}`) - lines.push(`Platform: ${runtimeEnv.platform}`) - lines.push(`Window context: ${runtimeEnv.windowContext}`) - lines.push(`OS: ${osDisplay}`) - lines.push(`Server URL: ${meta?.localUrl ?? "unknown"}`) - lines.push(`Workspace root: ${meta?.workspaceRoot ?? "unknown"}`) - lines.push(`UI source: ${meta?.ui?.source ?? "unknown"}`) - lines.push("") - return lines.join("\n") -} - -async function copyToClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text) - return true - } catch { - return false - } -} - function downloadTextFile(filename: string, text: string) { const blob = new Blob([text], { type: "text/plain;charset=utf-8" }) const url = URL.createObjectURL(blob) @@ -122,7 +94,16 @@ function versionNewer(current: string, latest: string): boolean | null { export const InfoSettingsSection: Component = () => { const { t } = useI18n() - const [meta, { mutate }] = createResource(() => getServerMeta()) + const [metaLoadFailed, setMetaLoadFailed] = createSignal(false) + const [meta, { mutate }] = createResource(async () => { + setMetaLoadFailed(false) + try { + return await getServerMeta() + } catch { + setMetaLoadFailed(true) + return null + } + }) const [copyFeedback, setCopyFeedback] = createSignal<"success" | "error" | null>(null) const [osArch, setOsArch] = createSignal(null) @@ -171,8 +152,13 @@ export const InfoSettingsSection: Component = () => { onCleanup(() => clearTimeout(feedbackTimer)) const handleRefresh = async () => { - const fresh = await getServerMeta(true) - mutate(fresh) + setMetaLoadFailed(false) + try { + const fresh = await getServerMeta(true) + mutate(fresh) + } catch { + setMetaLoadFailed(true) + } } const osDisplay = createMemo(() => { @@ -182,14 +168,14 @@ export const InfoSettingsSection: Component = () => { }) const handleCopy = async () => { - const report = buildDiagnosticReport(meta() ?? null, osDisplay()) + const report = buildDiagnosticReport(meta() ?? null, osDisplay(), runtimeEnv) const ok = await copyToClipboard(report) if (ok) setCopyFeedback("success") else setCopyFeedback("error") } const handleDownload = () => { - const report = buildDiagnosticReport(meta() ?? null, osDisplay()) + const report = buildDiagnosticReport(meta() ?? null, osDisplay(), runtimeEnv) const ts = new Date().toISOString().replace(/[:.]/g, "-") downloadTextFile(`codenomad-diagnostics-${ts}.txt`, report) } @@ -208,10 +194,6 @@ export const InfoSettingsSection: Component = () => {
-
- {t("settings.info.version.server")} - {meta()?.serverVersion ?? "—"} -
{t("settings.info.version.ui")} {meta()?.ui?.version ?? "—"} @@ -234,19 +216,95 @@ export const InfoSettingsSection: Component = () => { {t("settings.info.runtime.os")} {osDisplay()}
-
- {t("settings.info.server.url")} - - {meta()?.localUrl ?? "—"} - -
-
- {t("settings.info.server.root")} - - {meta()?.workspaceRoot ?? "—"} - +
+
+ +
+
+
+ +
+

{t("settings.info.connectivity.title")}

+

{t("settings.info.connectivity.subtitle")}

+
+ + +
+ {t("remoteAccess.addresses.loading")} +
+
+ + + + + {(serverMeta) => ( + <> +
+
+
{t("settings.info.version.server")}
+
{serverMeta().serverVersion ?? "—"}
+
+
+
{t("remoteAccess.sections.listeningMode.label")}
+
+ {t(serverMeta().listeningMode === "all" ? "settings.info.connectivity.mode.all" : "settings.info.connectivity.mode.local")} +
+
+
+
{t("settings.info.connectivity.host")}
+
{serverMeta().host}
+
+
+
{t("settings.info.connectivity.localListener")}
+
{serverMeta().localUrl}
+
+ + {(remoteUrl) => ( +
+
{t("settings.info.connectivity.remoteListener")}
+
{remoteUrl()}
+
+ )} +
+
+
{t("settings.info.server.root")}
+
{serverMeta().workspaceRoot}
+
+
+ +

{t("remoteAccess.sections.addresses.label")}

+ 0} fallback={
{t("remoteAccess.addresses.none")}
}> +
+ {(address) => ( +
+
+ {address.family.toUpperCase()} · {t(address.scope === "external" + ? "remoteAccess.address.scope.network" + : address.scope === "internal" + ? "remoteAccess.address.scope.internal" + : "remoteAccess.address.scope.loopback")} +
+
{address.remoteUrl}
+
+ )}
+
+
+

{t("settings.info.connectivity.disclaimer")}

+ + +
+ +
+
+ + )} +
@@ -300,6 +358,8 @@ export const InfoSettingsSection: Component = () => {
+
{t("settings.info.diagnostics.privacy")}
+