mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
* fix(web-shell): i18n for ~43 hardcoded English strings across 15 files Multi-round audit replaced hardcoded UI strings with t() calls and added en/zh-CN i18n keys for: - Session timeline labels, aria-labels, and kind labels (MessageList) - SubAgent tab labels, tools count, pending/running status - Auth protocol option labels, placeholders, API key masking - Voice dictation UI states (VoiceButton) - Copy tooltip, Runtime badge, N/A, Server/Agent fallbacks - Mermaid code block labels, image alt text, model switch summary * fix(web-shell): i18n for missed models placeholder in AuthMessage * fix(web-shell): invalidate timeline cache on locale switch Store the t function reference in sessionTimelineCache alongside the message signature. When the locale changes, t gets a new reference, triggering cache invalidation and re-generating entries with the new language labels.
1011 lines
35 KiB
TypeScript
1011 lines
35 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type KeyboardEvent,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import {
|
|
useStatusReport,
|
|
type DaemonMetricsSeriesBucket,
|
|
type DaemonStatusReport,
|
|
type DaemonStatusReportLevel,
|
|
type DaemonStatusReportSection,
|
|
} from '@qwen-code/webui/daemon-react-sdk';
|
|
import { useI18n } from '../../i18n';
|
|
import { ErrorBoundary } from '../ErrorBoundary';
|
|
import { SvgLineChart, type ChartSeries } from './SvgLineChart';
|
|
import { UsageDashboardTab } from './UsageDashboardTab';
|
|
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;
|
|
|
|
// The dashboard splits into tabs once it carries live charts: monitoring
|
|
// (charts you watch), configuration (static cards you glance at), and
|
|
// diagnostics (sessions/workspace you open when something is wrong) are
|
|
// different intents — and 6 cards + 7 charts + diagnostics overflow one 70vh
|
|
// scroll. Status badge / refresh / issues stay global above the tabs.
|
|
type DaemonTab = 'overview' | 'usage' | 'metrics' | 'diagnostics';
|
|
const DAEMON_TABS: ReadonlyArray<{ id: DaemonTab; labelKey: string }> = [
|
|
{ id: 'overview', labelKey: 'daemon.tab.overview' },
|
|
{ id: 'usage', labelKey: 'daemon.tab.usage' },
|
|
{ id: 'metrics', labelKey: 'daemon.tab.metrics' },
|
|
{ id: 'diagnostics', labelKey: 'daemon.tab.diagnostics' },
|
|
];
|
|
|
|
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 {
|
|
// Adaptive unit: sub-MB windows (idle-pipe keep-alive traffic) would read a
|
|
// misleading "0.0 MB", so drop to KB/B and show a nonzero value. RSS/heap are
|
|
// always ≥ 1 MB, so those charts stay in MB/GB unchanged.
|
|
const kb = bytes / 1024;
|
|
if (kb < 1) return `${Math.round(bytes)} B`;
|
|
const mb = kb / 1024;
|
|
if (mb < 1) return `${kb.toFixed(kb < 10 ? 1 : 0)} KB`;
|
|
return mb >= 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(1)} MB`;
|
|
}
|
|
|
|
// Compact counts for chart peaks/current values: thousands collapse to "12.3k"
|
|
// so token burn and request counts stay legible in the narrow legend.
|
|
function formatCount(value: number): string {
|
|
const n = Math.round(value);
|
|
if (n >= 10_000) {
|
|
return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
|
|
}
|
|
return n.toLocaleString();
|
|
}
|
|
|
|
function formatPercent(value: number): string {
|
|
return `${value.toFixed(value >= 100 || value === 0 ? 0 : 1)}%`;
|
|
}
|
|
|
|
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 ? t('common.na') : 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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Bottleneck-analysis dashboard: the daemon samples load, throughput, latency,
|
|
// resource pressure, and token burn into one time-bucketed series, so these
|
|
// charts share an x-axis. Lining up "N tasks running at once" (concurrency)
|
|
// against event-loop lag, queue wait, memory, and API latency shows *where* a
|
|
// busy daemon is actually stalling.
|
|
function MetricsCharts({ series }: { series: DaemonMetricsSeriesBucket[] }) {
|
|
const { t } = useI18n();
|
|
if (series.length === 0) {
|
|
return (
|
|
<Card title={t('daemon.charts.title')}>
|
|
<div className={styles.empty}>{t('daemon.charts.empty')}</div>
|
|
</Card>
|
|
);
|
|
}
|
|
const col = (pick: (b: DaemonMetricsSeriesBucket) => number): number[] =>
|
|
series.map(pick);
|
|
// Bucket timestamps drive the hover tooltip's time header.
|
|
const times = series.map((b) => b.t);
|
|
const chart = (
|
|
titleKey: string,
|
|
format: (v: number) => string,
|
|
lines: ChartSeries[],
|
|
): ReactNode => (
|
|
<Card title={t(titleKey)}>
|
|
<SvgLineChart
|
|
series={lines}
|
|
timestamps={times}
|
|
format={format}
|
|
ariaLabel={t(titleKey)}
|
|
peakLabel={t('daemon.charts.peak')}
|
|
/>
|
|
</Card>
|
|
);
|
|
return (
|
|
<div className={`${styles.grid} ${styles.chartsGrid}`}>
|
|
{chart('daemon.charts.concurrency', formatCount, [
|
|
{
|
|
label: t('daemon.charts.activePrompts'),
|
|
values: col((b) => b.activePrompts),
|
|
color: 'var(--primary)',
|
|
},
|
|
{
|
|
label: t('daemon.charts.queuedPrompts'),
|
|
values: col((b) => b.queuedPrompts),
|
|
color: 'var(--warning-color)',
|
|
},
|
|
{
|
|
label: t('daemon.charts.activeSessions'),
|
|
values: col((b) => b.activeSessions),
|
|
color: 'var(--muted-foreground)',
|
|
},
|
|
])}
|
|
{chart('daemon.charts.requests', formatCount, [
|
|
{
|
|
label: t('daemon.charts.reqTotal'),
|
|
values: col((b) => b.requests),
|
|
color: 'var(--success-color)',
|
|
},
|
|
{
|
|
label: t('daemon.charts.reqErrors'),
|
|
values: col((b) => b.errors),
|
|
color: 'var(--error-color)',
|
|
},
|
|
{
|
|
label: t('daemon.charts.reqRejected'),
|
|
values: col((b) => b.rateLimitRejected),
|
|
color: 'var(--warning-color)',
|
|
},
|
|
])}
|
|
{chart('daemon.charts.apiLatency', formatDurationMs, [
|
|
{
|
|
label: 'p50',
|
|
values: col((b) => b.latencyP50Ms),
|
|
color: 'var(--agent-blue-400)',
|
|
},
|
|
{
|
|
label: 'p95',
|
|
values: col((b) => b.latencyP95Ms),
|
|
color: 'var(--warning-color)',
|
|
},
|
|
])}
|
|
{chart('daemon.charts.llmLatency', formatDurationMs, [
|
|
{
|
|
label: 'p50',
|
|
values: col((b) => b.llmApiP50Ms),
|
|
color: 'var(--agent-blue-400)',
|
|
},
|
|
{
|
|
label: 'p95',
|
|
values: col((b) => b.llmApiP95Ms),
|
|
color: 'var(--primary)',
|
|
},
|
|
])}
|
|
{chart('daemon.charts.promptLatency', formatDurationMs, [
|
|
{
|
|
label: t('daemon.charts.queueWait'),
|
|
values: col((b) => b.promptQueueWaitP95Ms),
|
|
color: 'var(--warning-color)',
|
|
},
|
|
{
|
|
label: t('daemon.charts.promptDuration'),
|
|
values: col((b) => b.promptDurationP95Ms),
|
|
color: 'var(--primary)',
|
|
},
|
|
])}
|
|
{chart('daemon.charts.eventLoop', formatDurationMs, [
|
|
{
|
|
label: t('daemon.charts.eventLoopLag'),
|
|
values: col((b) => b.eventLoopLagP99Ms),
|
|
color: 'var(--error-color)',
|
|
},
|
|
])}
|
|
{chart('daemon.charts.cpu', formatPercent, [
|
|
{
|
|
label: t('daemon.charts.cpuDaemon'),
|
|
values: col((b) => b.cpuPercent),
|
|
color: 'var(--muted-foreground)',
|
|
},
|
|
{
|
|
label: t('daemon.charts.cpuChild'),
|
|
values: col((b) => b.childCpuPercent),
|
|
color: 'var(--primary)',
|
|
},
|
|
])}
|
|
{chart('daemon.charts.memory', formatBytes, [
|
|
{
|
|
label: t('daemon.charts.rssDaemon'),
|
|
values: col((b) => b.rssBytes),
|
|
color: 'var(--muted-foreground)',
|
|
},
|
|
{
|
|
label: t('daemon.charts.heap'),
|
|
values: col((b) => b.heapUsedBytes),
|
|
color: 'var(--agent-blue-400)',
|
|
},
|
|
{
|
|
label: t('daemon.charts.rssChild'),
|
|
values: col((b) => b.childRssBytes),
|
|
color: 'var(--primary)',
|
|
},
|
|
])}
|
|
{chart('daemon.charts.pipe', formatBytes, [
|
|
{
|
|
label: t('daemon.charts.pipeIn'),
|
|
values: col((b) => b.pipeInBytes),
|
|
color: 'var(--agent-blue-400)',
|
|
},
|
|
{
|
|
label: t('daemon.charts.pipeOut'),
|
|
values: col((b) => b.pipeOutBytes),
|
|
color: 'var(--success-color)',
|
|
},
|
|
])}
|
|
{chart('daemon.charts.connections', formatCount, [
|
|
{
|
|
label: 'SSE',
|
|
values: col((b) => b.sseConnections),
|
|
color: 'var(--primary)',
|
|
},
|
|
{
|
|
label: 'WS',
|
|
values: col((b) => b.wsConnections),
|
|
color: 'var(--agent-blue-400)',
|
|
},
|
|
{
|
|
label: 'ACP',
|
|
values: col((b) => b.acpConnections),
|
|
color: 'var(--muted-foreground)',
|
|
},
|
|
])}
|
|
{chart('daemon.charts.tokens', formatCount, [
|
|
{
|
|
label: t('daemon.charts.tokensIn'),
|
|
values: col((b) => b.tokensIn),
|
|
color: 'var(--agent-blue-400)',
|
|
},
|
|
{
|
|
label: t('daemon.charts.tokensOut'),
|
|
values: col((b) => b.tokensOut),
|
|
color: 'var(--success-color)',
|
|
},
|
|
])}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DaemonStatusDialogInner() {
|
|
const { t } = useI18n();
|
|
const [activeTab, setActiveTab] = useState<DaemonTab>('overview');
|
|
// WAI-ARIA tabs keyboard support: roving tabindex (only the active tab is in
|
|
// the tab order) + Arrow/Home/End moving focus and selection across the
|
|
// tablist, so keyboard-only users can switch tabs without tabbing through all
|
|
// the panel content in between.
|
|
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
const handleTabKeyDown = (
|
|
e: KeyboardEvent<HTMLButtonElement>,
|
|
index: number,
|
|
): void => {
|
|
let next: number;
|
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
next = (index + 1) % DAEMON_TABS.length;
|
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
next = (index - 1 + DAEMON_TABS.length) % DAEMON_TABS.length;
|
|
} else if (e.key === 'Home') {
|
|
next = 0;
|
|
} else if (e.key === 'End') {
|
|
next = DAEMON_TABS.length - 1;
|
|
} else {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
setActiveTab(DAEMON_TABS[next].id);
|
|
tabRefs.current[next]?.focus();
|
|
};
|
|
// 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.tabs}
|
|
role="tablist"
|
|
aria-label={t('daemon.title')}
|
|
>
|
|
{DAEMON_TABS.map((tab, index) => (
|
|
<button
|
|
key={tab.id}
|
|
ref={(el) => {
|
|
tabRefs.current[index] = el;
|
|
}}
|
|
type="button"
|
|
role="tab"
|
|
id={`daemon-tab-${tab.id}`}
|
|
aria-selected={tab.id === activeTab}
|
|
aria-controls={`daemon-tabpanel-${tab.id}`}
|
|
tabIndex={tab.id === activeTab ? 0 : -1}
|
|
className={`${styles.tab} ${
|
|
tab.id === activeTab ? styles.tabActive : ''
|
|
}`}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
|
>
|
|
{t(tab.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === 'overview' && (
|
|
<div
|
|
role="tabpanel"
|
|
id="daemon-tabpanel-overview"
|
|
aria-labelledby="daemon-tab-overview"
|
|
tabIndex={0}
|
|
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>
|
|
)}
|
|
|
|
{/* Aggregate token-usage dashboard (today's totals + 6-month heatmap).
|
|
Mounts only when active so the heavy aggregate loads on demand; a
|
|
crash in the payload is contained here, not the whole dialog. */}
|
|
{activeTab === 'usage' && (
|
|
<div
|
|
role="tabpanel"
|
|
id="daemon-tabpanel-usage"
|
|
aria-labelledby="daemon-tab-usage"
|
|
tabIndex={0}
|
|
>
|
|
<ErrorBoundary
|
|
label="daemon-usage"
|
|
fallback={
|
|
<div className={styles.empty}>{t('daemon.usage.failed')}</div>
|
|
}
|
|
>
|
|
<UsageDashboardTab />
|
|
</ErrorBoundary>
|
|
</div>
|
|
)}
|
|
|
|
{/* Time-series charts for bottleneck analysis. Driven by the
|
|
continuously-refreshed summary report so the curves advance on every
|
|
poll; the daemon retains the history, so it survives dialog close. */}
|
|
{activeTab === 'metrics' && (
|
|
<div
|
|
role="tabpanel"
|
|
id="daemon-tabpanel-metrics"
|
|
aria-labelledby="daemon-tab-metrics"
|
|
tabIndex={0}
|
|
>
|
|
<MetricsCharts series={report.runtime.metrics?.series ?? []} />
|
|
</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. */}
|
|
{activeTab === 'diagnostics' && (
|
|
<div
|
|
role="tabpanel"
|
|
id="daemon-tabpanel-diagnostics"
|
|
aria-labelledby="daemon-tab-diagnostics"
|
|
tabIndex={0}
|
|
>
|
|
<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>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|