diff --git a/.changeset/calm-desktops-recover.md b/.changeset/calm-desktops-recover.md new file mode 100644 index 00000000..3a2e6a17 --- /dev/null +++ b/.changeset/calm-desktops-recover.md @@ -0,0 +1,5 @@ +--- +"@coder-studio/desktop": patch +--- + +Recover authentication after Electron Network Service restarts without bypassing WebSocket reconnect backoff, and auto-hide the native application menu bar. diff --git a/.changeset/clear-updates-report.md b/.changeset/clear-updates-report.md new file mode 100644 index 00000000..38216cb8 --- /dev/null +++ b/.changeset/clear-updates-report.md @@ -0,0 +1,5 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Improve product update version and diagnostics UI, and let Desktop web clients request authentication recovery while reconnecting. diff --git a/packages/desktop/src/desktop-auth-recovery.test.ts b/packages/desktop/src/desktop-auth-recovery.test.ts new file mode 100644 index 00000000..24eb11fb --- /dev/null +++ b/packages/desktop/src/desktop-auth-recovery.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DesktopAuthRecoveryCoordinator, + isDesktopNetworkService, +} from "./desktop-auth-recovery.js"; + +describe("Desktop authentication recovery", () => { + it("recognizes Electron Network Service exits without matching unrelated utility processes", () => { + expect( + isDesktopNetworkService({ + type: "Utility", + serviceName: "network.mojom.NetworkService", + }) + ).toBe(true); + expect(isDesktopNetworkService({ type: "Utility", name: "Network Service" })).toBe(true); + expect(isDesktopNetworkService({ type: "Utility", name: "Audio Service" })).toBe(false); + expect(isDesktopNetworkService({ type: "GPU", name: "Network Service" })).toBe(false); + }); + + it("coalesces concurrent recovery requests and notifies once", async () => { + let finishAuthentication: ((result: "recovered") => void) | undefined; + const authenticate = vi.fn( + () => + new Promise<"recovered">((resolve) => { + finishAuthentication = resolve; + }) + ); + const onRecovered = vi.fn(); + const coordinator = new DesktopAuthRecoveryCoordinator({ + canRecover: () => true, + authenticate, + onRecovered, + retryDelaysMs: [], + }); + + const first = coordinator.recover(); + const second = coordinator.recover(); + expect(first).toBe(second); + expect(authenticate).toHaveBeenCalledTimes(1); + + finishAuthentication?.("recovered"); + await expect(first).resolves.toBe(true); + expect(onRecovered).toHaveBeenCalledTimes(1); + }); + + it("retries transient authentication failures before notifying the renderer", async () => { + const authenticate = vi + .fn<() => Promise<"recovered">>() + .mockRejectedValueOnce(new Error("network service restarting")) + .mockResolvedValueOnce("recovered"); + const onRecovered = vi.fn(); + const onAttemptFailure = vi.fn(); + const wait = vi.fn(async () => undefined); + const coordinator = new DesktopAuthRecoveryCoordinator({ + canRecover: () => true, + authenticate, + onRecovered, + onAttemptFailure, + retryDelaysMs: [250], + wait, + }); + + await expect(coordinator.recover()).resolves.toBe(true); + expect(authenticate).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledWith(250); + expect(onAttemptFailure).toHaveBeenCalledWith(expect.any(Error), 1, true); + expect(onRecovered).toHaveBeenCalledTimes(1); + }); + + it("keeps the normal WebSocket backoff when authentication is already valid", async () => { + const onRecovered = vi.fn(); + const coordinator = new DesktopAuthRecoveryCoordinator({ + canRecover: () => true, + authenticate: vi.fn(async () => "already_authenticated" as const), + onRecovered, + retryDelaysMs: [], + }); + + await expect(coordinator.recover()).resolves.toBe(true); + expect(onRecovered).not.toHaveBeenCalled(); + }); + + it("notifies after a Network Service restart even when authentication remains valid", async () => { + const onRecovered = vi.fn(); + const coordinator = new DesktopAuthRecoveryCoordinator({ + canRecover: () => true, + authenticate: vi.fn(async () => "already_authenticated" as const), + onRecovered, + retryDelaysMs: [], + }); + + await expect(coordinator.recover({ notifyWhenAlreadyAuthenticated: true })).resolves.toBe(true); + expect(onRecovered).toHaveBeenCalledTimes(1); + }); + + it("preserves a Network Service notification request while recovery is in flight", async () => { + let finishAuthentication: ((result: "already_authenticated") => void) | undefined; + const onRecovered = vi.fn(); + const coordinator = new DesktopAuthRecoveryCoordinator({ + canRecover: () => true, + authenticate: vi.fn( + () => + new Promise<"already_authenticated">((resolve) => { + finishAuthentication = resolve; + }) + ), + onRecovered, + retryDelaysMs: [], + }); + + const reconnectRecovery = coordinator.recover(); + const networkServiceRecovery = coordinator.recover({ notifyWhenAlreadyAuthenticated: true }); + expect(reconnectRecovery).toBe(networkServiceRecovery); + + finishAuthentication?.("already_authenticated"); + await expect(reconnectRecovery).resolves.toBe(true); + expect(onRecovered).toHaveBeenCalledTimes(1); + }); + + it("stops retrying when the Desktop environment begins shutting down", async () => { + let recoverable = true; + const authenticate = vi.fn(async (): Promise<"recovered"> => { + recoverable = false; + throw new Error("shutting down"); + }); + const onRecovered = vi.fn(); + const coordinator = new DesktopAuthRecoveryCoordinator({ + canRecover: () => recoverable, + authenticate, + onRecovered, + retryDelaysMs: [250, 1_000], + wait: vi.fn(async () => undefined), + }); + + await expect(coordinator.recover()).resolves.toBe(false); + expect(authenticate).toHaveBeenCalledTimes(1); + expect(onRecovered).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/desktop/src/desktop-auth-recovery.ts b/packages/desktop/src/desktop-auth-recovery.ts new file mode 100644 index 00000000..44298899 --- /dev/null +++ b/packages/desktop/src/desktop-auth-recovery.ts @@ -0,0 +1,83 @@ +export interface DesktopChildProcessGoneDetails { + type: string; + name?: string; + serviceName?: string; +} + +export function isDesktopNetworkService(details: DesktopChildProcessGoneDetails): boolean { + if (details.type !== "Utility") return false; + return ( + details.name === "Network Service" || details.serviceName === "network.mojom.NetworkService" + ); +} + +export interface DesktopAuthRecoveryOptions { + canRecover(): boolean; + authenticate(): Promise<"already_authenticated" | "recovered">; + onRecovered(): void; + onAttemptFailure?(error: unknown, attempt: number, willRetry: boolean): void; + retryDelaysMs?: readonly number[]; + wait?(delayMs: number): Promise; +} + +export interface DesktopAuthRecoveryRequest { + notifyWhenAlreadyAuthenticated?: boolean; +} + +const DEFAULT_RETRY_DELAYS_MS = [250, 1_000, 3_000] as const; + +const waitForDelay = (delayMs: number) => + new Promise((resolve) => setTimeout(resolve, delayMs)); + +export class DesktopAuthRecoveryCoordinator { + private inFlight: Promise | null = null; + private notifyWhenAlreadyAuthenticated = false; + + constructor(private readonly options: DesktopAuthRecoveryOptions) {} + + recover(request: DesktopAuthRecoveryRequest = {}): Promise { + if (request.notifyWhenAlreadyAuthenticated) { + this.notifyWhenAlreadyAuthenticated = true; + } + if (this.inFlight) return this.inFlight; + if (!this.options.canRecover()) { + this.notifyWhenAlreadyAuthenticated = false; + return Promise.resolve(false); + } + + const recovery = this.run().finally(() => { + if (this.inFlight === recovery) { + this.inFlight = null; + this.notifyWhenAlreadyAuthenticated = false; + } + }); + this.inFlight = recovery; + return recovery; + } + + private async run(): Promise { + const retryDelays = this.options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS; + const wait = this.options.wait ?? waitForDelay; + + for (let attemptIndex = 0; attemptIndex <= retryDelays.length; attemptIndex += 1) { + if (!this.options.canRecover()) return false; + if (attemptIndex > 0) { + await wait(retryDelays[attemptIndex - 1] as number); + if (!this.options.canRecover()) return false; + } + + try { + const result = await this.options.authenticate(); + if (!this.options.canRecover()) return false; + if (result === "recovered" || this.notifyWhenAlreadyAuthenticated) { + this.options.onRecovered(); + } + return true; + } catch (error) { + this.options.onAttemptFailure?.(error, attemptIndex + 1, attemptIndex < retryDelays.length); + } + } + + return false; + } +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 255f7580..45a2f23a 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -24,6 +24,10 @@ import { import { autoUpdater, CancellationToken } from "electron-updater"; import { BackendManager } from "./backend-manager.js"; import { readDesktopBuildInfo } from "./build-info.js"; +import { + DesktopAuthRecoveryCoordinator, + isDesktopNetworkService, +} from "./desktop-auth-recovery.js"; import { parseDesktopChannel, resolveDesktopChannelUrl, @@ -93,6 +97,45 @@ let shutdownComplete = false; let shutdownStarted = false; let shutdownActivationPromise: Promise = Promise.resolve(); const smokeResultPath = process.env.CODER_STUDIO_DESKTOP_SMOKE_RESULT?.trim() || null; +const desktopAuthRecovery = new DesktopAuthRecoveryCoordinator({ + canRecover: () => + !shutdownStarted && + backendManager?.getStatus()?.source === "managed" && + activeSession !== null && + activeGatewayUrl !== null, + authenticate: async () => { + const manager = backendManager; + const browserSession = activeSession; + const gatewayUrl = activeGatewayUrl; + if (!manager || !browserSession || !gatewayUrl) { + throw new Error("Desktop authentication recovery is not ready"); + } + + try { + const response = await browserSession.fetch(`${gatewayUrl}/auth/status`); + if (response.ok) { + const status = (await response.json()) as { authenticated?: unknown }; + if (status.authenticated === true) return "already_authenticated"; + } + } catch { + // A newly relaunched Network Service may fail its first request. The login below + // and the coordinator retry schedule provide the recovery path. + } + + await manager.authenticatePublicSession(browserSession, gatewayUrl); + return "recovered"; + }, + onRecovered: () => { + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) return; + mainWindow.webContents.send("desktop:authentication-recovered"); + }, + onAttemptFailure: (error, attempt, willRetry) => { + console.warn( + `[desktop-auth] Recovery attempt ${attempt} failed${willRetry ? "; retrying" : ""}`, + error + ); + }, +}); const environmentActivation = new EnvironmentActivationCoordinator({ focusWindow: () => { if (shutdownStarted || !mainWindow) return false; @@ -261,6 +304,7 @@ function registerIpcHandlers(rootUserDataDir: string): void { typeof value === "string" ? openExternal(value) : false ); ipcMain.handle("desktop:get-backend-status", () => backendManager?.getStatus() ?? null); + ipcMain.handle("desktop:recover-authentication", () => desktopAuthRecovery.recover()); ipcMain.handle("desktop:get-window-activity-state", () => readDesktopWindowActivityState(mainWindow) ); @@ -441,6 +485,7 @@ function createMainWindow(url: string, browserSession = activeSession): BrowserW height: 900, minWidth: 960, minHeight: 640, + autoHideMenuBar: process.platform !== "darwin", show: false, backgroundColor: "#111318", webPreferences: { @@ -868,6 +913,11 @@ if (!hasSingleInstanceLock) { }); }); + app.on("child-process-gone", (_event, details) => { + if (!isDesktopNetworkService(details)) return; + void desktopAuthRecovery.recover({ notifyWhenAlreadyAuthenticated: true }); + }); + app.whenReady().then(startApplication).catch(handleStartupFailure); } diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 99e25db8..e51952f9 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -18,6 +18,13 @@ const api: DesktopApi = { openExternal: (url: string) => ipcRenderer.invoke("desktop:open-external", url), getBackendStatus: () => ipcRenderer.invoke("desktop:get-backend-status") as Promise, + recoverAuthentication: () => + ipcRenderer.invoke("desktop:recover-authentication") as Promise, + onAuthenticationRecovered: (listener: () => void) => { + const handler = () => listener(); + ipcRenderer.on("desktop:authentication-recovered", handler); + return () => ipcRenderer.removeListener("desktop:authentication-recovered", handler); + }, getWindowActivityState: () => ipcRenderer.invoke("desktop:get-window-activity-state") as Promise, onWindowActivityStateChanged: (listener: (state: DesktopWindowActivityState) => void) => { diff --git a/packages/desktop/src/protocol.ts b/packages/desktop/src/protocol.ts index 3cf64ca6..9f12b149 100644 --- a/packages/desktop/src/protocol.ts +++ b/packages/desktop/src/protocol.ts @@ -83,6 +83,8 @@ export interface DesktopApi { selectWorkspaceDirectory(): Promise; openExternal(url: string): Promise; getBackendStatus(): Promise; + recoverAuthentication(): Promise; + onAuthenticationRecovered(listener: () => void): () => void; getWindowActivityState(): Promise; onWindowActivityStateChanged(listener: (state: DesktopWindowActivityState) => void): () => void; listEnvironments(): Promise; diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx index b5b90ed3..14a9bedf 100644 --- a/packages/web/src/app/providers.lifecycle.test.tsx +++ b/packages/web/src/app/providers.lifecycle.test.tsx @@ -922,6 +922,41 @@ describe("AppProviders lifecycle recovery", () => { }); }); + it("asks Desktop to restore authentication and retries immediately after recovery", async () => { + let authenticationRecovered: (() => void) | undefined; + const recoverAuthentication = vi.fn(async () => true); + const unsubscribe = vi.fn(); + Object.defineProperty(window, "coderStudioDesktop", { + configurable: true, + value: { + recoverAuthentication, + onAuthenticationRecovered: vi.fn((listener: () => void) => { + authenticationRecovered = listener; + return unsubscribe; + }), + } as unknown as CoderStudioDesktopApi, + }); + + const rendered = renderProviders(); + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + wsState.client?.recoverConnection.mockClear(); + + act(() => { + wsState.client?.statusHandler?.("reconnecting"); + }); + await vi.waitFor(() => { + expect(recoverAuthentication).toHaveBeenCalledTimes(1); + }); + + act(() => authenticationRecovered?.()); + expect(wsState.client?.recoverConnection).toHaveBeenCalledWith("manual_retry"); + + rendered.unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + it("probes and reconciles on visibility return instead of forcing replay semantics", async () => { const probeConnection = vi.fn().mockResolvedValue({ ok: true }); const sendCommand = createWsSendCommandMock((op) => { diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index d2ca12cd..bcf0d608 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -731,6 +731,9 @@ export function AppProviders({ children }: AppProvidersProps) { store.set(productUpdateStateAtom, null); store.set(updateControllerAtom, null); store.set(updatePreparationAtom, null); + void window.coderStudioDesktop?.recoverAuthentication?.().catch((error) => { + console.error("Desktop authentication recovery failed:", error); + }); } // Reset writer status on disconnect @@ -886,6 +889,12 @@ export function AppProviders({ children }: AppProvidersProps) { }); }; + const unsubscribeDesktopAuthenticationRecovery = + window.coderStudioDesktop?.onAuthenticationRecovered?.(() => { + if (store.get(activationStatusAtom) === "gated") return; + wsClientRef.current?.recoverConnection("manual_retry"); + }) ?? (() => {}); + const refreshBranchState = (workspaceId: string) => { dispatchRef .current<{ current: string; branches: GitBranch[] }>("git.branches", { workspaceId }) @@ -1074,6 +1083,7 @@ export function AppProviders({ children }: AppProvidersProps) { window.removeEventListener("focus", handleWindowFocus); window.removeEventListener("pageshow", handlePageShow); window.removeEventListener("online", handleOnline); + unsubscribeDesktopAuthenticationRecovery(); unsubscribeStatus(); unsubscribeEvents(); refreshTimersRef.current.forEach((timer) => clearTimeout(timer)); @@ -1135,6 +1145,7 @@ export function AppProviders({ children }: AppProvidersProps) { window.removeEventListener("focus", handleWindowFocus); window.removeEventListener("pageshow", handlePageShow); window.removeEventListener("online", handleOnline); + unsubscribeDesktopAuthenticationRecovery(); unsubscribeStatus(); unsubscribeEvents(); refreshTimersRef.current.forEach((timer) => clearTimeout(timer)); diff --git a/packages/web/src/desktop-api.d.ts b/packages/web/src/desktop-api.d.ts index 0fc5cb6a..9d938de1 100644 --- a/packages/web/src/desktop-api.d.ts +++ b/packages/web/src/desktop-api.d.ts @@ -10,6 +10,9 @@ interface CoderStudioDesktopApi { pid: number | null; } | null>; // Optional while newer Web bundles can still be paired with an older Desktop shell. + recoverAuthentication?(): Promise; + onAuthenticationRecovered?(listener: () => void): () => void; + // Optional while newer Web bundles can still be paired with an older Desktop shell. getWindowActivityState?(): Promise; onWindowActivityStateChanged?(listener: (state: DesktopWindowActivityState) => void): () => void; listEnvironments(): Promise; diff --git a/packages/web/src/features/settings/components/about-settings.test.tsx b/packages/web/src/features/settings/components/about-settings.test.tsx index 19a12ca9..cf39a449 100644 --- a/packages/web/src/features/settings/components/about-settings.test.tsx +++ b/packages/web/src/features/settings/components/about-settings.test.tsx @@ -185,11 +185,21 @@ describe("AboutSettings unified updates", () => { renderAbout(); expect(screen.getByTestId("product-version")).toHaveTextContent("v0.6.0"); expect(screen.queryByText("Shell v0.2.0 → v0.3.0")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Component diagnostics" })); + const diagnosticsButton = screen.getByRole("button", { name: "Component diagnostics" }); + expect(diagnosticsButton).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(diagnosticsButton); + expect(diagnosticsButton).toHaveAttribute("aria-expanded", "true"); expect(screen.getByText("Shell v0.2.0 → v0.3.0")).toBeInTheDocument(); - expect(screen.getByText(/Authority: desktop/)).toBeInTheDocument(); - expect(screen.getByText(/Environment: desktop-native/)).toBeInTheDocument(); - expect(screen.getByText(/Plan ID: plan-1/)).toBeInTheDocument(); + const diagnostics = screen.getByRole("region", { name: "Component diagnostics" }); + expect(diagnostics).toHaveTextContent("Authority: desktop"); + expect(diagnostics).toHaveTextContent("Environment: desktop-native"); + expect(diagnostics).toHaveTextContent("Plan ID: plan-1"); + }); + + it("shows the current product version as latest when no update is required", () => { + const state = desktopState({ status: "idle", components: [] }); + renderAbout({ state, controller: createController(state) }); + expect(screen.getByTestId("latest-version")).toHaveTextContent("v0.6.0"); }); it("renders trusted UTC release time locally and preserves unknown", () => { diff --git a/packages/web/src/features/settings/components/about-settings.tsx b/packages/web/src/features/settings/components/about-settings.tsx index 3d779b31..9520a031 100644 --- a/packages/web/src/features/settings/components/about-settings.tsx +++ b/packages/web/src/features/settings/components/about-settings.tsx @@ -1,5 +1,6 @@ import type { ProductUpdatePreparation, ProductUpdateState } from "@coder-studio/core"; import { useAtomValue, useSetAtom } from "jotai"; +import { ChevronDown, ChevronUp } from "lucide-react"; import { useId, useMemo, useState } from "react"; import { serverInfoAtom } from "../../../atoms/connection"; import { @@ -96,6 +97,21 @@ function versionTransition(component: ProductUpdateState["components"][number]): return `${componentLabel(component)} v${component.currentVersion}${target}`; } +interface DiagnosticItemProps { + label: string; + value: string | number; + wide?: boolean; +} + +function DiagnosticItem({ label, value, wide = false }: DiagnosticItemProps) { + return ( +
+
{label}:
+
{value}
+
+ ); +} + function environmentGuidance( state: ProductUpdateState, controller: UpdateController @@ -128,6 +144,8 @@ export function AboutSettings({ const autoCheckLabelId = useId(); const autoCheckDescId = useId(); const checkIntervalLabelId = useId(); + const diagnosticsLabelId = useId(); + const diagnosticsPanelId = useId(); const showProduct = view === "all" || view === "product"; const showUpdateStatus = view === "all" || view === "update-status"; const showAutoUpdate = view === "all" || view === "auto-update"; @@ -153,6 +171,7 @@ export function AboutSettings({ const targetVersion = updateState?.components.find( (component) => component.kind === "runtime" || component.kind === "cli" )?.targetVersion; + const latestVersion = targetVersion ?? updateState?.productVersion ?? serverInfo?.version; const progress = updateState?.components .map((component) => component.progressPercent) .filter((value): value is number => value !== null) @@ -260,7 +279,9 @@ export function AboutSettings({ {guidanceKey ? : null}
{t("settings.about.latest_version")} - {targetVersion ? `v${targetVersion}` : "-"} + + {latestVersion ? `v${latestVersion}` : "-"} +
{t("settings.about.last_checked")} @@ -323,58 +344,130 @@ export function AboutSettings({ ) : null} {updateState ? ( -
- -
- ) : null} - {updateState && diagnosticsOpen ? ( -
- {updateState.components.map((component) => ( -
- - {versionTransition(component)} - {component.progressPercent !== null ? ` · ${component.progressPercent}%` : ""} - {component.errorSummary ? ` · ${component.errorSummary}` : ""} - + {diagnosticsOpen ? ( +
+ {updateState.components.length > 0 ? ( +
+

+ {t("settings.about.component_versions")} +

+
+ {updateState.components.map((component) => ( +
+
+ + {versionTransition(component)} + + + + {t(`settings.about.product_status_${component.status}`)} + +
+ {component.progressPercent !== null || component.errorSummary ? ( +
+ {component.progressPercent !== null ? ( + + {t("settings.about.progress")}: {component.progressPercent}% + + ) : null} + {component.errorSummary ? ( + + {component.errorSummary} + + ) : null} +
+ ) : null} +
+ ))} +
+
+ ) : null} + +
+

+ {t("settings.about.update_context")} +

+
+ + + + {updateState.diagnostics.shellBuiltAt ? ( + + ) : null} + {updateState.diagnostics.engineVersion ? ( + + ) : null} + {updateState.diagnostics.nodeVersion ? ( + + ) : null} + {updateState.diagnostics.failedPhase ? ( + + ) : null} + {updateState.diagnostics.recoveryAction ? ( + + ) : null} +
+
+ + {updateState.diagnostics.logLocations.length > 0 ? ( +
+

+ {t("settings.about.diagnostic_paths")} +

+
+ {updateState.diagnostics.logLocations.map((location) => ( + {location} + ))} +
+
+ ) : null}
- ))} -

- {t("settings.about.authority")}: {updateState.runtimeContext.authority} -

-

- {t("settings.about.environment")}: {updateState.runtimeContext.environment} -

-

- {t("settings.about.plan_id")}: {updateState.planId ?? "-"} -

- {updateState.diagnostics.shellBuiltAt ? ( -

- {t("settings.about.shell_built_at")}: {updateState.diagnostics.shellBuiltAt} -

- ) : null} - {updateState.diagnostics.engineVersion ? ( -

- {t("settings.about.engine_abi")}: {updateState.diagnostics.engineVersion} -

- ) : null} - {updateState.diagnostics.nodeVersion ? ( -

Node: {updateState.diagnostics.nodeVersion}

- ) : null} - {updateState.diagnostics.failedPhase ? ( -

- {t("settings.about.failed_phase")}: {updateState.diagnostics.failedPhase} -

- ) : null} - {updateState.diagnostics.recoveryAction ? ( -

- {t("settings.about.recovery_action")}: {updateState.diagnostics.recoveryAction} -

) : null} - {updateState.diagnostics.logLocations.map((location) => ( -

{location}

- ))}
) : null}
diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 9d353767..8a844253 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -1363,6 +1363,9 @@ "restart_later": "Restart later", "retry_update": "Retry", "component_diagnostics": "Component diagnostics", + "component_versions": "Component versions", + "update_context": "Update context", + "diagnostic_paths": "Diagnostic paths", "authority": "Authority", "environment": "Environment", "plan_id": "Plan ID", diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index 85afb821..77dd46fc 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -1375,6 +1375,9 @@ "restart_later": "稍后重启", "retry_update": "重试", "component_diagnostics": "组件诊断", + "component_versions": "组件版本", + "update_context": "更新上下文", + "diagnostic_paths": "诊断路径", "authority": "更新协调方", "environment": "运行环境", "plan_id": "更新计划 ID", diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index 94531d0a..f1733fcd 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -1337,6 +1337,175 @@ margin-top: var(--sp-3); } +.update-diagnostics { + margin-top: var(--sp-4); + overflow: hidden; + border: 1px solid var(--surface-elevated-border); + border-radius: var(--radius-xl); + background: var(--surface-elevated); + box-shadow: var(--shadow-sm); +} + +.update-diagnostics__toggle.btn { + width: 100%; + height: auto; + min-height: var(--control-height-md); + justify-content: space-between; + padding: var(--sp-3) var(--sp-4); + border-radius: var(--radius-xl); + color: var(--text-primary); + text-align: left; +} + +.update-diagnostics__toggle.btn:hover:not(:disabled) { + transform: none; +} + +.update-diagnostics--open .update-diagnostics__toggle.btn { + border-radius: var(--radius-xl) var(--radius-xl) 0 0; +} + +.update-diagnostics__body { + display: grid; + gap: var(--sp-5); + padding: var(--sp-4); + border-top: 1px solid var(--border-default); +} + +.update-diagnostics__section { + display: grid; + min-width: 0; + gap: var(--sp-2); +} + +.update-diagnostics__section-title { + margin: 0; + color: var(--text-secondary); + font-size: var(--type-body-6-size); + font-weight: var(--type-body-6-weight); + letter-spacing: 0.08em; + line-height: var(--type-body-6-line-height); + text-transform: uppercase; +} + +.update-diagnostics__components { + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--surface-muted); +} + +.update-diagnostics__component { + display: grid; + gap: var(--sp-2); + padding: var(--sp-3); + border-bottom: 1px solid var(--border-default); +} + +.update-diagnostics__component:last-child { + border-bottom: none; +} + +.update-diagnostics__component-main { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + flex-wrap: wrap; +} + +.update-diagnostics__component-version { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--type-body-5-size); + font-weight: var(--font-medium); + line-height: var(--type-body-5-line-height); + overflow-wrap: anywhere; +} + +.update-diagnostics__component-status { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + color: var(--text-secondary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); +} + +.update-diagnostics__component-meta { + display: flex; + gap: var(--sp-3); + flex-wrap: wrap; + color: var(--text-tertiary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); +} + +.update-diagnostics__component-error { + color: var(--status-danger-fg); +} + +.update-diagnostics__details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); + gap: var(--sp-2); + margin: 0; +} + +.update-diagnostics__detail { + display: grid; + min-width: 0; + gap: var(--sp-1); + padding: var(--sp-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--surface-muted); +} + +.update-diagnostics__detail--wide { + grid-column: 1 / -1; +} + +.update-diagnostics__detail dt { + color: var(--text-secondary); + font-size: var(--type-body-6-size); + font-weight: var(--type-body-6-weight); + letter-spacing: 0.04em; + line-height: var(--type-body-6-line-height); + text-transform: uppercase; +} + +.update-diagnostics__detail dd { + min-width: 0; + margin: 0; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + overflow-wrap: anywhere; +} + +.update-diagnostics__paths { + display: grid; + gap: var(--sp-2); +} + +.update-diagnostics__paths code { + display: block; + min-width: 0; + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--surface-muted); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: var(--type-body-6-size); + line-height: var(--type-body-5-line-height); + overflow-wrap: anywhere; + white-space: normal; +} + .settings-card { border: 1px solid var(--surface-elevated-border); background: var(--surface-elevated);