diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index ef23c71a9c..f3eb3a2cf8 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -38,6 +38,8 @@ import type { DaemonSessionSummary, DaemonSessionSupportedCommandsStatus, DaemonSessionStatsStatus, + DaemonStatusReport, + DaemonStatusReportDetail, DaemonSessionTaskStatus, DaemonSessionTasksStatus, DaemonUpdateAgentRequest, @@ -676,6 +678,21 @@ export class DaemonClient { ); } + /** + * Consolidated daemon status report (`GET /daemon/status`). The default + * `summary` detail reads cheap in-memory counters; `full` adds per-session, + * ACP-connection, auth, and workspace diagnostics sections. + */ + async daemonStatus( + detail: DaemonStatusReportDetail = 'summary', + ): Promise { + const query = detail === 'summary' ? '' : `?detail=${detail}`; + return await this.jsonRequest( + `/daemon/status${query}`, + 'GET /daemon/status', + ); + } + async workspaceMcp(): Promise { return await this.fetchWithTimeout( `${this.baseUrl}/workspace/mcp`, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 4243b6e02a..5dc5186fea 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -423,6 +423,12 @@ export type { DaemonPreflightKind, DaemonStatus, DaemonStatusCell, + DaemonStatusReport, + DaemonStatusReportDetail, + DaemonStatusReportIssue, + DaemonStatusReportLevel, + DaemonStatusReportSection, + DaemonStatusReportSession, DaemonUpdateAgentRequest, DaemonContentHash, DaemonWorkspaceAgentDetail, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index ee3b84039d..08629e0c59 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -130,6 +130,177 @@ export function requireWorkspaceCwd(caps: DaemonCapabilities): string { return caps.workspaceCwd; } +/** Detail level accepted by `GET /daemon/status?detail=`. */ +export type DaemonStatusReportDetail = 'summary' | 'full'; + +/** Overall health rollup of a daemon status report. */ +export type DaemonStatusReportLevel = 'ok' | 'warning' | 'error'; + +/** One triage finding surfaced by the daemon status rollup. */ +export interface DaemonStatusReportIssue { + code: string; + severity: 'warning' | 'error'; + message: string; + /** Status section the issue was derived from (e.g. `workspace.mcp`). */ + section?: string; +} + +/** + * One independently-degraded workspace diagnostics section in a + * `detail=full` status report (`full.workspace.`). `data` is the raw + * section payload (shape varies per section) — render `summary` instead. + */ +export interface DaemonStatusReportSection { + status: DaemonStatusReportLevel | 'unavailable'; + durationMs: number; + summary?: Record; + data?: unknown; + error?: { kind: 'timeout' | 'error'; message: string }; +} + +/** Per-session diagnostics row in a `detail=full` status report. */ +export interface DaemonStatusReportSession { + sessionId: string; + workspaceCwd: string; + createdAt: string; + displayName?: string; + clientCount: number; + subscriberCount: number; + attachCount: number; + pendingPromptCount: number; + pendingPermissionCount: number; + hasActivePrompt: boolean; + lastEventId: number; + lastSeenAt?: number; + currentModelId?: string; + currentApprovalMode?: string; +} + +/** + * Status report envelope returned from `GET /daemon/status`. Fields the + * daemon may add over time arrive as additive optional members, mirroring + * the `DaemonCapabilities` convention. + */ +export interface DaemonStatusReport { + v: 1; + detail: DaemonStatusReportDetail; + generatedAt: string; + status: DaemonStatusReportLevel; + issues: DaemonStatusReportIssue[]; + daemon: { + pid: number; + uptimeMs: number; + mode: DaemonMode; + workspaceCwd: string; + /** Startup timing/preheat snapshot; `preheat.status` is widened to string. */ + startup?: { + processStartedAt: string; + listenerReadyAt?: string; + processToListenMs?: number; + runQwenServeToListenMs?: number; + preheat: { status: string; durationMs?: number; error?: string }; + }; + qwenCodeVersion?: string; + daemonId?: string; + /** Present only in `detail=full` responses. */ + logPath?: string; + }; + security: { + tokenConfigured: boolean; + requireAuth: boolean; + loopbackBind: boolean; + allowOriginConfigured: boolean; + allowOriginMode: string; + sessionShellCommandEnabled: boolean; + }; + limits: { + maxSessions: number | null; + maxPendingPromptsPerSession: number | null; + listenerMaxConnections: number | null; + eventRingSize: number; + promptDeadlineMs: number | null; + writerIdleTimeoutMs: number | null; + channelIdleTimeoutMs: number; + sessionIdleTimeoutMs: number; + acpConnectionCap: number | null; + }; + capabilities: { + protocolVersions: DaemonProtocolVersions; + features: string[]; + }; + runtime: { + /** Present while the daemon runtime is still starting up. */ + loading?: boolean; + /** Present when the daemon runtime failed to start. */ + error?: string; + sessions: { active: number }; + permissions: { pending: number; policy: string }; + channel: { live: boolean }; + // Mirrors the daemon's ChannelWorkerSnapshot. `state` and `signal` are + // widened to string to avoid coupling the wire type to the daemon's unions. + channelWorker: { + enabled: boolean; + state: string; + channels: string[]; + requestedChannels?: string[]; + pid?: number; + startedAt?: string; + exitCode?: number | null; + signal?: string | null; + error?: string; + restartCount?: number; + lastExitAt?: string; + lastRestartAt?: string; + nextRestartAt?: string; + lastHeartbeatAt?: string; + staleHeartbeatAt?: string; + }; + transport: { + restSseActive: number; + acp: { + enabled: boolean; + connections: number; + connectionStreams: number; + sessionStreams: number; + sseStreams: number; + wsStreams: number; + pendingClientRequests: number; + }; + }; + rateLimit: { + enabled: boolean; + rejectedSinceStart: Record; + }; + /** + * Prompt/session activity counters. Optional because this is additive to + * v=1; daemons predating it omit the sub-object. `lastActivityAt`/ + * `idleSinceMs` are null when the daemon has seen no activity yet. + */ + activity?: { + activePrompts: number; + lastActivityAt: string | null; + idleSinceMs: number | null; + }; + process: { + rss: number; + heapTotal: number; + heapUsed: number; + external?: number; + arrayBuffers?: number; + }; + }; + /** Present only when requested with `detail=full`. */ + full?: { + sessions: DaemonStatusReportSession[]; + acpConnections: Array>; + workspace: Record; + auth: { + supportedDeviceFlowProviders: string[]; + pendingDeviceFlowCount: number; + }; + }; +} + /** Returned from `POST /session`. */ export interface DaemonSession { sessionId: string; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index e26c71bf05..a95b7896d6 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -143,6 +143,12 @@ export { type DaemonPreflightKind, type DaemonStatus, type DaemonStatusCell, + type DaemonStatusReport, + type DaemonStatusReportDetail, + type DaemonStatusReportIssue, + type DaemonStatusReportLevel, + type DaemonStatusReportSection, + type DaemonStatusReportSession, type DaemonWorkspaceEnvStatus, type DaemonWorkspaceFile, type DaemonWorkspaceFileBytes, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index c2bfac4eb3..859c59889f 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -148,6 +148,42 @@ describe('DaemonClient', () => { }); }); + describe('daemonStatus', () => { + it('GETs /daemon/status without a detail param by default', async () => { + const body = { v: 1, detail: 'summary', status: 'ok', issues: [] }; + const { fetch, calls } = recordingFetch(() => jsonResponse(200, body)); + const client = new DaemonClient({ + baseUrl: 'http://daemon', + token: 'secret', + fetch, + }); + const res = await client.daemonStatus(); + expect(res).toEqual(body); + expect(calls[0]?.url).toBe('http://daemon/daemon/status'); + expect(calls[0]?.method).toBe('GET'); + expect(calls[0]?.headers['authorization']).toBe('Bearer secret'); + }); + + it('GETs /daemon/status?detail=full when asked for full detail', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { v: 1, detail: 'full' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await client.daemonStatus('full'); + expect(calls[0]?.url).toBe('http://daemon/daemon/status?detail=full'); + }); + + it('throws DaemonHttpError on non-2xx', async () => { + const { fetch } = recordingFetch(() => + jsonResponse(500, { error: 'Failed to build daemon status' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect(client.daemonStatus()).rejects.toBeInstanceOf( + DaemonHttpError, + ); + }); + }); + describe('capabilities', () => { it('GETs /capabilities and returns the v1 envelope', async () => { const envelope = { diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts index 304483fd19..e7345c6abe 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -61,6 +61,12 @@ import type { DaemonSessionUpdateData, DaemonSessionUpdateEvent, DaemonSessionViewState, + DaemonStatusReport, + DaemonStatusReportDetail, + DaemonStatusReportIssue, + DaemonStatusReportLevel, + DaemonStatusReportSection, + DaemonStatusReportSession, DaemonStreamErrorData, DaemonStreamErrorEvent, DaemonStreamLifecycleEvent, @@ -227,6 +233,14 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => { expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); + // `GET /daemon/status` report surface (PR 5174 client coverage): the + // envelope plus the sub-shapes UI dashboards need to type against. + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); + expectTypeOf().not.toBeNever(); }); it('exposes the PR 21 auth device-flow surface at the public entry', () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 217886a127..b034ff0efa 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -61,6 +61,7 @@ import { import { MemoryMessage } from './components/messages/MemoryMessage'; import { AuthMessage } from './components/messages/AuthMessage'; import { ToolsDialog } from './components/dialogs/ToolsDialog'; +import { DaemonStatusDialog } from './components/dialogs/DaemonStatusDialog'; import { ExtensionsDialog } from './components/dialogs/ExtensionsDialog'; import { SettingsMessage } from './components/messages/SettingsMessage'; import { resolveShellOutputMaxLines } from './components/messages/ToolGroup'; @@ -1156,6 +1157,7 @@ export function App({ const [showHelpDialog, setShowHelpDialog] = useState(false); const [showThemeDialog, setShowThemeDialog] = useState(false); const [showToolsDialog, setShowToolsDialog] = useState(false); + const [showDaemonStatusDialog, setShowDaemonStatusDialog] = useState(false); const [showExtensionsDialog, setShowExtensionsDialog] = useState(false); const [mcpDialogMessage, setMcpDialogMessage] = useState(null); @@ -1360,6 +1362,7 @@ export function App({ showHelpDialog || showThemeDialog || showToolsDialog || + showDaemonStatusDialog || showExtensionsDialog || modelDialogMode !== null || showApprovalModeDialog || @@ -3554,6 +3557,15 @@ export function App({ )} + {showDaemonStatusDialog && ( + setShowDaemonStatusDialog(false)} + > + + + )} {showExtensionsDialog && ( { + closeMobileDrawer(); + setShowDaemonStatusDialog(true); + }} onNewSession={createNewSession} onLoadSession={loadSidebarSession} onError={reportError} diff --git a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.module.css b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.module.css new file mode 100644 index 0000000000..c52abdd6cd --- /dev/null +++ b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.module.css @@ -0,0 +1,276 @@ +.dialog { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 70vh; + overflow-y: auto; + font-size: 13px; + color: var(--foreground); +} + +.toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.toolbarActions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.updatedAt { + color: var(--muted-foreground); + font-size: 12px; +} + +.refreshError { + color: var(--error-color); + font-size: 12px; +} + +.refreshButton { + padding: 4px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--foreground); + font-size: 12px; + cursor: pointer; +} + +.refreshButton:disabled { + opacity: 0.5; + cursor: default; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + line-height: 1.5; +} + +.levelOk { + color: var(--success-color); + background: color-mix(in srgb, var(--success-color) 14%, transparent); +} + +.levelWarning { + color: var(--warning-color); + background: color-mix(in srgb, var(--warning-color) 14%, transparent); +} + +.levelError { + color: var(--error-color); + background: color-mix(in srgb, var(--error-color) 14%, transparent); +} + +.levelUnavailable { + color: var(--muted-foreground); + background: color-mix(in srgb, var(--muted-foreground) 14%, transparent); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.card { + border: 1px solid color-mix(in srgb, var(--border) 74%, transparent); + border-radius: 10px; + padding: 10px 14px; + min-width: 0; +} + +.cardTitle { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted-foreground); +} + +.row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 2px 0; + min-width: 0; +} + +.rowLabel { + color: var(--muted-foreground); +} + +.rowValue { + /* Keep short values (counts, "0 / 0 / 0", "first-responder") on one line; + the label wraps instead. Long path values opt back into wrapping via + .pathValue, which restores a small min-content width for this flex item. */ + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.pathRow { + display: flex; + flex-direction: column; + gap: 2px; + padding: 2px 0; + min-width: 0; +} + +.pathValue { + max-width: 100%; + font-family: var(--font-mono, monospace); + font-size: 12px; + /* Single line, ellipsis at the START so the tail (…/parent/workspace) + stays visible; the row widens/narrows with the card. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + direction: rtl; + text-align: left; +} + +.empty { + color: var(--muted-foreground); + padding: 4px 0; +} + +.issueRow { + display: flex; + align-items: baseline; + gap: 10px; + padding: 3px 0; +} + +.issueMessage { + overflow-wrap: anywhere; +} + +.featureChips { + display: flex; + flex-wrap: wrap; + gap: 4px 6px; + /* A long capability set shouldn't make this card tower over its neighbours; + cap the height and let it scroll. */ + max-height: 132px; + overflow-y: auto; +} + +.featureChip { + padding: 1px 7px; + border-radius: 5px; + background: color-mix(in srgb, var(--border) 40%, transparent); + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.5; + font-family: var(--font-mono, monospace); + white-space: nowrap; +} + +.summaryChip { + padding: 2px 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--border) 40%, transparent); + font-size: 12px; + font-family: var(--font-mono, monospace); + overflow-wrap: anywhere; +} + +.sessionRow { + padding: 6px 0; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.sessionRow:first-of-type { + border-top: none; +} + +.sessionName { + font-family: var(--font-mono, monospace); + font-size: 12px; + overflow-wrap: anywhere; +} + +.sessionMeta { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 2px; + color: var(--muted-foreground); + font-size: 12px; +} + +.activePrompt { + color: var(--warning-color); +} + +.workspaceRow { + padding: 6px 0; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.workspaceRow:first-of-type { + border-top: none; +} + +.workspaceRowHead { + display: flex; + align-items: center; + gap: 10px; +} + +.workspaceName { + font-weight: 600; +} + +.workspaceDuration { + margin-left: auto; + color: var(--muted-foreground); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.workspaceError { + margin-top: 2px; + color: var(--error-color); + font-size: 12px; + overflow-wrap: anywhere; +} + +.workspaceSummary { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +.workspaceCell { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; + font-size: 12px; +} + +.workspaceCellLabel { + font-family: var(--font-mono, monospace); + font-weight: 600; +} + +.workspaceCellMessage { + color: var(--muted-foreground); + overflow-wrap: anywhere; +} diff --git a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.test.tsx b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.test.tsx new file mode 100644 index 0000000000..442b0a4051 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.test.tsx @@ -0,0 +1,803 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nProvider } from '../../i18n'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const summaryReport = { + v: 1, + detail: 'summary', + generatedAt: '2026-07-03T08:00:00.000Z', + status: 'warning', + issues: [ + { + code: 'pending_permissions', + severity: 'warning', + message: '2 permission requests are waiting for a client response', + }, + ], + daemon: { + pid: 4242, + uptimeMs: 3_723_000, + mode: 'http-bridge', + workspaceCwd: '/work/demo', + qwenCodeVersion: '0.9.0', + }, + security: { + tokenConfigured: true, + requireAuth: false, + loopbackBind: true, + allowOriginConfigured: false, + allowOriginMode: 'default', + sessionShellCommandEnabled: false, + }, + limits: { + maxSessions: 8, + maxPendingPromptsPerSession: 5, + listenerMaxConnections: null, + eventRingSize: 1024, + promptDeadlineMs: 120_000, + writerIdleTimeoutMs: null, + channelIdleTimeoutMs: 60_000, + sessionIdleTimeoutMs: 300_000, + acpConnectionCap: null, + }, + capabilities: { + protocolVersions: { serve: 1 }, + features: ['daemon_status', 'session_events'], + }, + runtime: { + sessions: { active: 3 }, + permissions: { pending: 2, policy: 'vote' }, + channel: { live: true }, + channelWorker: { enabled: false, state: 'disabled', channels: [] }, + transport: { + restSseActive: 1, + acp: { + enabled: true, + connections: 2, + connectionStreams: 2, + sessionStreams: 1, + sseStreams: 1, + wsStreams: 0, + pendingClientRequests: 0, + }, + }, + // Real daemon rate-limit tiers (RateLimitTier = prompt | mutation | read). + rateLimit: { + enabled: true, + rejectedSinceStart: { prompt: 37, mutation: 3, read: 1 }, + }, + process: { + rss: 200 * 1024 * 1024, + heapTotal: 80 * 1024 * 1024, + heapUsed: 50 * 1024 * 1024, + }, + }, +}; + +const fullReport = { + ...summaryReport, + detail: 'full', + // The daemon rolls workspace/preflight problems into status + issues only for + // detail=full, so the full report is strictly more severe than the summary. + status: 'error', + issues: [ + ...summaryReport.issues, + { + code: 'preflight_error', + severity: 'error', + section: 'workspace.preflight', + message: 'preflight failed: node version too old', + }, + ], + full: { + sessions: [ + { + sessionId: 'sess-1', + workspaceCwd: '/work/demo', + createdAt: '2026-07-03T07:00:00.000Z', + displayName: 'My session', + clientCount: 2, + subscriberCount: 1, + attachCount: 1, + pendingPromptCount: 1, + pendingPermissionCount: 2, + hasActivePrompt: true, + lastEventId: 42, + }, + ], + acpConnections: [{ connectionId: 'conn-1' }], + workspace: { + mcp: { + status: 'ok', + durationMs: 12, + summary: { servers: 2, connected: 2 }, + }, + preflight: { + status: 'error', + durationMs: 30, + error: { kind: 'error', message: 'preflight exploded' }, + }, + }, + auth: { + supportedDeviceFlowProviders: ['qwen'], + pendingDeviceFlowCount: 0, + }, + }, +}; + +type HookState = { + report: unknown; + loading: boolean; + error: Error | undefined; +}; + +const summaryReload = vi.fn(async () => undefined); +const fullReload = vi.fn(async () => undefined); +let summaryState: HookState = { + report: summaryReport, + loading: false, + error: undefined, +}; +let fullState: HookState = { + report: fullReport, + loading: false, + error: undefined, +}; +const seenDetails: Array = []; + +vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ + useStatusReport: (options: { detail?: string } = {}) => { + seenDetails.push(options.detail); + if (options.detail === 'full') { + return { ...fullState, data: fullState.report, reload: fullReload }; + } + return { + ...summaryState, + data: summaryState.report, + reload: summaryReload, + }; + }, +})); + +const { DaemonStatusDialog } = await import('./DaemonStatusDialog'); + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +function mount(language: 'en' | 'zh-CN' = 'en') { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); +} + +// The toolbar status badge is the first span whose text is a level label; it +// renders before the issues card, so `find` returns the top badge. +function topBadgeText(): string | undefined { + return Array.from(container!.querySelectorAll('span')) + .map((s) => s.textContent?.trim() ?? '') + .find( + (label) => label === 'OK' || label === 'Warning' || label === 'Error', + ); +} + +beforeEach(() => { + summaryState = { report: summaryReport, loading: false, error: undefined }; + fullState = { report: fullReport, loading: false, error: undefined }; + seenDetails.length = 0; + summaryReload.mockReset(); + summaryReload.mockImplementation(async () => undefined); + fullReload.mockReset(); + fullReload.mockImplementation(async () => undefined); +}); + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + vi.useRealTimers(); +}); + +describe('DaemonStatusDialog', () => { + it('renders live summary counters with the full-detail rollup badge', () => { + mount(); + const text = container!.textContent ?? ''; + // Live counters come from the summary response. + expect(text).toContain( + '2 permission requests are waiting for a client response', + ); + expect(text).toContain('0.9.0'); + expect(text).toContain('4242'); + expect(text).toContain('/work/demo'); + expect(text).toContain('1h 2m 3s'); + expect(text).toContain('daemon_status'); + // rate-limit rejects are summed across tiers (37 + 4) + expect(text).toContain('41'); + // The badge + issues reflect the full rollup (error + preflight), not the + // summary (warning) — otherwise the dialog would read "OK/Warning" while a + // loaded full diagnostic is failing. + expect(topBadgeText()).toBe('Error'); + expect(text).toContain('preflight failed: node version too old'); + }); + + it('translates workspace section status badges, including "unavailable"', () => { + fullState = { + report: { + ...fullReport, + full: { + ...fullReport.full, + workspace: { + preflight: { + status: 'unavailable', + durationMs: 5, + error: { kind: 'timeout', message: 'timed out' }, + }, + }, + }, + }, + loading: false, + error: undefined, + }; + mount('zh-CN'); + const text = container!.textContent ?? ''; + // The section badge is translated ("不可用"), not the raw wire value. + expect(text).toContain('不可用'); + expect(text).not.toContain('unavailable'); + }); + + it('fetches both summary and full detail and renders diagnostics with no toggle', () => { + mount(); + // Both detail levels are requested up front; there is no user-facing + // summary/full switch to reason about. + expect(seenDetails).toContain('summary'); + expect(seenDetails).toContain('full'); + const buttonLabels = Array.from(container!.querySelectorAll('button')).map( + (el) => el.textContent, + ); + expect(buttonLabels).not.toContain('Summary'); + expect(buttonLabels).not.toContain('Full'); + // Detail sections render immediately alongside the summary cards. + const text = container!.textContent ?? ''; + expect(text).toContain('My session'); + expect(text).toContain('preflight exploded'); + expect(text).toContain('Workspace Diagnostics'); + // A healthy workspace section renders its name, translated status, and + // summary chips. + expect(text).toContain('mcp'); + expect(text).toContain('OK'); + expect(text).toContain('servers: 2'); + }); + + it('auto-refresh reloads only the cheap summary, never the full report', async () => { + vi.useFakeTimers(); + mount(); + expect(summaryReload).not.toHaveBeenCalled(); + // Advance one interval at a time, flushing the in-flight `.finally` between + // ticks so the guard is clear for the next tick. + for (let tick = 1; tick <= 3; tick++) { + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + expect(summaryReload).toHaveBeenCalledTimes(tick); + } + // The expensive detail path is never hit by the interval. + expect(fullReload).not.toHaveBeenCalled(); + }); + + it('skips a poll tick while the previous summary reload is still in flight', async () => { + vi.useFakeTimers(); + let release: () => void = () => {}; + summaryReload.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(undefined); + }), + ); + mount(); + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + expect(summaryReload).toHaveBeenCalledTimes(1); // first tick, still pending + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + expect(summaryReload).toHaveBeenCalledTimes(1); // coalesced away while pending + await act(async () => { + release(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + expect(summaryReload).toHaveBeenCalledTimes(2); // fires again once free + }); + + it('does not poll while the tab is backgrounded', async () => { + vi.useFakeTimers(); + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => true, + }); + mount(); + await act(async () => { + vi.advanceTimersByTime(15_000); + await Promise.resolve(); + }); + expect(summaryReload).not.toHaveBeenCalled(); + // Bring the tab back to the foreground; polling resumes. + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => false, + }); + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + expect(summaryReload).toHaveBeenCalledTimes(1); + Reflect.deleteProperty(document, 'hidden'); + }); + + it('manual refresh reloads both summary and full', () => { + mount(); + const refreshButton = Array.from( + container!.querySelectorAll('button'), + ).find((el) => el.textContent === 'Refresh'); + expect(refreshButton).toBeDefined(); + act(() => { + refreshButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(summaryReload).toHaveBeenCalledTimes(1); + expect(fullReload).toHaveBeenCalledTimes(1); + }); + + it('stops refreshing after unmount', () => { + vi.useFakeTimers(); + mount(); + act(() => root!.unmount()); + act(() => { + vi.advanceTimersByTime(20_000); + }); + expect(summaryReload).not.toHaveBeenCalled(); + expect(fullReload).not.toHaveBeenCalled(); + }); + + it('falls back to the summary rollup badge while diagnostics are still loading', () => { + fullState = { report: undefined, loading: true, error: undefined }; + mount(); + const text = container!.textContent ?? ''; + // Top cards come from the summary and render right away... + expect(text).toContain('4242'); + // ...while the detail sections show a loading placeholder. + expect(text).toContain('Loading diagnostics'); + expect(text).not.toContain('Workspace Diagnostics'); + // Before the full report lands the badge reflects the summary rollup, and + // the full-only preflight issue is not shown yet. + expect(topBadgeText()).toBe('Warning'); + expect(text).not.toContain('preflight failed: node version too old'); + }); + + it('shows the load error when no report is available', () => { + summaryState = { + report: undefined, + loading: false, + error: new Error('connection refused'), + }; + fullState = { report: undefined, loading: false, error: undefined }; + mount(); + const text = container!.textContent ?? ''; + expect(text).toContain('Failed to load daemon status'); + expect(text).toContain('connection refused'); + }); + + it('keeps the toolbar healthy when only the full fetch fails, and flags the detail section', () => { + // Summary succeeds (cards + timestamp fresh); only the detail fetch fails. + fullState = { + report: undefined, + loading: false, + error: new Error('full boom'), + }; + mount(); + const text = container!.textContent ?? ''; + // Live summary cards still render... + expect(text).toContain('4242'); + expect(text).toContain('http-bridge'); + // ...the toolbar does NOT show the summary-failure banner... + expect(text).not.toContain('Failed to load daemon status'); + // ...and the failure is confined to the diagnostics section. + expect(text).toContain('Failed to load diagnostics'); + // With no full report, the badge falls back to the summary rollup. + expect(topBadgeText()).toBe('Warning'); + }); + + it('renders the ACP-disabled branch when the transport is off', () => { + const acpOff = { + ...summaryReport, + runtime: { + ...summaryReport.runtime, + transport: { + ...summaryReport.runtime.transport, + acp: { + ...summaryReport.runtime.transport.acp, + enabled: false, + }, + }, + }, + }; + summaryState = { report: acpOff, loading: false, error: undefined }; + fullState = { report: undefined, loading: true, error: undefined }; + mount(); + const text = container!.textContent ?? ''; + expect(text).toContain('ACP transport disabled'); + expect(text).not.toContain('ACP streams (session/SSE/WS)'); + }); + + it('formats uptime, memory, and durations across unit boundaries', () => { + const boundaries = { + ...summaryReport, + daemon: { ...summaryReport.daemon, uptimeMs: 90_061_000 }, // 1d 1h 1m + limits: { + ...summaryReport.limits, + promptDeadlineMs: 1_500, // fractional seconds -> "1.5s" + sessionIdleTimeoutMs: 500, // sub-second -> "500ms" + }, + runtime: { + ...summaryReport.runtime, + process: { + rss: 2 * 1024 * 1024 * 1024, // 2 GB + heapTotal: 1024 * 1024 * 1024, + heapUsed: 512 * 1024 * 1024, // 512.0 MB + }, + }, + }; + summaryState = { report: boundaries, loading: false, error: undefined }; + fullState = { report: undefined, loading: true, error: undefined }; + mount(); + const text = container!.textContent ?? ''; + expect(text).toContain('1d 1h 1m'); // formatUptime day branch + expect(text).toContain('2.00 GB'); // formatBytes GB branch + expect(text).toContain('512.0 MB'); // formatBytes MB branch + expect(text).toContain('1.5s'); // formatDurationMs fractional-second branch + expect(text).toContain('500ms'); // formatDurationMs sub-second branch + }); + + it('surfaces runtime startup state and channel-worker diagnostics', () => { + const degraded = { + ...summaryReport, + runtime: { + ...summaryReport.runtime, + loading: true, + channel: { live: false }, + channelWorker: { + enabled: true, + state: 'exited', + channels: ['alpha'], + error: 'worker crashed on boot', + restartCount: 3, + exitCode: 1, + }, + }, + }; + summaryState = { report: degraded, loading: false, error: undefined }; + fullState = { report: undefined, loading: true, error: undefined }; + mount(); + const text = container!.textContent ?? ''; + expect(text).toContain('Runtime is starting'); // runtime.loading cue + expect(text).toContain('down'); // channel.live === false + expect(text).toContain('exited (exit 1)'); // channelWorkerState() + expect(text).toContain('worker crashed on boot'); // channelWorker.error + expect(text).toContain('Worker restarts'); // restartCount > 0 + expect(text).toContain('3'); + }); + + it('shows the runtime start-failure message', () => { + const failed = { + ...summaryReport, + runtime: { ...summaryReport.runtime, error: 'bind EADDRINUSE :4170' }, + }; + summaryState = { report: failed, loading: false, error: undefined }; + fullState = { report: undefined, loading: true, error: undefined }; + mount(); + const text = container!.textContent ?? ''; + expect(text).toContain('Runtime failed to start'); + expect(text).toContain('bind EADDRINUSE :4170'); + }); + + it('renders empty/disabled placeholders (sessions, rate limit, capabilities, ACP)', () => { + const sparseSummary = { + ...summaryReport, + capabilities: { protocolVersions: { serve: 1 }, features: [] }, + runtime: { + ...summaryReport.runtime, + rateLimit: { enabled: false, rejectedSinceStart: {} }, + transport: { + ...summaryReport.runtime.transport, + acp: { ...summaryReport.runtime.transport.acp, enabled: false }, + }, + }, + }; + summaryState = { report: sparseSummary, loading: false, error: undefined }; + fullState = { + report: { ...fullReport, full: { ...fullReport.full, sessions: [] } }, + loading: false, + error: undefined, + }; + mount(); + const text = container!.textContent ?? ''; + expect(text).toContain('No active sessions'); // empty sessions placeholder + expect(text).toContain('ACP transport disabled'); // acp.enabled === false + // rate-limit disabled and empty capabilities both render "disabled"/"none". + expect(text).toContain('disabled'); + expect(text).toContain('none'); + }); + + it('shows the toolbar failure banner when a poll fails but data is present', () => { + // Distinct from the no-data early return: the summary has stale data plus + // an error, so the cards render and the toolbar banner appears. + summaryState = { + report: summaryReport, + loading: false, + error: new Error('poll failed'), + }; + mount(); + const text = container!.textContent ?? ''; + expect(text).toContain('4242'); // stale cards still render + expect(text).toContain('Failed to load daemon status'); // toolbar banner + }); + + it('shows the pure loading state before any report arrives', () => { + summaryState = { report: undefined, loading: true, error: undefined }; + fullState = { report: undefined, loading: true, error: undefined }; + mount(); + const text = container!.textContent ?? ''; + expect(text).toContain('Loading daemon status'); + expect(text).not.toContain('Failed to load daemon status'); + }); + + it('renders the workspace empty-state when no sections are reported', () => { + fullState = { + report: { ...fullReport, full: { ...fullReport.full, workspace: {} } }, + loading: false, + error: undefined, + }; + mount(); + expect(container!.textContent ?? '').toContain( + 'No workspace diagnostics reported', + ); + }); + + it('contains a malformed daemon response and surfaces the render error', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // channelWorker is required by the wire type, but an older daemon could + // omit it; the inner render would throw on `.enabled` without the boundary. + summaryState = { + report: { + ...summaryReport, + runtime: { ...summaryReport.runtime, channelWorker: undefined }, + }, + loading: false, + error: undefined, + }; + fullState = { report: undefined, loading: false, error: undefined }; + mount(); + const text = container!.textContent ?? ''; + // The outer boundary fallback renders instead of the throw escaping, and + // the function-form fallback surfaces the actual render error. + expect(text).toContain('Failed to load daemon status'); + expect(text).toContain('enabled'); // the TypeError message is included + errorSpy.mockRestore(); + }); + + it('contains a detail-section crash without losing the summary cards', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // A malformed detail=full payload (auth omitted) throws inside FullDetail. + summaryState = { report: summaryReport, loading: false, error: undefined }; + fullState = { + report: { + ...fullReport, + full: { + sessions: [], + workspace: {}, + acpConnections: [], + auth: undefined, + }, + }, + loading: false, + error: undefined, + }; + mount(); + const text = container!.textContent ?? ''; + // Summary cards stay live; only the detail region shows its own fallback. + expect(text).toContain('4242'); + expect(text).toContain('Failed to load diagnostics'); + // The whole-dialog (outer) fallback did NOT trigger. + expect(text).not.toContain('Failed to load daemon status'); + errorSpy.mockRestore(); + }); + + it('shows a failed state when the full fetch resolves without a full section', () => { + summaryState = { report: summaryReport, loading: false, error: undefined }; + // Fetch resolved (no error, not loading) but the daemon omitted `full`. + fullState = { + report: { ...summaryReport }, + loading: false, + error: undefined, + }; + mount(); + const text = container!.textContent ?? ''; + expect(text).toContain('Failed to load diagnostics'); + expect(text).not.toContain('Loading diagnostics'); + }); + + it('renders runtime.activity counters when the daemon reports them', () => { + summaryState = { + report: { + ...summaryReport, + runtime: { + ...summaryReport.runtime, + activity: { + activePrompts: 2, + lastActivityAt: '2026-07-03T07:59:00.000Z', + idleSinceMs: 65_000, + }, + }, + }, + loading: false, + error: undefined, + }; + fullState = { report: undefined, loading: true, error: undefined }; + mount(); + const text = container!.textContent ?? ''; + expect(text).toContain('Active prompts'); + expect(text).toContain('2'); + expect(text).toContain('Idle for'); + expect(text).toContain('1m 5s'); // formatDurationMs(65000) + }); + + it('shows "no activity yet" when idleSinceMs is null', () => { + summaryState = { + report: { + ...summaryReport, + runtime: { + ...summaryReport.runtime, + activity: { + activePrompts: 0, + lastActivityAt: null, + idleSinceMs: null, + }, + }, + }, + loading: false, + error: undefined, + }; + fullState = { report: undefined, loading: true, error: undefined }; + mount(); + expect(container!.textContent ?? '').toContain('no activity yet'); + }); + + it('omits the activity rows for a daemon that predates runtime.activity', () => { + // The default fixture has no runtime.activity — the section must not render. + mount(); + expect(container!.textContent ?? '').not.toContain('Active prompts'); + }); + + it('falls back to the session id when a session has no display name', () => { + fullState = { + report: { + ...fullReport, + full: { + ...fullReport.full, + sessions: [ + { + sessionId: 'sess-no-name-9', + workspaceCwd: '/work/demo', + createdAt: '2026-07-03T07:00:00.000Z', + clientCount: 1, + subscriberCount: 0, + attachCount: 0, + pendingPromptCount: 0, + pendingPermissionCount: 0, + hasActivePrompt: false, + lastEventId: 1, + }, + ], + }, + }, + loading: false, + error: undefined, + }; + mount(); + expect(container!.textContent ?? '').toContain('sess-no-name-9'); + }); + + it('names the individual warning/error cells behind a section status', () => { + fullState = { + report: { + ...fullReport, + full: { + ...fullReport.full, + workspace: { + preflight: { + status: 'warning', + durationMs: 8, + summary: { initialized: true, cellsCount: 3 }, + data: { + cells: [ + { kind: 'node_version', status: 'ok' }, + { + kind: 'auth', + status: 'warning', + error: 'No auth method configured.', + }, + { kind: 'egress', status: 'not_started', hint: 'not impl' }, + ], + }, + }, + }, + }, + }, + loading: false, + error: undefined, + }; + mount(); + const text = container!.textContent ?? ''; + // The warning cell is named with its message... + expect(text).toContain('auth'); + expect(text).toContain('No auth method configured.'); + // ...while ok / not_started cells are not surfaced as problems. + expect(text).not.toContain('node_version'); + expect(text).not.toContain('not impl'); + }); + + it('formats the channel-worker signal branch', () => { + summaryState = { + report: { + ...summaryReport, + runtime: { + ...summaryReport.runtime, + channel: { live: false }, + channelWorker: { + enabled: true, + state: 'exited', + channels: [], + signal: 'SIGTERM', // no exitCode -> signal branch + }, + }, + }, + loading: false, + error: undefined, + }; + fullState = { report: undefined, loading: true, error: undefined }; + mount(); + expect(container!.textContent ?? '').toContain('exited (SIGTERM)'); + }); + + it('suppresses the toolbar banner when the summary is absent but the full fallback provides data', () => { + summaryState = { + report: undefined, + loading: false, + error: new Error('summary poll down'), + }; + fullState = { report: fullReport, loading: false, error: undefined }; + mount(); + const text = container!.textContent ?? ''; + // Cards render from the full fallback... + expect(text).toContain('4242'); + // ...so the toolbar must not claim the dashboard failed to load. + expect(text).not.toContain('Failed to load daemon status'); + }); +}); diff --git a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx new file mode 100644 index 0000000000..c754b7d744 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx @@ -0,0 +1,670 @@ +import { useCallback, useEffect, useRef, type ReactNode } from 'react'; +import { + useStatusReport, + type DaemonStatusReport, + type DaemonStatusReportLevel, + type DaemonStatusReportSection, +} from '@qwen-code/webui/daemon-react-sdk'; +import { useI18n } from '../../i18n'; +import { ErrorBoundary } from '../ErrorBoundary'; +import styles from './DaemonStatusDialog.module.css'; + +// The cheap in-memory summary is polled continuously; the expensive detail +// (per-session, workspace diagnostics, auth — the daemon may spawn the ACP +// child and aggregate several diagnostic surfaces to build it) is fetched only +// on open and on an explicit refresh, so parking the dialog open never rehits +// that path. Both surface as one dashboard: the summary/full split is a daemon +// cost boundary, not something the operator should have to think about. +const REFRESH_INTERVAL_MS = 5000; + +function formatUptime(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function formatDurationMs(ms: number): string { + ms = Math.max(0, ms); // clamp clock-skew negatives to a "0ms" contract + if (ms >= 60_000) return formatUptime(ms); + if (ms >= 1000) return `${(ms / 1000).toFixed(ms % 1000 === 0 ? 0 : 1)}s`; + return `${ms}ms`; +} + +function formatBytes(bytes: number): string { + const mb = bytes / (1024 * 1024); + return mb >= 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(1)} MB`; +} + +function channelWorkerState( + worker: DaemonStatusReport['runtime']['channelWorker'], +): string { + if (worker.exitCode != null) { + return `${worker.state} (exit ${worker.exitCode})`; + } + if (worker.signal) return `${worker.state} (${worker.signal})`; + return worker.state; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +interface WorkspaceProblemCell { + label: string; + status: 'warning' | 'error'; + message?: string; +} + +// A section's status is the worst of its individual checks, but the summary +// chips only carry counts — so a "warning preflight" reads as opaque. Pull the +// individual warning/error entries out of the raw section data so the dashboard +// can say *what* is wrong (e.g. "auth: No auth method configured"). Section +// payloads differ but consistently carry status cells under these keys. +const SECTION_CELL_KEYS = [ + 'cells', + 'servers', + 'errors', + 'skills', + 'tools', + 'providers', + 'hooks', + 'extensions', + 'budgets', +] as const; + +function extractProblemCells(data: unknown): WorkspaceProblemCell[] { + if (!isRecord(data)) return []; + const problems: WorkspaceProblemCell[] = []; + for (const key of SECTION_CELL_KEYS) { + const arr = data[key]; + if (!Array.isArray(arr)) continue; + for (const item of arr) { + if (!isRecord(item)) continue; + const status = item['status']; + if (status !== 'warning' && status !== 'error') continue; + const label = String( + item['kind'] ?? item['name'] ?? item['serverName'] ?? key, + ); + const message = + typeof item['error'] === 'string' + ? item['error'] + : typeof item['hint'] === 'string' + ? item['hint'] + : undefined; + problems.push({ label, status, message }); + } + } + return problems; +} + +function levelClass( + level: DaemonStatusReportLevel | 'unavailable', +): string | undefined { + switch (level) { + case 'ok': + return styles.levelOk; + case 'warning': + return styles.levelWarning; + case 'error': + return styles.levelError; + default: + return styles.levelUnavailable; + } +} + +function Row({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function Card({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function WorkspaceSectionRow({ + name, + section, +}: { + name: string; + section: DaemonStatusReportSection; +}) { + const { t } = useI18n(); + const summaryEntries = Object.entries(section.summary ?? {}); + const problemCells = extractProblemCells(section.data); + return ( +
+
+ + {t(`daemon.level.${section.status}`)} + + {name} + + {formatDurationMs(section.durationMs)} + +
+ {section.error && ( +
{section.error.message}
+ )} + {/* Name the individual checks that pushed this section to warning/error, + so the badge is self-explanatory. */} + {problemCells.map((cell, index) => ( +
+ + {t(`daemon.level.${cell.status}`)} + + {cell.label} + {cell.message && ( + {cell.message} + )} +
+ ))} + {summaryEntries.length > 0 && ( +
+ {summaryEntries.map(([key, value]) => ( + + {key}: {value === null ? 'N/A' : String(value)} + + ))} +
+ )} +
+ ); +} + +function FullDetail({ report }: { report: DaemonStatusReport }) { + const { t } = useI18n(); + const full = report.full; + if (!full) return null; + const workspaceEntries = Object.entries(full.workspace).sort(([a], [b]) => + a.localeCompare(b), + ); + return ( + <> + + {full.sessions.length === 0 ? ( +
{t('daemon.full.sessions.empty')}
+ ) : ( + full.sessions.map((session) => ( +
+
+ {session.displayName || session.sessionId} +
+
+ + {t('common.clients', { count: session.clientCount })} + + + {t('daemon.full.session.pendingPrompts', { + count: session.pendingPromptCount, + })} + + + {t('daemon.full.session.pendingPermissions', { + count: session.pendingPermissionCount, + })} + + {session.hasActivePrompt && ( + + {t('daemon.full.session.prompting')} + + )} +
+
+ )) + )} +
+ + {workspaceEntries.length === 0 ? ( +
{t('daemon.full.workspace.empty')}
+ ) : ( + workspaceEntries.map(([name, section]) => ( + + )) + )} +
+ + + + + + + ); +} + +function DaemonStatusDialogInner() { + const { t } = useI18n(); + // Two independent fetches: the summary drives the always-live top cards and + // rides the auto-refresh interval; the full report backs the detail sections + // and is only pulled on open (autoLoad) and on manual refresh. + const summary = useStatusReport({ autoLoad: true, detail: 'summary' }); + const full = useStatusReport({ autoLoad: true, detail: 'full' }); + // `reload` is a stable callback; depend on it (not the hook object, which is + // a fresh spread each render) so the poll interval is installed once rather + // than torn down and reinstalled on every data update. + const summaryReload = summary.reload; + const fullReload = full.reload; + + // Skip a tick when the tab is backgrounded (matching the sidebar poll) or + // when the previous poll is still outstanding: useDaemonResource discards + // stale completions but does not abort, and the client timeout is 30s, so a + // degraded daemon could otherwise accumulate overlapping calls. + const summaryPollInFlightRef = useRef(false); + useEffect(() => { + const timer = window.setInterval(() => { + if (document.hidden || summaryPollInFlightRef.current) return; + summaryPollInFlightRef.current = true; + void summaryReload().finally(() => { + summaryPollInFlightRef.current = false; + }); + }, REFRESH_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [summaryReload]); + + const refreshAll = useCallback(() => { + void summaryReload(); + void fullReload(); + }, [summaryReload, fullReload]); + + // Prefer the continuously-refreshed summary for the top cards; fall back to + // the full report so the dashboard still renders if only that has landed. + const report = summary.report ?? full.report; + const fullReport = full.report; + const loading = summary.loading || full.loading; + const error = summary.error ?? full.error; + + if (!report) { + return ( +
+
+ {error + ? `${t('daemon.loadFailed')}: ${error.message}` + : t('daemon.loading')} +
+
+ ); + } + + // The daemon only appends workspace/preflight/MCP issues (and rolls them into + // `status`) for detail=full, so the summary can read "ok" with an empty issue + // list while a loaded full report is failing. Drive the badge and issue list + // off the full report whenever it is available; keep the live counters on the + // summary. The rollup then refreshes on open/manual rather than every 5s, + // which only ever over-reports (safe) between full fetches. + const rollupReport = fullReport ?? report; + + const { daemon, runtime, security, limits, capabilities } = report; + const acp = runtime.transport.acp; + const rateRejected = Object.values( + runtime.rateLimit.rejectedSinceStart, + ).reduce((sum, count) => sum + count, 0); + const limitValue = (value: number | null) => + value === null ? t('daemon.limits.unlimited') : value; + + return ( +
+
+ + {t(`daemon.level.${rollupReport.status}`)} + + + {t('daemon.updatedAt', { + time: new Date(report.generatedAt).toLocaleTimeString(), + })} + + {/* Flag the toolbar only when the summary that owns the visible + counters/timestamp is the failing, stale source: it errored AND + still has (now-stale) data on screen. When the summary never loaded + and the cards are rendering from the full fallback, or when only the + full fetch failed (surfaced in the diagnostics section), the banner + would misrepresent an otherwise-usable dashboard. */} + {summary.error && summary.report && ( + {t('daemon.loadFailed')} + )} +
+ +
+
+ + {rollupReport.issues.length > 0 && ( + + {rollupReport.issues.map((issue, index) => ( +
+ + {issue.severity === 'error' + ? t('daemon.level.error') + : t('daemon.level.warning')} + + {issue.message} +
+ ))} +
+ )} + +
+ + {daemon.qwenCodeVersion && ( + + )} + + + + {/* The workspace path is long; give it its own full-width row and + keep it to a single line — front-truncated so the meaningful tail + (…/parent/workspace) stays visible, full path on hover. */} +
+ + {t('daemon.overview.workspace')} + + + {daemon.workspaceCwd} + +
+
+ + + {/* The counters below read as plausible zeros while the daemon + runtime is still coming up or has failed; call that out so they + are not mistaken for a healthy idle daemon. */} + {runtime.error ? ( +
+ {t('daemon.runtime.startFailed')}: {runtime.error} +
+ ) : runtime.loading ? ( +
{t('daemon.runtime.startingUp')}
+ ) : null} + + {/* Activity counters (daemons predating this omit the sub-object). */} + {runtime.activity && ( + <> + + + + )} + + + + {/* Surface why a channel worker is unhealthy instead of leaving the + operator with a bare "down" — these fields are already fetched. */} + {runtime.channelWorker.enabled && ( + <> + + {runtime.channelWorker.error && ( +
+ {runtime.channelWorker.error} +
+ )} + {(runtime.channelWorker.restartCount ?? 0) > 0 && ( + + )} + + )} + +
+ + + + {acp.enabled ? ( + <> + + + + + ) : ( +
+ {t('daemon.transport.acpDisabled')} +
+ )} + +
+ + + + + + + + + + + + + + + + + + + + {capabilities.features.length === 0 ? ( + {t('daemon.none')} + ) : ( +
+ {[...capabilities.features].sort().map((feature) => ( + + {feature} + + ))} +
+ )} +
+
+ + {/* Contain a crash in the detail sections (e.g. a partial detail=full + payload) to this region so the healthy summary cards above stay live, + rather than letting the outer boundary replace the whole dialog. */} + {t('daemon.details.failed')}
+ } + > + {fullReport?.full ? ( + + ) : full.loading ? ( +
{t('daemon.details.loading')}
+ ) : full.error ? ( +
+ {t('daemon.details.failed')}: {full.error.message} +
+ ) : ( + // Fetch resolved but the daemon omitted the `full` section — don't + // hang on the loading placeholder forever. +
{t('daemon.details.failed')}
+ )} + + + ); +} + +// A malformed or partial daemon response — most likely exactly when the daemon +// is sick and this dashboard is most needed — must not white-screen the whole +// web shell. Contain any render throw to the dialog; the function-form fallback +// surfaces the actual render error (distinct from a network failure). Because +// the parent only mounts the dialog while open, closing and re-opening remounts +// the boundary, so a transient bad payload recovers on the next open. +export function DaemonStatusDialog() { + const { t } = useI18n(); + return ( + ( +
+
+ {t('daemon.loadFailed')}: {error.message} +
+
+ )} + > + +
+ ); +} diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx index cb88fa13aa..9ecc20c139 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx @@ -37,7 +37,13 @@ const mounted: Array<{ root: Root; container: HTMLElement }> = []; const noop = () => {}; -function renderSidebar(collapsed: boolean): HTMLElement { +function renderSidebar( + collapsed: boolean, + overrides: Partial<{ + onOpenSettings: () => void; + onOpenDaemonStatus: () => void; + }> = {}, +): HTMLElement { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); @@ -48,9 +54,11 @@ function renderSidebar(collapsed: boolean): HTMLElement { collapsed={collapsed} onCollapsedChange={noop} onOpenSettings={noop} + onOpenDaemonStatus={noop} onNewSession={() => false} onLoadSession={noop} onError={noop} + {...overrides} /> , ); @@ -99,3 +107,31 @@ describe('WebShellSidebar — version footer', () => { expect(container.textContent ?? '').not.toMatch(/v\d/); }); }); + +describe('WebShellSidebar — daemon status entry', () => { + it('invokes onOpenDaemonStatus when the footer button is clicked', () => { + const onOpenDaemonStatus = vi.fn(); + const container = renderSidebar(false, { onOpenDaemonStatus }); + const button = container.querySelector( + '[aria-label="Daemon Status"]', + ); + expect(button).not.toBeNull(); + act(() => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onOpenDaemonStatus).toHaveBeenCalledTimes(1); + }); + + it('still exposes the daemon status button when collapsed', () => { + const onOpenDaemonStatus = vi.fn(); + const container = renderSidebar(true, { onOpenDaemonStatus }); + const button = container.querySelector( + '[aria-label="Daemon Status"]', + ); + expect(button).not.toBeNull(); + act(() => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onOpenDaemonStatus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index bc66117aab..c8ff38a31f 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -33,6 +33,7 @@ interface WebShellSidebarProps { collapsed: boolean; onCollapsedChange: (collapsed: boolean) => void; onOpenSettings: () => void; + onOpenDaemonStatus: () => void; onNewSession: () => Promise | boolean; onLoadSession: (sessionId: string) => Promise | void; onError: (error: unknown, fallback: string) => void; @@ -136,6 +137,14 @@ function IconSettings() { ); } +function IconPulse() { + return ( + + ); +} + function IconRename() { return (