feat(web-shell): add a daemon status page backed by GET /daemon/status (#6272)

* feat(web-shell): add a daemon status page backed by GET /daemon/status

Surface the consolidated daemon status API (#5174) in the Web Shell as a
dashboard dialog opened from a sidebar footer button.

- @qwen-code/sdk: DaemonClient.daemonStatus(detail) plus DaemonStatusReport*
  wire types for the /daemon/status envelope (summary and full detail).
- @qwen-code/webui: loadDaemonStatus workspace action and a
  useDaemonStatusReport hook (exported as useDaemonStatus from
  daemon-react-sdk).
- web-shell: DaemonStatusDialog rendering one dashboard — overall status
  badge, issues list, daemon/runtime/transport/security/limits/capabilities
  cards, plus per-session, workspace-diagnostics, and auth sections. The
  daemon's summary/full cost split is hidden from the operator rather than
  exposed as a toggle: the cheap summary rides a 5s auto-refresh while the
  expensive full report (which may spawn the ACP child and aggregate
  workspace diagnostics) is fetched only on open and on manual refresh, so
  parking the dialog open never rehits that path. Capabilities are sorted,
  counted, and height-capped; the long workspace path stays on one line,
  front-truncated so the tail remains visible. New pulse-icon sidebar entry;
  EN/zh-CN strings.
- vite dev proxy: forward /daemon to the daemon; without it the SPA fallback
  answered /daemon/status with index.html and the dialog failed JSON parsing
  under npm run dev:daemon.

* fix(web-shell): address review on the daemon status dashboard

- Drive the status badge and issues list off the full report when it is
  available, not the summary. The daemon only rolls workspace/preflight/MCP
  problems into status+issues for detail=full, so the summary can read "ok"
  with no issues while a loaded full report is degraded — the dashboard now
  reflects the full rollup (live counters still come from the summary).
- Guard the 5s poll with an in-flight ref so a slow/degraded daemon cannot
  accumulate overlapping status calls (useDaemonResource discards stale
  completions but does not abort; the client timeout is 30s).
- Fix the public DaemonStatusReport wire type: runtime.channelWorker.channels
  is string[] (ChannelWorkerSnapshot), not an array of objects; mirror the
  remaining optional snapshot fields.

* fix(web-shell): translate workspace section status badges

WorkspaceSectionRow rendered the raw wire status (`ok`/`warning`/`error`/
`unavailable`) while every other badge in the dialog goes through `t()`, so
under a Chinese UI these badges showed lowercase English. Route the badge
through `t('daemon.level.<status>')` and add the missing `daemon.level.unavailable`
key to both dictionaries.

* fix(web-shell): scope toolbar error to summary; broaden dashboard test coverage

- The toolbar "failed to load" banner now keys on the summary fetch only. A
  failed full fetch is already surfaced in the diagnostics section, so it no
  longer makes an otherwise-healthy summary (fresh cards + timestamp) read as
  broken.
- Use the ASCII "..." ellipsis in the diagnostics-loading string to match the
  rest of the i18n dictionary.
- Add tests: summary-healthy/full-failed degraded state, the ACP-disabled
  transport branch, uptime/memory/duration formatting across unit boundaries
  (day, GB, sub-second, fractional-second), and sidebar Daemon Status button
  click (expanded + collapsed) — the feature's only entry point.

* fix(web-shell): pause polling on hidden tab; scope dev proxy; fix test mock

- Skip the 5s status poll while document.hidden, matching the sidebar poll —
  a backgrounded tab no longer hits the daemon every 5s.
- Narrow the vite dev proxy to the exact /daemon/status route instead of a
  bare /daemon prefix, mirroring the scoped /voice/stream entry; verified the
  dashboard still proxies (summary + detail=full) in dev.
- Add a message field to the DaemonStatusReport issue mock in the webui
  provider test so it matches the required DaemonStatusReportIssue shape.

* feat(web-shell): surface runtime/channel-worker diagnostics; a11y + polish

Address the daemon-status review round:
- Render the runtime startup/failure state (runtime.loading / runtime.error)
  in the Runtime card so the plausible-looking zero counters during startup
  are not mistaken for a healthy idle daemon.
- Surface channel-worker diagnostics (state, exit code/signal, error, restart
  count) when the worker is enabled — these fields were fetched and typed but
  never shown, leaving a bare "down" with no context.
- Include full.error.message in the diagnostics-failure line (matching the
  summary error path) so a failed detail fetch is actionable.
- Show "N/A" instead of a literal "null" chip for null workspace summary
  values (the wire type allows null).
- Add role="status" + aria-label to the health badge for screen readers.
- Rename the public hook alias useDaemonStatus -> useStatusReport, matching
  the Daemon-prefix-stripping convention of the other re-exports.
- Add tests: runtime startup/failure, channel-worker diagnostics, and the
  empty/disabled placeholders (sessions, rate limit, capabilities, ACP),
  toolbar-banner-with-data, and pure-loading branches.

* fix(web-shell): contain daemon status crashes; workspace empty-state

- Wrap the dashboard in a local ErrorBoundary so a malformed/partial daemon
  response (e.g. an older daemon omitting an additive field like
  channelWorker) — most likely exactly when the daemon is sick and the
  dashboard is most needed — shows a contained fallback instead of throwing
  to the root boundary and white-screening the whole web shell.
- Add an empty-state to the Workspace Diagnostics card (parity with the
  Sessions card) for when full.workspace is empty.
- Tests: error-boundary containment on a malformed report, and the workspace
  empty-state.

* fix(web-shell): fix error-boundary recovery; contain detail crashes

Address the review round (one Critical):
- ErrorBoundary recovery was broken: the comment claimed resetKeys cleared the
  fallback, but none was passed. Switch to a function-form fallback that
  surfaces the actual render error (distinct from a network failure) and fix
  the comment — recovery happens on re-open, since the parent only mounts the
  dialog while open.
- Wrap FullDetail in its own ErrorBoundary so a malformed detail=full payload
  is contained to the detail region instead of taking down the healthy summary
  cards with it; add a catch-all branch so a fetch that resolves without a
  `full` section shows a failed state instead of hanging on "Loading...".
- Toolbar failure banner now shows only when the summary errored AND still has
  data on screen (`summary.error && summary.report`), so it no longer
  misrepresents a dashboard that is rendering from the full fallback.
- SDK type: drop `& Record<string, unknown>` on DaemonStatusReport.daemon and
  add the typed optional `startup` field, matching the DaemonCapabilities
  convention the interface JSDoc claims.
- Add a real useDaemonStatusReport hook test asserting the `report` alias maps
  from `data` — the dialog test mocks the whole hook, so nothing else guarded
  it.

* feat(web-shell): surface runtime.activity in the daemon status dashboard

PR #6270 added a runtime.activity sub-object to GET /daemon/status
(activePrompts, lastActivityAt, idleSinceMs). Type it as an additive optional
on the SDK DaemonStatusReport and render it in the Runtime card: active-prompt
count and an idle duration ("no activity yet" when the daemon has seen none).
Gated on the field's presence so older daemons that omit it still render.
Verified end-to-end against a real qwen serve --web that emits the field.

* fix(web-shell): daemon status polish — i18n count, negative clamp, coverage

Address the review round (all minor):
- Move the capabilities count into the i18n string (daemon.capabilities.titleCount
  with a {count} placeholder) so locales can reorder it.
- Clamp negative durations in formatDurationMs (clock-skew defense).
- Re-export the hook options type as StatusReportOptions for consumers wrapping
  useStatusReport.
- Tests: use the real rate-limit tier keys (prompt/mutation/read) in the fixture,
  and cover the session-id display fallback, the channel-worker signal branch,
  and a healthy workspace section's chip/status rendering.

* feat(web-shell): name the failing checks behind a workspace section status

A "warning"/"error" workspace-diagnostics section only showed a rollup badge
plus count chips, so e.g. a warning preflight was opaque — the operator
couldn't tell it was the auth check without curling the API. Extract the
individual warning/error cells from the section's raw data (across cells /
servers / skills / tools / providers / hooks / extensions) and render each with
its label and message (e.g. "auth: No auth method configured."). OK and other
non-problem cells stay hidden. Verified end-to-end: a real daemon with no
credentials now shows the auth warning inline under preflight.
This commit is contained in:
Shaojin Wen 2026-07-04 08:35:51 +08:00 committed by GitHub
parent 5dc2e1501f
commit 9b2fb30cb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2432 additions and 1 deletions

View file

@ -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<DaemonStatusReport> {
const query = detail === 'summary' ? '' : `?detail=${detail}`;
return await this.jsonRequest<DaemonStatusReport>(
`/daemon/status${query}`,
'GET /daemon/status',
);
}
async workspaceMcp(): Promise<DaemonWorkspaceMcpStatus> {
return await this.fetchWithTimeout(
`${this.baseUrl}/workspace/mcp`,

View file

@ -423,6 +423,12 @@ export type {
DaemonPreflightKind,
DaemonStatus,
DaemonStatusCell,
DaemonStatusReport,
DaemonStatusReportDetail,
DaemonStatusReportIssue,
DaemonStatusReportLevel,
DaemonStatusReportSection,
DaemonStatusReportSession,
DaemonUpdateAgentRequest,
DaemonContentHash,
DaemonWorkspaceAgentDetail,

View file

@ -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.<name>`). `data` is the raw
* section payload (shape varies per section) render `summary` instead.
*/
export interface DaemonStatusReportSection {
status: DaemonStatusReportLevel | 'unavailable';
durationMs: number;
summary?: Record<string, string | number | boolean | null>;
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<string, number>;
};
/**
* 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<Record<string, unknown>>;
workspace: Record<string, DaemonStatusReportSection>;
auth: {
supportedDeviceFlowProviders: string[];
pendingDeviceFlowCount: number;
};
};
}
/** Returned from `POST /session`. */
export interface DaemonSession {
sessionId: string;

View file

@ -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,

View file

@ -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 = {

View file

@ -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<DaemonSessionRecapResult>().not.toBeNever();
expectTypeOf<DaemonLspServerStatus>().not.toBeNever();
expectTypeOf<DaemonSessionLspStatus>().not.toBeNever();
// `GET /daemon/status` report surface (PR 5174 client coverage): the
// envelope plus the sub-shapes UI dashboards need to type against.
expectTypeOf<DaemonStatusReport>().not.toBeNever();
expectTypeOf<DaemonStatusReportDetail>().not.toBeNever();
expectTypeOf<DaemonStatusReportIssue>().not.toBeNever();
expectTypeOf<DaemonStatusReportLevel>().not.toBeNever();
expectTypeOf<DaemonStatusReportSection>().not.toBeNever();
expectTypeOf<DaemonStatusReportSession>().not.toBeNever();
});
it('exposes the PR 21 auth device-flow surface at the public entry', () => {

View file

@ -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<SerializedMcpStatusMessage | null>(null);
@ -1360,6 +1362,7 @@ export function App({
showHelpDialog ||
showThemeDialog ||
showToolsDialog ||
showDaemonStatusDialog ||
showExtensionsDialog ||
modelDialogMode !== null ||
showApprovalModeDialog ||
@ -3554,6 +3557,15 @@ export function App({
<ToolsDialog />
</DialogShell>
)}
{showDaemonStatusDialog && (
<DialogShell
title={t('daemon.title')}
size="xl"
onClose={() => setShowDaemonStatusDialog(false)}
>
<DaemonStatusDialog />
</DialogShell>
)}
{showExtensionsDialog && (
<DialogShell
title={t('extensions.manage.title')}
@ -3787,6 +3799,10 @@ export function App({
closeMobileDrawer();
setShowSettingsDialog(true);
}}
onOpenDaemonStatus={() => {
closeMobileDrawer();
setShowDaemonStatusDialog(true);
}}
onNewSession={createNewSession}
onLoadSession={loadSidebarSession}
onError={reportError}

View file

@ -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;
}

View file

@ -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<string | undefined> = [];
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(
<I18nProvider language={language}>
<DaemonStatusDialog />
</I18nProvider>,
);
});
}
// 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<undefined>((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');
});
});

View file

@ -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<string, unknown> {
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 (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<span className={styles.rowValue}>{value}</span>
</div>
);
}
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<section className={styles.card}>
<h3 className={styles.cardTitle}>{title}</h3>
{children}
</section>
);
}
function WorkspaceSectionRow({
name,
section,
}: {
name: string;
section: DaemonStatusReportSection;
}) {
const { t } = useI18n();
const summaryEntries = Object.entries(section.summary ?? {});
const problemCells = extractProblemCells(section.data);
return (
<div className={styles.workspaceRow}>
<div className={styles.workspaceRowHead}>
<span className={`${styles.badge} ${levelClass(section.status)}`}>
{t(`daemon.level.${section.status}`)}
</span>
<span className={styles.workspaceName}>{name}</span>
<span className={styles.workspaceDuration}>
{formatDurationMs(section.durationMs)}
</span>
</div>
{section.error && (
<div className={styles.workspaceError}>{section.error.message}</div>
)}
{/* Name the individual checks that pushed this section to warning/error,
so the badge is self-explanatory. */}
{problemCells.map((cell, index) => (
<div key={`${cell.label}-${index}`} className={styles.workspaceCell}>
<span className={`${styles.badge} ${levelClass(cell.status)}`}>
{t(`daemon.level.${cell.status}`)}
</span>
<span className={styles.workspaceCellLabel}>{cell.label}</span>
{cell.message && (
<span className={styles.workspaceCellMessage}>{cell.message}</span>
)}
</div>
))}
{summaryEntries.length > 0 && (
<div className={styles.workspaceSummary}>
{summaryEntries.map(([key, value]) => (
<span key={key} className={styles.summaryChip}>
{key}: {value === null ? 'N/A' : String(value)}
</span>
))}
</div>
)}
</div>
);
}
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 (
<>
<Card title={t('daemon.full.sessions.title')}>
{full.sessions.length === 0 ? (
<div className={styles.empty}>{t('daemon.full.sessions.empty')}</div>
) : (
full.sessions.map((session) => (
<div key={session.sessionId} className={styles.sessionRow}>
<div className={styles.sessionName}>
{session.displayName || session.sessionId}
</div>
<div className={styles.sessionMeta}>
<span>
{t('common.clients', { count: session.clientCount })}
</span>
<span>
{t('daemon.full.session.pendingPrompts', {
count: session.pendingPromptCount,
})}
</span>
<span>
{t('daemon.full.session.pendingPermissions', {
count: session.pendingPermissionCount,
})}
</span>
{session.hasActivePrompt && (
<span className={styles.activePrompt}>
{t('daemon.full.session.prompting')}
</span>
)}
</div>
</div>
))
)}
</Card>
<Card title={t('daemon.full.workspace.title')}>
{workspaceEntries.length === 0 ? (
<div className={styles.empty}>{t('daemon.full.workspace.empty')}</div>
) : (
workspaceEntries.map(([name, section]) => (
<WorkspaceSectionRow key={name} name={name} section={section} />
))
)}
</Card>
<Card title={t('daemon.full.auth.title')}>
<Row
label={t('daemon.full.auth.providers')}
value={
full.auth.supportedDeviceFlowProviders.join(', ') ||
t('daemon.none')
}
/>
<Row
label={t('daemon.full.auth.pending')}
value={full.auth.pendingDeviceFlowCount}
/>
<Row
label={t('daemon.full.acp.title')}
value={full.acpConnections.length}
/>
</Card>
</>
);
}
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 (
<div className={styles.dialog}>
<div className={styles.empty}>
{error
? `${t('daemon.loadFailed')}: ${error.message}`
: t('daemon.loading')}
</div>
</div>
);
}
// 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 (
<div className={styles.dialog}>
<div className={styles.toolbar}>
<span
role="status"
aria-label={`${t('daemon.title')}: ${t(
`daemon.level.${rollupReport.status}`,
)}`}
className={`${styles.badge} ${levelClass(rollupReport.status)}`}
>
{t(`daemon.level.${rollupReport.status}`)}
</span>
<span className={styles.updatedAt}>
{t('daemon.updatedAt', {
time: new Date(report.generatedAt).toLocaleTimeString(),
})}
</span>
{/* 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 && (
<span className={styles.refreshError}>{t('daemon.loadFailed')}</span>
)}
<div className={styles.toolbarActions}>
<button
type="button"
className={styles.refreshButton}
onClick={refreshAll}
disabled={loading}
>
{t('daemon.refresh')}
</button>
</div>
</div>
{rollupReport.issues.length > 0 && (
<Card title={t('daemon.issues.title')}>
{rollupReport.issues.map((issue, index) => (
<div key={`${issue.code}-${index}`} className={styles.issueRow}>
<span
className={`${styles.badge} ${
issue.severity === 'error'
? styles.levelError
: styles.levelWarning
}`}
>
{issue.severity === 'error'
? t('daemon.level.error')
: t('daemon.level.warning')}
</span>
<span className={styles.issueMessage}>{issue.message}</span>
</div>
))}
</Card>
)}
<div className={styles.grid}>
<Card title={t('daemon.overview.title')}>
{daemon.qwenCodeVersion && (
<Row
label={t('daemon.overview.version')}
value={daemon.qwenCodeVersion}
/>
)}
<Row label={t('daemon.overview.pid')} value={daemon.pid} />
<Row label={t('daemon.overview.mode')} value={daemon.mode} />
<Row
label={t('daemon.overview.uptime')}
value={formatUptime(daemon.uptimeMs)}
/>
{/* 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. */}
<div className={styles.pathRow}>
<span className={styles.rowLabel}>
{t('daemon.overview.workspace')}
</span>
<span
className={styles.pathValue}
title={daemon.workspaceCwd}
// Front-truncate (ellipsis at the start) via CSS `direction:rtl`
// so the meaningful tail stays visible; `bdi` keeps the path's
// own characters in logical order despite the rtl context.
>
<bdi>{daemon.workspaceCwd}</bdi>
</span>
</div>
</Card>
<Card title={t('daemon.runtime.title')}>
{/* 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 ? (
<div className={styles.workspaceError}>
{t('daemon.runtime.startFailed')}: {runtime.error}
</div>
) : runtime.loading ? (
<div className={styles.empty}>{t('daemon.runtime.startingUp')}</div>
) : null}
<Row
label={t('daemon.runtime.activeSessions')}
value={runtime.sessions.active}
/>
{/* Activity counters (daemons predating this omit the sub-object). */}
{runtime.activity && (
<>
<Row
label={t('daemon.runtime.activePrompts')}
value={runtime.activity.activePrompts}
/>
<Row
label={t('daemon.runtime.idle')}
value={
runtime.activity.idleSinceMs === null
? t('daemon.runtime.noActivity')
: formatDurationMs(runtime.activity.idleSinceMs)
}
/>
</>
)}
<Row
label={t('daemon.runtime.pendingPermissions')}
value={runtime.permissions.pending}
/>
<Row
label={t('daemon.runtime.permissionPolicy')}
value={runtime.permissions.policy}
/>
<Row
label={t('daemon.runtime.channel')}
value={
runtime.channel.live
? t('daemon.runtime.channelLive')
: t('daemon.runtime.channelDown')
}
/>
{/* Surface why a channel worker is unhealthy instead of leaving the
operator with a bare "down" these fields are already fetched. */}
{runtime.channelWorker.enabled && (
<>
<Row
label={t('daemon.runtime.channelWorker')}
value={channelWorkerState(runtime.channelWorker)}
/>
{runtime.channelWorker.error && (
<div className={styles.workspaceError}>
{runtime.channelWorker.error}
</div>
)}
{(runtime.channelWorker.restartCount ?? 0) > 0 && (
<Row
label={t('daemon.runtime.channelWorkerRestarts')}
value={runtime.channelWorker.restartCount}
/>
)}
</>
)}
<Row
label={t('daemon.runtime.memory')}
value={`${formatBytes(runtime.process.rss)} / ${formatBytes(
runtime.process.heapUsed,
)}`}
/>
</Card>
<Card title={t('daemon.transport.title')}>
<Row
label={t('daemon.transport.restSse')}
value={runtime.transport.restSseActive}
/>
{acp.enabled ? (
<>
<Row
label={t('daemon.transport.acpConnections')}
value={acp.connections}
/>
<Row
label={t('daemon.transport.acpStreams')}
value={`${acp.sessionStreams} / ${acp.sseStreams} / ${acp.wsStreams}`}
/>
<Row
label={t('daemon.transport.pendingRequests')}
value={acp.pendingClientRequests}
/>
</>
) : (
<div className={styles.empty}>
{t('daemon.transport.acpDisabled')}
</div>
)}
<Row
label={t('daemon.transport.rateLimitRejected')}
value={
runtime.rateLimit.enabled ? rateRejected : t('common.disabled')
}
/>
</Card>
<Card title={t('daemon.security.title')}>
<Row
label={t('daemon.security.token')}
value={
security.tokenConfigured
? t('daemon.security.configured')
: t('daemon.security.notConfigured')
}
/>
<Row
label={t('daemon.security.requireAuth')}
value={
security.requireAuth ? t('common.enabled') : t('common.disabled')
}
/>
<Row
label={t('daemon.security.loopback')}
value={
security.loopbackBind ? t('common.enabled') : t('common.disabled')
}
/>
<Row
label={t('daemon.security.allowOrigin')}
value={security.allowOriginMode}
/>
<Row
label={t('daemon.security.shell')}
value={
security.sessionShellCommandEnabled
? t('common.enabled')
: t('common.disabled')
}
/>
</Card>
<Card title={t('daemon.limits.title')}>
<Row
label={t('daemon.limits.maxSessions')}
value={limitValue(limits.maxSessions)}
/>
<Row
label={t('daemon.limits.maxPendingPrompts')}
value={limitValue(limits.maxPendingPromptsPerSession)}
/>
<Row
label={t('daemon.limits.maxConnections')}
value={limitValue(limits.listenerMaxConnections)}
/>
<Row
label={t('daemon.limits.eventRing')}
value={limits.eventRingSize}
/>
<Row
label={t('daemon.limits.promptDeadline')}
value={
limits.promptDeadlineMs === null
? t('daemon.limits.unlimited')
: formatDurationMs(limits.promptDeadlineMs)
}
/>
<Row
label={t('daemon.limits.sessionIdle')}
value={formatDurationMs(limits.sessionIdleTimeoutMs)}
/>
</Card>
<Card
title={
capabilities.features.length
? t('daemon.capabilities.titleCount', {
count: capabilities.features.length,
})
: t('daemon.capabilities.title')
}
>
{capabilities.features.length === 0 ? (
<span className={styles.empty}>{t('daemon.none')}</span>
) : (
<div className={styles.featureChips}>
{[...capabilities.features].sort().map((feature) => (
<span key={feature} className={styles.featureChip}>
{feature}
</span>
))}
</div>
)}
</Card>
</div>
{/* 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. */}
<ErrorBoundary
label="daemon-status-detail"
fallback={
<div className={styles.empty}>{t('daemon.details.failed')}</div>
}
>
{fullReport?.full ? (
<FullDetail report={fullReport} />
) : full.loading ? (
<div className={styles.empty}>{t('daemon.details.loading')}</div>
) : full.error ? (
<div className={styles.empty}>
{t('daemon.details.failed')}: {full.error.message}
</div>
) : (
// Fetch resolved but the daemon omitted the `full` section — don't
// hang on the loading placeholder forever.
<div className={styles.empty}>{t('daemon.details.failed')}</div>
)}
</ErrorBoundary>
</div>
);
}
// 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 (
<ErrorBoundary
label="daemon-status"
fallback={(error) => (
<div className={styles.dialog}>
<div className={styles.empty}>
{t('daemon.loadFailed')}: {error.message}
</div>
</div>
)}
>
<DaemonStatusDialogInner />
</ErrorBoundary>
);
}

View file

@ -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}
/>
</I18nProvider>,
);
@ -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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[aria-label="Daemon Status"]',
);
expect(button).not.toBeNull();
act(() => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onOpenDaemonStatus).toHaveBeenCalledTimes(1);
});
});

View file

@ -33,6 +33,7 @@ interface WebShellSidebarProps {
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onOpenSettings: () => void;
onOpenDaemonStatus: () => void;
onNewSession: () => Promise<boolean> | boolean;
onLoadSession: (sessionId: string) => Promise<void> | void;
onError: (error: unknown, fallback: string) => void;
@ -136,6 +137,14 @@ function IconSettings() {
);
}
function IconPulse() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 12h4l3-8 4 16 3-8h4" />
</svg>
);
}
function IconRename() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
@ -173,6 +182,7 @@ export function WebShellSidebar({
collapsed,
onCollapsedChange,
onOpenSettings,
onOpenDaemonStatus,
onNewSession,
onLoadSession,
onError,
@ -953,6 +963,15 @@ export function WebShellSidebar({
{versionLabel}
</span>
)}
<button
className={styles.collapseButton}
type="button"
title={t('sidebar.daemonStatus')}
aria-label={t('sidebar.daemonStatus')}
onClick={onOpenDaemonStatus}
>
<IconPulse />
</button>
{!mobileOpen && (
<button
className={styles.collapseButton}

View file

@ -309,6 +309,78 @@ const EN: Messages = {
'contextUsage.tokens': 'tokens',
'contextUsage.usageByCategory': 'Usage by category',
'contextUsage.used': 'Used',
'daemon.title': 'Daemon Status',
'daemon.details.loading': 'Loading diagnostics...',
'daemon.details.failed': 'Failed to load diagnostics.',
'daemon.refresh': 'Refresh',
'daemon.loading': 'Loading daemon status...',
'daemon.loadFailed': 'Failed to load daemon status',
'daemon.updatedAt': (v) => `Updated ${v?.time ?? ''}`,
'daemon.none': 'none',
'daemon.level.ok': 'OK',
'daemon.level.warning': 'Warning',
'daemon.level.error': 'Error',
'daemon.level.unavailable': 'Unavailable',
'daemon.issues.title': 'Issues',
'daemon.overview.title': 'Daemon',
'daemon.overview.version': 'Version',
'daemon.overview.pid': 'PID',
'daemon.overview.mode': 'Mode',
'daemon.overview.uptime': 'Uptime',
'daemon.overview.workspace': 'Workspace',
'daemon.runtime.title': 'Runtime',
'daemon.runtime.activeSessions': 'Active sessions',
'daemon.runtime.activePrompts': 'Active prompts',
'daemon.runtime.idle': 'Idle for',
'daemon.runtime.noActivity': 'no activity yet',
'daemon.runtime.pendingPermissions': 'Pending permissions',
'daemon.runtime.permissionPolicy': 'Permission policy',
'daemon.runtime.channel': 'ACP channel',
'daemon.runtime.channelLive': 'live',
'daemon.runtime.channelDown': 'down',
'daemon.runtime.startingUp': 'Runtime is starting...',
'daemon.runtime.startFailed': 'Runtime failed to start',
'daemon.runtime.channelWorker': 'Channel worker',
'daemon.runtime.channelWorkerRestarts': 'Worker restarts',
'daemon.runtime.memory': 'Memory (RSS / heap)',
'daemon.transport.title': 'Transport',
'daemon.transport.restSse': 'REST SSE streams',
'daemon.transport.acpDisabled': 'ACP transport disabled',
'daemon.transport.acpConnections': 'ACP connections',
'daemon.transport.acpStreams': 'ACP streams (session/SSE/WS)',
'daemon.transport.pendingRequests': 'Pending client requests',
'daemon.transport.rateLimitRejected': 'Rate-limit rejects',
'daemon.security.title': 'Security',
'daemon.security.token': 'Bearer token',
'daemon.security.requireAuth': 'Require auth',
'daemon.security.loopback': 'Loopback bind',
'daemon.security.allowOrigin': 'Allowed origins',
'daemon.security.shell': 'Session shell commands',
'daemon.security.configured': 'configured',
'daemon.security.notConfigured': 'not configured',
'daemon.limits.title': 'Limits',
'daemon.limits.unlimited': 'unlimited',
'daemon.limits.maxSessions': 'Max sessions',
'daemon.limits.maxPendingPrompts': 'Max pending prompts / session',
'daemon.limits.maxConnections': 'Max listener connections',
'daemon.limits.eventRing': 'Event ring size',
'daemon.limits.promptDeadline': 'Prompt deadline',
'daemon.limits.sessionIdle': 'Session idle timeout',
'daemon.capabilities.title': 'Capabilities',
'daemon.capabilities.titleCount': (v) => `Capabilities (${v?.count ?? 0})`,
'daemon.full.sessions.title': 'Sessions',
'daemon.full.sessions.empty': 'No active sessions',
'daemon.full.session.pendingPrompts': (v) =>
`${v?.count ?? 0} pending prompts`,
'daemon.full.session.pendingPermissions': (v) =>
`${v?.count ?? 0} pending permissions`,
'daemon.full.session.prompting': 'prompting',
'daemon.full.workspace.title': 'Workspace Diagnostics',
'daemon.full.workspace.empty': 'No workspace diagnostics reported',
'daemon.full.auth.title': 'Auth',
'daemon.full.auth.providers': 'Device-flow providers',
'daemon.full.auth.pending': 'Pending device flows',
'daemon.full.acp.title': 'ACP connections',
'delete.cannotCurrent': 'Cannot delete the current active session.',
'delete.action': 'Delete',
'delete.deleted': 'Session deleted.',
@ -421,6 +493,7 @@ const EN: Messages = {
'sidebar.project': 'Project',
'sidebar.projectFallback': 'Project',
'sidebar.settings': 'Settings',
'sidebar.daemonStatus': 'Daemon Status',
'sidebar.collapse': 'Collapse',
'sidebar.expand': 'Expand',
'sidebar.collapseProject': 'Collapse project',
@ -1544,6 +1617,78 @@ const ZH: Messages = {
'contextUsage.tokens': 'tokens',
'contextUsage.usageByCategory': '按类别统计',
'contextUsage.used': '已用',
'daemon.title': 'Daemon 状态',
'daemon.details.loading': '正在加载诊断信息...',
'daemon.details.failed': '诊断信息加载失败。',
'daemon.refresh': '刷新',
'daemon.loading': '正在加载 Daemon 状态...',
'daemon.loadFailed': '加载 Daemon 状态失败',
'daemon.updatedAt': (v) => `更新于 ${v?.time ?? ''}`,
'daemon.none': '无',
'daemon.level.ok': '正常',
'daemon.level.warning': '警告',
'daemon.level.error': '错误',
'daemon.level.unavailable': '不可用',
'daemon.issues.title': '问题',
'daemon.overview.title': 'Daemon',
'daemon.overview.version': '版本',
'daemon.overview.pid': 'PID',
'daemon.overview.mode': '模式',
'daemon.overview.uptime': '运行时长',
'daemon.overview.workspace': '工作区',
'daemon.runtime.title': '运行时',
'daemon.runtime.activeSessions': '活跃会话',
'daemon.runtime.activePrompts': '执行中的 prompt',
'daemon.runtime.idle': '空闲时长',
'daemon.runtime.noActivity': '暂无活动',
'daemon.runtime.pendingPermissions': '待审批权限',
'daemon.runtime.permissionPolicy': '权限策略',
'daemon.runtime.channel': 'ACP 通道',
'daemon.runtime.channelLive': '在线',
'daemon.runtime.channelDown': '离线',
'daemon.runtime.startingUp': '运行时启动中...',
'daemon.runtime.startFailed': '运行时启动失败',
'daemon.runtime.channelWorker': '通道 Worker',
'daemon.runtime.channelWorkerRestarts': 'Worker 重启次数',
'daemon.runtime.memory': '内存 (RSS / 堆)',
'daemon.transport.title': '传输',
'daemon.transport.restSse': 'REST SSE 流',
'daemon.transport.acpDisabled': 'ACP 传输未启用',
'daemon.transport.acpConnections': 'ACP 连接',
'daemon.transport.acpStreams': 'ACP 流 (会话/SSE/WS)',
'daemon.transport.pendingRequests': '待处理客户端请求',
'daemon.transport.rateLimitRejected': '限流拒绝数',
'daemon.security.title': '安全',
'daemon.security.token': 'Bearer 令牌',
'daemon.security.requireAuth': '强制鉴权',
'daemon.security.loopback': '回环绑定',
'daemon.security.allowOrigin': '允许来源',
'daemon.security.shell': '会话 Shell 命令',
'daemon.security.configured': '已配置',
'daemon.security.notConfigured': '未配置',
'daemon.limits.title': '限制',
'daemon.limits.unlimited': '无限制',
'daemon.limits.maxSessions': '最大会话数',
'daemon.limits.maxPendingPrompts': '每会话最大待处理 prompt 数',
'daemon.limits.maxConnections': '最大监听连接数',
'daemon.limits.eventRing': '事件环大小',
'daemon.limits.promptDeadline': 'Prompt 截止时间',
'daemon.limits.sessionIdle': '会话空闲超时',
'daemon.capabilities.title': '能力',
'daemon.capabilities.titleCount': (v) => `能力 (${v?.count ?? 0})`,
'daemon.full.sessions.title': '会话',
'daemon.full.sessions.empty': '无活跃会话',
'daemon.full.session.pendingPrompts': (v) =>
`${v?.count ?? 0} 个待处理 prompt`,
'daemon.full.session.pendingPermissions': (v) =>
`${v?.count ?? 0} 个待审批权限`,
'daemon.full.session.prompting': '执行中',
'daemon.full.workspace.title': '工作区诊断',
'daemon.full.workspace.empty': '未上报工作区诊断',
'daemon.full.auth.title': '认证',
'daemon.full.auth.providers': '设备码登录提供方',
'daemon.full.auth.pending': '进行中的设备码登录',
'daemon.full.acp.title': 'ACP 连接',
'delete.cannotCurrent': '无法删除当前活动会话。',
'delete.action': '删除',
'delete.deleted': '会话已删除。',
@ -1646,6 +1791,7 @@ const ZH: Messages = {
'sidebar.project': '项目',
'sidebar.projectFallback': '项目',
'sidebar.settings': '设置',
'sidebar.daemonStatus': 'Daemon 状态',
'sidebar.collapse': '收起',
'sidebar.expand': '展开',
'sidebar.collapseProject': '收起项目',

View file

@ -66,6 +66,11 @@ export default defineConfig(({ command }) => ({
proxy: {
'/health': daemonProxy,
'/capabilities': daemonProxy,
// Daemon status report; scoped to the exact route the dashboard uses (a
// bare `/daemon` prefix would proxy unrelated `/daemon/*` paths). Without
// it the SPA fallback answers with index.html and the dialog fails JSON
// parsing in dev.
'/daemon/status': daemonProxy,
'/session': daemonProxy,
'/permission': daemonProxy,
'/workspace': daemonProxy,

View file

@ -100,6 +100,11 @@ export { useDaemonSessions as useSessions } from './daemon/index.js';
/** Available slash-command skills. */
export { useDaemonSkills as useSkills } from './daemon/index.js';
/** Consolidated daemon status report (`GET /daemon/status`). */
export { useDaemonStatusReport as useStatusReport } from './daemon/index.js';
/** Options for `useStatusReport` (detail level + resource load flags). */
export type { DaemonStatusReportOptions as StatusReportOptions } from './daemon/index.js';
/** Registered tools and their configuration. */
export { useDaemonTools as useTools } from './daemon/index.js';
@ -276,6 +281,18 @@ export type {
export type {
/** Session list entry: id, title, timestamps, client count, active prompt flag. */
DaemonSessionSummary,
/** Daemon status report envelope from `GET /daemon/status`. */
DaemonStatusReport,
/** Status report detail level: `'summary' | 'full'`. */
DaemonStatusReportDetail,
/** One triage finding in the daemon status rollup. */
DaemonStatusReportIssue,
/** Overall daemon health rollup: `'ok' | 'warning' | 'error'`. */
DaemonStatusReportLevel,
/** Per-section workspace diagnostics in a `detail=full` report. */
DaemonStatusReportSection,
/** Per-session diagnostics row in a `detail=full` report. */
DaemonStatusReportSession,
/** Full agent detail including system prompt, tools, and run config. */
DaemonWorkspaceAgentDetail,
/** Agent list entry: name, description, level, model, builtin flag. */

View file

@ -75,6 +75,7 @@ export {
useDaemonResource,
useDaemonSessions,
useDaemonSkills,
useDaemonStatusReport,
useDaemonTools,
useDaemonSettings,
} from './workspace/index.js';
@ -85,6 +86,7 @@ export type {
DaemonGlobOptions,
DaemonGlobResult,
DaemonResourceOptions,
DaemonStatusReportOptions,
DaemonWorkspaceActions,
DaemonWorkspaceContextValue,
DaemonWorkspaceProviderProps,
@ -131,6 +133,12 @@ export type {
DaemonSessionStatsStatus,
DaemonSessionStatsToolByName,
DaemonSessionSummary,
DaemonStatusReport,
DaemonStatusReportDetail,
DaemonStatusReportIssue,
DaemonStatusReportLevel,
DaemonStatusReportSection,
DaemonStatusReportSession,
DaemonWorkspaceAgentDetail,
DaemonWorkspaceAgentSummary,
DaemonWorkspaceMcpServerStatus,

View file

@ -36,6 +36,7 @@ const sdkMocks = vi.hoisted(() => {
const workspaceProviders = vi.fn();
const listWorkspaceSessions = vi.fn();
const deleteSessionsData = vi.fn();
const daemonStatus = vi.fn();
class MockDaemonClient {
constructor(_opts: unknown) {}
@ -58,6 +59,7 @@ const sdkMocks = vi.hoisted(() => {
workspaceProviders = workspaceProviders;
listWorkspaceSessions = listWorkspaceSessions;
deleteSessionsData = deleteSessionsData;
daemonStatus = daemonStatus;
dispose = vi.fn();
}
@ -81,6 +83,7 @@ const sdkMocks = vi.hoisted(() => {
workspaceProviders,
listWorkspaceSessions,
deleteSessionsData,
daemonStatus,
reset() {
capabilities.mockReset();
capabilities.mockResolvedValue({
@ -163,6 +166,13 @@ const sdkMocks = vi.hoisted(() => {
notFound: [],
errors: [],
});
daemonStatus.mockReset();
daemonStatus.mockResolvedValue({
v: 1,
detail: 'summary',
status: 'ok',
issues: [],
});
},
};
});
@ -551,4 +561,42 @@ describe('DaemonWorkspaceProvider', () => {
's-3',
]);
});
it('actions.loadDaemonStatus forwards the detail level to client.daemonStatus', async () => {
const report = {
v: 1,
detail: 'full',
status: 'warning',
issues: [
{
code: 'pending_permissions',
severity: 'warning',
message: '2 pending permissions',
},
],
};
sdkMocks.daemonStatus.mockResolvedValueOnce(report);
let actions: DaemonWorkspaceActions | undefined;
function Harness() {
const workspace = useOptionalDaemonWorkspace();
actions = workspace?.actions;
return null;
}
await renderWithProvider(<Harness />);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
if (!actions) throw new Error('actions not defined');
let result: unknown;
await act(async () => {
result = await actions!.loadDaemonStatus('full');
});
expect(result).toEqual(report);
expect(sdkMocks.daemonStatus).toHaveBeenCalledWith('full');
});
});

View file

@ -141,6 +141,14 @@ export function createDaemonWorkspaceActions({
);
},
async loadDaemonStatus(detail) {
const client = requireClient(getClient, 'Load daemon status failed');
return withActionTimeout(
client.daemonStatus(detail),
'Load daemon status timed out',
);
},
async loadSkillsStatus() {
const client = requireClient(getClient, 'Load skills failed');
return withActionTimeout(

View file

@ -14,5 +14,9 @@ export { useDaemonMemory } from './useDaemonMemory.js';
export { useDaemonResource } from './useDaemonResource.js';
export { useDaemonSessions } from './useDaemonSessions.js';
export { useDaemonSkills } from './useDaemonSkills.js';
export {
useDaemonStatusReport,
type DaemonStatusReportOptions,
} from './useDaemonStatusReport.js';
export { useDaemonTools } from './useDaemonTools.js';
export { useDaemonSettings } from './useDaemonSettings.js';

View file

@ -0,0 +1,86 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The real provider memoizes its actions object; mirror that with a stable
// reference so `load`/`reload` identities stay put (a fresh object each render
// would loop the resource effect).
const { loadDaemonStatus, actions } = vi.hoisted(() => {
const fn = vi.fn();
return { loadDaemonStatus: fn, actions: { loadDaemonStatus: fn } };
});
vi.mock('../DaemonWorkspaceProvider.js', () => ({
useDaemonWorkspaceActions: () => actions,
}));
const { useDaemonStatusReport } = await import('./useDaemonStatusReport.js');
describe('useDaemonStatusReport', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
loadDaemonStatus.mockReset();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('exposes the loaded data through the `report` alias', async () => {
// Guards the alias itself: the dialog reads `.report`, but its own test
// mocks the whole hook, so only this real-hook test catches a regression
// where `report: result.data` is dropped as "redundant".
const report = { v: 1, detail: 'summary', status: 'ok', issues: [] };
loadDaemonStatus.mockResolvedValue(report);
let result: ReturnType<typeof useDaemonStatusReport> | undefined;
function TestComponent() {
result = useDaemonStatusReport({ autoLoad: true });
return null;
}
await act(async () => {
root.render((<TestComponent />) as ReactNode);
});
expect(result?.report).toBe(report);
expect(result?.report).toBe(result?.data);
});
it('forwards the detail level to loadDaemonStatus (default summary)', async () => {
loadDaemonStatus.mockResolvedValue({ v: 1, detail: 'full' });
function Full() {
useDaemonStatusReport({ autoLoad: true, detail: 'full' });
return null;
}
await act(async () => {
root.render((<Full />) as ReactNode);
});
expect(loadDaemonStatus).toHaveBeenCalledWith('full');
loadDaemonStatus.mockClear();
loadDaemonStatus.mockResolvedValue({ v: 1, detail: 'summary' });
function Default() {
useDaemonStatusReport({ autoLoad: true });
return null;
}
await act(async () => {
root.render((<Default />) as ReactNode);
});
expect(loadDaemonStatus).toHaveBeenCalledWith('summary');
});
});

View file

@ -0,0 +1,30 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback } from 'react';
import type { DaemonStatusReportDetail } from '@qwen-code/sdk/daemon';
import { useDaemonWorkspaceActions } from '../DaemonWorkspaceProvider.js';
import type { DaemonResourceOptions } from '../types.js';
import { useDaemonResource } from './useDaemonResource.js';
export interface DaemonStatusReportOptions extends DaemonResourceOptions {
/** Detail level to request; defaults to the cheap `summary` view. */
detail?: DaemonStatusReportDetail;
}
export function useDaemonStatusReport(options: DaemonStatusReportOptions = {}) {
const { detail = 'summary', ...resourceOptions } = options;
const workspaceActions = useDaemonWorkspaceActions();
const load = useCallback(
() => workspaceActions.loadDaemonStatus(detail),
[workspaceActions, detail],
);
const result = useDaemonResource(load, resourceOptions);
return {
...result,
report: result.data,
};
}

View file

@ -35,6 +35,8 @@ export {
useDaemonResource,
useDaemonSessions,
useDaemonSkills,
useDaemonStatusReport,
useDaemonTools,
useDaemonSettings,
} from './hooks/index.js';
export type { DaemonStatusReportOptions } from './hooks/index.js';

View file

@ -51,6 +51,8 @@ import type {
DaemonWorkspaceSettingsStatus,
DaemonSettingUpdateResult,
DaemonSessionSummary,
DaemonStatusReport,
DaemonStatusReportDetail,
DaemonWriteMemoryRequest,
DaemonWriteMemoryResult,
} from '@qwen-code/sdk/daemon';
@ -164,6 +166,11 @@ export interface DaemonWorkspaceActions {
action: DaemonMcpManageAction,
): Promise<DaemonMcpManageResult>;
// Daemon status (read-only)
loadDaemonStatus(
detail?: DaemonStatusReportDetail,
): Promise<DaemonStatusReport>;
// Skills (read-only)
loadSkillsStatus(): Promise<DaemonWorkspaceSkillsStatus>;