mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
5dc2e1501f
commit
9b2fb30cb0
23 changed files with 2432 additions and 1 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -423,6 +423,12 @@ export type {
|
|||
DaemonPreflightKind,
|
||||
DaemonStatus,
|
||||
DaemonStatusCell,
|
||||
DaemonStatusReport,
|
||||
DaemonStatusReportDetail,
|
||||
DaemonStatusReportIssue,
|
||||
DaemonStatusReportLevel,
|
||||
DaemonStatusReportSection,
|
||||
DaemonStatusReportSession,
|
||||
DaemonUpdateAgentRequest,
|
||||
DaemonContentHash,
|
||||
DaemonWorkspaceAgentDetail,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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': '收起项目',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -35,6 +35,8 @@ export {
|
|||
useDaemonResource,
|
||||
useDaemonSessions,
|
||||
useDaemonSkills,
|
||||
useDaemonStatusReport,
|
||||
useDaemonTools,
|
||||
useDaemonSettings,
|
||||
} from './hooks/index.js';
|
||||
export type { DaemonStatusReportOptions } from './hooks/index.js';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue