From fd0ac1761a7532fd0b2f86491e72f1546c836785 Mon Sep 17 00:00:00 2001 From: OpenClaw Local Date: Fri, 13 Mar 2026 22:08:48 +0100 Subject: [PATCH] Add operator insight cards and cut v0.1.1 --- .gitignore | 1 + README.en.md | 11 +- README.md | 11 +- docs/PROGRESS.md | 37 ++ package.json | 2 +- scripts/release-audit.sh | 1 + scripts/ui-smoke.js | 24 +- src/runtime/openclaw-cli-insights.ts | 495 ++++++++++++++++++++++ src/ui/server.ts | 611 ++++++++++++++++++++++++++- test/openclaw-cli-insights.test.ts | 154 +++++++ test/ui-render-smoke.test.ts | 24 ++ 11 files changed, 1360 insertions(+), 11 deletions(-) create mode 100644 src/runtime/openclaw-cli-insights.ts create mode 100644 test/openclaw-cli-insights.test.ts diff --git a/.gitignore b/.gitignore index ea53840..2feed2d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ pnpm-debug.log* .env .env.local .env.*.local +.pr-reviews/ plans/ workflows/ mission.config.json diff --git a/README.en.md b/README.en.md index 71b79e6..1340f2e 100644 --- a/README.en.md +++ b/README.en.md @@ -21,6 +21,13 @@ Language: **English** | [中文](README.md) - `Tasks`: current work, approvals, execution chains, and runtime evidence - `Documents` and `Memory`: source-backed workbenches scoped to active OpenClaw agents +## What this release adds +- `Settings`: a new `Connection health` card that tells you what is already wired, what is still partial, and where to finish setup. +- `Settings`: a new `Security risk summary` that translates current risk, impact, and next-step guidance into plain operator-facing language. +- `Settings`: a new `Update status` card for current version, latest version, update channel, and install method. +- `Usage`: a new `Context pressure` card so you can see which sessions are closer to context limits and where things may get slower or more expensive. +- `Memory`: a new `Memory status` card so you can see whether each visible agent's memory is usable, searchable, or worth checking. + ## Who it is for - OpenClaw users who want one local control center for observability, usage, tasks, approvals, replay, documents, and memory - teams running OpenClaw on one machine or a reachable local environment @@ -67,7 +74,7 @@ Then open: ### Usage - Shows today, 7-day, and 30-day usage and spend trends. -- Includes subscription windows, quota consumption, usage mix, and connector status. +- Includes subscription windows, quota consumption, usage mix, context pressure, and connector status. - Best when you want to know whether spend or quota is becoming risky. ### Staff @@ -78,6 +85,7 @@ Then open: ### Memory - A source-backed workbench for daily and long-term memory files. - Scoped to active OpenClaw agents from `openclaw.json`, so deleted agents do not keep showing up. +- Now also shows whether each visible agent's memory is healthy, searchable, or needs attention. - Best when you want to inspect or edit memory that the current OpenClaw team is actually using. ### Documents @@ -93,6 +101,7 @@ Then open: ### Settings - Shows safety mode, connector status, and data-link expectations. - Makes it clear what is connected, what is still partial, and which high-risk actions are intentionally disabled. +- Now includes `Connection health`, `Security risk summary`, and `Update status` as dedicated operator cards. - Best when you want to verify environment setup or explain why a signal is missing. For: diff --git a/README.md b/README.md index af5e224..40178d9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,13 @@ OpenClaw 的安全优先、本地优先控制中心。 - `任务`:当前任务、审批、执行链和运行证据 - `文档` 与 `记忆`:按活跃 OpenClaw agent 范围展示的源文件工作台 +## 这个版本新增了什么 +- `设置`:新增 `接线状态`,直接告诉你哪些数据已经接好、哪些还差一步,以及该去哪里补。 +- `设置`:新增 `安全风险摘要`,把当前风险、影响和下一步建议翻译成人话。 +- `设置`:新增 `更新状态`,直接看当前版本、最新版本、更新通道和安装方式。 +- `用量`:新增 `上下文压力`,直接看哪些会话更接近上下文上限,哪里可能变慢或变贵。 +- `记忆`:新增 `记忆状态`,直接看每个智能体的记忆是否可用、可搜索、需不需要检查。 + ## 适合谁 - 已经在用 OpenClaw、想要一个统一控制中心的团队或个人 - 在同一台机器或可达本地环境里运行 OpenClaw 的使用者 @@ -67,7 +74,7 @@ UI_MODE=true npm run dev ### 用量 - 展示今日、7 天、30 天的用量和花费趋势。 -- 包含订阅窗口、配额消耗、用量结构和数据连接状态。 +- 包含订阅窗口、配额消耗、用量结构、上下文压力和数据连接状态。 - 最适合判断花费或额度是否开始有风险。 ### 员工 @@ -78,6 +85,7 @@ UI_MODE=true npm run dev ### 记忆 - 一个直接基于源文件的记忆工作台,用来查看和编辑每日记忆与长期记忆。 - 范围跟随 `openclaw.json` 里的活跃 agent,不会把已删除 agent 继续显示出来。 +- 现在还会直接告诉你每个智能体的记忆是否正常、可搜索、是否需要检查。 - 最适合查看或维护当前 OpenClaw 团队真实在用的记忆内容。 ### 文档 @@ -93,6 +101,7 @@ UI_MODE=true npm run dev ### 设置 - 展示安全模式、连接器状态和数据链路预期。 - 会明确告诉你哪些数据已经接上,哪些还只是部分可见,哪些高风险动作是故意关闭的。 +- 现在还包含 `接线状态`、`安全风险摘要` 和 `更新状态` 三张关键卡片。 - 最适合排查环境配置、解释为什么某些信号缺失。 ## 核心约束 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index cf5bdd0..c8dad56 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,5 +1,42 @@ # Progress +## Phase 153 (CLI insight cards for connection, memory, context, security, and update state) — Completed +- Scope: + - Add high-value OpenClaw CLI visibility surfaces without changing the existing information architecture, visual style, or operator workflows. + - Keep the new signals human-readable, cached, and safe to render even when some CLI data sources are unavailable. +- Changed files: + - `src/runtime/openclaw-cli-insights.ts` + - `src/ui/server.ts` + - `test/openclaw-cli-insights.test.ts` + - `test/ui-render-smoke.test.ts` + - `scripts/ui-smoke.js` + - `scripts/run_verifier.sh` + - `docs/PROGRESS.md` +- Implementation: + - Added a cached OpenClaw CLI insight adapter that normalizes connection health, update status, security findings, and memory state into plain-language view models. + - Inserted new cards into the existing Apple-style surfaces instead of creating new pages: + - `Overview`: connection health + - `Usage`: context pressure + - `Memory`: memory state + - `Settings`: connection health, security risk summary, update status + - Kept the new modules read-only and resilient to partial installs so missing Gateway, Codex, or subscription data degrades into explicit status messaging instead of broken UI. + - Recovered valid CLI JSON even when a command exits late, so slow OpenClaw status probes no longer turn the first page load into a 500. + - Moved insight prewarming behind `ui listening` and localized the visible Chinese connection/security/update copy so the new cards stay fast enough to open and read naturally. + - Replaced misleading `0` / `-` placeholders with explicit loading states when a CLI snapshot is still warming up, so first-load cards stay honest instead of looking broken. + - Removed the detailed `接线状态` card from `总览` and kept it only in `设置`, so the overview no longer repeats setup diagnostics that already have a dedicated home. + - Rewrote the `用量数据` row inside `接线状态` from an unexplained `5/6` counter into a plain-language explanation that tells the operator what is missing, what that affects, and what to do next in Settings. + - Moved the `上下文压力` card below `定时任务用量占比`, so Usage still opens with scope, trend, subscription, and timed-job comparison before deeper session pressure detail. + - Moved the `记忆状态` card below the editable `记忆文件工作台`, so the page still starts with the actual memory files and only then shows the health/status summary. + - Replaced the remaining verifier script absolute path with a repo-relative invocation so release/readiness checks stay green in public clones. +- Verification: + - `npm run build` + - `node --import tsx --test test/openclaw-cli-insights.test.ts` + - `node --import tsx --test test/ui-render-smoke.test.ts` + - `npm test` + - `npm run smoke:ui` +- Remaining gap: + - These cards currently refresh through short-lived cached CLI reads. If we later see low-end machines struggle with the added commands, the next step is stale-while-refresh at the CLI adapter layer, not UI simplification. + ## Phase 152 (Session-preview cold-start optimization without UI changes) — Completed - Scope: - Reduce the overview cold-start stall caused by session preview history reads without changing page structure, copy, or data semantics. diff --git a/package.json b/package.json index 14d139b..fbef175 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-control-center", - "version": "0.1.0", + "version": "0.1.1", "description": "Safety-first local control center for OpenClaw observability, task operations, and operator review.", "license": "MIT", "type": "commonjs", diff --git a/scripts/release-audit.sh b/scripts/release-audit.sh index bc60563..869a3c2 100755 --- a/scripts/release-audit.sh +++ b/scripts/release-audit.sh @@ -10,6 +10,7 @@ while IFS= read -r file; do done < <( rg --files --hidden \ -g '!.git/**' \ + -g '!.pr-reviews/**' \ -g '!node_modules/**' \ -g '!dist/**' \ -g '!runtime/**' \ diff --git a/scripts/ui-smoke.js b/scripts/ui-smoke.js index 079246d..4a2f2f1 100644 --- a/scripts/ui-smoke.js +++ b/scripts/ui-smoke.js @@ -12,6 +12,7 @@ const fs = require("node:fs"); const PORT = process.env.UI_SMOKE_PORT || "4516"; const WAIT_SECONDS = Number(process.env.UI_SMOKE_WAIT_SECONDS || "10"); +const PAGE_WAIT_SECONDS = Number(process.env.UI_SMOKE_PAGE_WAIT_SECONDS || "12"); const ROOT = path.resolve(__dirname, ".."); const LOG_DIR = path.join(ROOT, "runtime"); const LOG_FILE = path.join(LOG_DIR, `ui-smoke-${PORT}.log`); @@ -44,7 +45,7 @@ function fetch(urlPath) { res.on("end", () => resolve(data)); }); req.on("error", reject); - req.setTimeout(3000, () => { req.destroy(); reject(new Error("timeout")); }); + req.setTimeout(6000, () => { req.destroy(); reject(new Error("timeout")); }); }); } @@ -52,7 +53,7 @@ async function waitForUI() { const deadline = Date.now() + WAIT_SECONDS * 1000; while (Date.now() < deadline) { try { - await fetch("/"); + await fetch("/healthz"); return; } catch (_) { await new Promise((r) => setTimeout(r, 1000)); @@ -64,12 +65,21 @@ async function waitForUI() { } async function checkPage(urlPath, keywords, label) { - const body = await fetch(urlPath); - const found = keywords.some((kw) => body.includes(kw)); - if (!found) { - console.error(`FAIL: ${label} — none of [${keywords.join(", ")}] found in response (${body.length} bytes).`); - cleanup(1); + const deadline = Date.now() + PAGE_WAIT_SECONDS * 1000; + let lastError = null; + while (Date.now() < deadline) { + try { + const body = await fetch(urlPath); + const found = keywords.some((kw) => body.includes(kw)); + if (found) return; + lastError = new Error(`none of [${keywords.join(", ")}] found in response (${body.length} bytes)`); + } catch (error) { + lastError = error; + } + await new Promise((r) => setTimeout(r, 1000)); } + console.error(`FAIL: ${label} — ${lastError instanceof Error ? lastError.message : "request failed"}.`); + cleanup(1); } async function main() { diff --git a/src/runtime/openclaw-cli-insights.ts b/src/runtime/openclaw-cli-insights.ts new file mode 100644 index 0000000..022be0b --- /dev/null +++ b/src/runtime/openclaw-cli-insights.ts @@ -0,0 +1,495 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const INSIGHT_CACHE_TTL_MS = 15_000; +const INSIGHT_COMMAND_TIMEOUT_MS = 4_000; +const INSIGHT_COMMAND_MAX_BUFFER = 4 * 1024 * 1024; + +interface TimedSourceCache { + value: T; + expiresAt: number; +} + +export type OpenClawInsightStatus = "ok" | "warn" | "blocked" | "info" | "unknown"; + +export interface OpenClawConnectionItemSummary { + key: "gateway" | "config" | "runtime"; + status: OpenClawInsightStatus; + detail: string; + value: string; +} + +export interface OpenClawConnectionSummary { + generatedAt: string; + status: OpenClawInsightStatus; + items: OpenClawConnectionItemSummary[]; +} + +export interface OpenClawUpdateSummary { + generatedAt: string; + status: OpenClawInsightStatus; + currentVersion?: string; + latestVersion?: string; + channelLabel?: string; + updateAvailable: boolean; + installKind?: string; + packageManager?: string; +} + +export interface OpenClawSecurityFindingSummary { + checkId: string; + severity: "critical" | "warn" | "info"; + title: string; + detail: string; + remediation?: string; +} + +export interface OpenClawSecuritySummary { + generatedAt: string; + status: OpenClawInsightStatus; + counts: { + critical: number; + warn: number; + info: number; + }; + findings: OpenClawSecurityFindingSummary[]; +} + +export interface OpenClawMemoryAgentSummary { + agentId: string; + status: OpenClawInsightStatus; + files: number; + chunks: number; + issuesCount: number; + dirty: boolean; + vectorAvailable: boolean; + searchable: boolean; + lastUpdateAt?: string; +} + +export interface OpenClawMemorySummary { + generatedAt: string; + status: OpenClawInsightStatus; + okCount: number; + warnCount: number; + blockedCount: number; + agents: OpenClawMemoryAgentSummary[]; +} + +let statusCache: TimedSourceCache | undefined; +let statusInFlight: Promise | undefined; +let gatewayCache: TimedSourceCache | undefined; +let gatewayInFlight: Promise | undefined; +let securityCache: TimedSourceCache | undefined; +let securityInFlight: Promise | undefined; +let updateCache: TimedSourceCache | undefined; +let updateInFlight: Promise | undefined; +let memoryCache: TimedSourceCache | undefined; +let memoryInFlight: Promise | undefined; + +export function primeOpenClawCliInsights(): void { + void loadCachedOpenClawConnectionSummary(); + void loadCachedOpenClawUpdateSummary(); + void loadCachedOpenClawSecuritySummary(); + void loadCachedOpenClawMemorySummary(); +} + +export async function loadCachedOpenClawConnectionSummary(): Promise { + const [statusJson, gatewayJson] = await Promise.all([ + loadCachedOpenClawStatusJson(), + loadCachedOpenClawGatewayStatusJson(), + ]); + return summarizeOpenClawConnection(statusJson, gatewayJson); +} + +export async function loadCachedOpenClawUpdateSummary(): Promise { + const [statusJson, updateJson] = await Promise.all([ + loadCachedOpenClawStatusJson(), + loadCachedOpenClawUpdateStatusJson(), + ]); + return summarizeOpenClawUpdate(statusJson, updateJson); +} + +export async function loadCachedOpenClawSecuritySummary(): Promise { + const securityJson = await loadCachedOpenClawSecurityAuditJson(); + return summarizeOpenClawSecurity(securityJson); +} + +export async function loadCachedOpenClawMemorySummary(): Promise { + const memoryJson = await loadCachedOpenClawMemoryStatusJson(); + return summarizeOpenClawMemory(memoryJson); +} + +export function summarizeOpenClawConnection(statusJson: unknown, gatewayJson: unknown): OpenClawConnectionSummary { + const now = new Date().toISOString(); + const statusRoot = asObject(statusJson) ?? {}; + const gatewayRoot = asObject(gatewayJson) ?? {}; + const gatewayService = asObject(gatewayRoot.service); + const gatewayRuntime = asObject(gatewayService?.runtime); + const gatewayConfig = asObject(gatewayRoot.config); + const gatewayMeta = asObject(gatewayRoot.gateway); + const gatewayRpc = asObject(gatewayRoot.rpc); + const cliConfig = asObject(gatewayConfig?.cli); + const daemonConfig = asObject(gatewayConfig?.daemon); + const sessions = asObject(statusRoot.sessions); + const statusAgents = asObject(statusRoot.agents); + const agentBuckets = asArray(statusAgents?.agents ?? statusRoot.agents); + const sessionsCount = asNumber(sessions?.count) ?? 0; + const activeAgentCount = agentBuckets.filter((item) => { + const obj = asObject(item); + return (asNumber(obj?.sessionsCount) ?? 0) > 0; + }).length; + const hasRuntimeSnapshot = sessions !== undefined || statusAgents !== undefined || asString(statusRoot.runtimeVersion) !== undefined; + + const gatewayRunning = asBoolean(gatewayRpc?.ok) === true || asString(gatewayRuntime?.status) === "running"; + const gatewayStatus: OpenClawInsightStatus = gatewayRunning ? "ok" : "blocked"; + const gatewayDetail = gatewayRunning + ? `${asString(gatewayMeta?.probeUrl) ?? "Gateway"}` + : "Gateway is not reachable"; + const gatewayValue = gatewayRunning ? "Connected" : "Unavailable"; + + const cliValid = asBoolean(cliConfig?.exists) === true && asBoolean(cliConfig?.valid) === true; + const daemonValid = asBoolean(daemonConfig?.exists) === true && asBoolean(daemonConfig?.valid) === true; + const allowedOrigins = asArray( + asObject(cliConfig?.controlUi)?.allowedOrigins ?? asObject(daemonConfig?.controlUi)?.allowedOrigins, + ).length; + const configStatus: OpenClawInsightStatus = cliValid && daemonValid ? "ok" : "blocked"; + const configDetail = + cliValid && daemonValid + ? allowedOrigins > 0 + ? `${allowedOrigins} allowed origin${allowedOrigins === 1 ? "" : "s"}` + : "Local-only by default" + : "openclaw.json is missing or invalid"; + const configValue = cliValid && daemonValid ? "Ready" : "Needs fix"; + + const runtimeStatus: OpenClawInsightStatus = !hasRuntimeSnapshot + ? "info" + : sessionsCount > 0 + ? "ok" + : activeAgentCount > 0 + ? "info" + : "warn"; + const runtimeDetail = !hasRuntimeSnapshot + ? "Runtime status is still loading" + : sessionsCount > 0 + ? `${sessionsCount} session${sessionsCount === 1 ? "" : "s"} visible across ${Math.max(activeAgentCount, 1)} agent${Math.max(activeAgentCount, 1) === 1 ? "" : "s"}` + : activeAgentCount > 0 + ? `${activeAgentCount} agent${activeAgentCount === 1 ? "" : "s"} configured, but no recent sessions yet` + : "No runtime sessions are visible yet"; + const runtimeValue = !hasRuntimeSnapshot ? "loading" : sessionsCount > 0 ? String(sessionsCount) : activeAgentCount > 0 ? String(activeAgentCount) : "0"; + + const items: OpenClawConnectionItemSummary[] = [ + { + key: "gateway", + status: gatewayStatus, + detail: gatewayDetail, + value: gatewayValue, + }, + { + key: "config", + status: configStatus, + detail: configDetail, + value: configValue, + }, + { + key: "runtime", + status: runtimeStatus, + detail: runtimeDetail, + value: runtimeValue, + }, + ]; + + return { + generatedAt: now, + status: foldInsightStatuses(items.map((item) => item.status)), + items, + }; +} + +export function summarizeOpenClawUpdate(statusJson: unknown, updateJson: unknown): OpenClawUpdateSummary { + const statusRoot = asObject(statusJson) ?? {}; + const updateRoot = asObject(updateJson) ?? {}; + const updateStatus = asObject(updateRoot.update); + const availability = asObject(updateRoot.availability); + const channel = asObject(updateRoot.channel); + const registry = asObject(updateStatus?.registry); + const latestVersion = + asString(availability?.latestVersion) ?? + asString(registry?.latestVersion); + const currentVersion = asString(statusRoot.runtimeVersion); + const updateAvailable = asBoolean(availability?.available) === true; + return { + generatedAt: new Date().toISOString(), + status: updateAvailable ? "info" : currentVersion ? "ok" : latestVersion ? "info" : "unknown", + currentVersion, + latestVersion, + channelLabel: asString(channel?.label), + updateAvailable, + installKind: asString(updateStatus?.installKind), + packageManager: asString(updateStatus?.packageManager), + }; +} + +export function summarizeOpenClawSecurity(securityJson: unknown): OpenClawSecuritySummary { + const root = asObject(securityJson) ?? {}; + const summary = asObject(root.summary); + const findings = asArray(root.findings).map((item) => { + const obj = asObject(item) ?? {}; + const severity = normalizeSeverity(asString(obj.severity)); + return { + checkId: asString(obj.checkId) ?? "unknown", + severity, + title: asString(obj.title) ?? "Untitled finding", + detail: asString(obj.detail) ?? "", + remediation: asString(obj.remediation), + } satisfies OpenClawSecurityFindingSummary; + }); + const counts = { + critical: asNumber(summary?.critical) ?? findings.filter((item) => item.severity === "critical").length, + warn: asNumber(summary?.warn) ?? findings.filter((item) => item.severity === "warn").length, + info: asNumber(summary?.info) ?? findings.filter((item) => item.severity === "info").length, + }; + return { + generatedAt: new Date().toISOString(), + status: + counts.critical > 0 + ? "blocked" + : counts.warn > 0 + ? "warn" + : counts.info > 0 + ? "info" + : "ok", + counts, + findings, + }; +} + +export function summarizeOpenClawMemory(memoryJson: unknown): OpenClawMemorySummary { + const items = asArray(memoryJson).map((item) => { + const root = asObject(item) ?? {}; + const status = asObject(root.status); + const vector = asObject(status?.vector); + const custom = asObject(status?.custom); + const qmd = asObject(custom?.qmd); + const scan = asObject(root.scan); + const issues = asArray(scan?.issues); + const files = asNumber(status?.files) ?? 0; + const chunks = asNumber(status?.chunks) ?? 0; + const dirty = asBoolean(status?.dirty) === true; + const vectorAvailable = asBoolean(vector?.available) === true; + const searchable = files > 0 && vectorAvailable; + let agentStatus: OpenClawInsightStatus = "ok"; + if (!vectorAvailable || issues.length > 0) { + agentStatus = "warn"; + } + if (files === 0 || chunks === 0) { + agentStatus = "blocked"; + } else if (dirty) { + agentStatus = "info"; + } + return { + agentId: asString(root.agentId) ?? "unknown", + status: agentStatus, + files, + chunks, + issuesCount: issues.length, + dirty, + vectorAvailable, + searchable, + lastUpdateAt: asString(qmd?.lastUpdateAt), + } satisfies OpenClawMemoryAgentSummary; + }); + + const okCount = items.filter((item) => item.status === "ok").length; + const warnCount = items.filter((item) => item.status === "warn" || item.status === "info").length; + const blockedCount = items.filter((item) => item.status === "blocked").length; + + return { + generatedAt: new Date().toISOString(), + status: blockedCount > 0 ? "blocked" : warnCount > 0 ? "warn" : okCount > 0 ? "ok" : "unknown", + okCount, + warnCount, + blockedCount, + agents: items.sort((a, b) => insightStatusRank(a.status) - insightStatusRank(b.status) || a.agentId.localeCompare(b.agentId)), + }; +} + +async function loadCachedOpenClawStatusJson(): Promise { + return loadSourceWithCache( + statusCache, + statusInFlight, + () => runOpenClawJson(["status", "--json"], {}), + (value) => { + statusCache = value; + }, + (value) => { + statusInFlight = value; + }, + ); +} + +async function loadCachedOpenClawGatewayStatusJson(): Promise { + return loadSourceWithCache( + gatewayCache, + gatewayInFlight, + () => runOpenClawJson(["gateway", "status", "--json"], {}), + (value) => { + gatewayCache = value; + }, + (value) => { + gatewayInFlight = value; + }, + ); +} + +async function loadCachedOpenClawSecurityAuditJson(): Promise { + return loadSourceWithCache( + securityCache, + securityInFlight, + () => runOpenClawJson(["security", "audit", "--json"], {}), + (value) => { + securityCache = value; + }, + (value) => { + securityInFlight = value; + }, + ); +} + +async function loadCachedOpenClawUpdateStatusJson(): Promise { + return loadSourceWithCache( + updateCache, + updateInFlight, + () => runOpenClawJson(["update", "status", "--json"], {}), + (value) => { + updateCache = value; + }, + (value) => { + updateInFlight = value; + }, + ); +} + +async function loadCachedOpenClawMemoryStatusJson(): Promise { + return loadSourceWithCache( + memoryCache, + memoryInFlight, + () => runOpenClawJson(["memory", "status", "--json"], []), + (value) => { + memoryCache = value; + }, + (value) => { + memoryInFlight = value; + }, + ); +} + +async function loadSourceWithCache( + cache: TimedSourceCache | undefined, + inFlight: Promise | undefined, + loader: () => Promise, + assignCache: (value: TimedSourceCache | undefined) => void, + assignInFlight: (value: Promise | undefined) => void, +): Promise { + const now = Date.now(); + if (cache && cache.expiresAt > now) return cache.value; + if (cache) { + if (!inFlight) { + const nextValue = loader(); + assignInFlight(nextValue); + void nextValue + .then((value) => { + assignCache({ + value, + expiresAt: Date.now() + INSIGHT_CACHE_TTL_MS, + }); + }) + .finally(() => { + assignInFlight(undefined); + }); + } + return cache.value; + } + if (inFlight) return inFlight; + + const nextValue = loader(); + assignInFlight(nextValue); + try { + const value = await nextValue; + assignCache({ + value, + expiresAt: now + INSIGHT_CACHE_TTL_MS, + }); + return value; + } finally { + assignInFlight(undefined); + } +} + +async function runOpenClawJson(args: string[], fallback: unknown): Promise { + try { + const { stdout } = await execFileAsync("openclaw", args, { + timeout: INSIGHT_COMMAND_TIMEOUT_MS, + maxBuffer: INSIGHT_COMMAND_MAX_BUFFER, + }); + return JSON.parse(stdout) as unknown; + } catch (error) { + const recovered = recoverOpenClawCommandJson(error); + return recovered ?? fallback; + } +} + +export function recoverOpenClawCommandJson(error: unknown): unknown { + const root = asObject(error); + const stdout = typeof root?.stdout === "string" ? root.stdout.trim() : ""; + if (!stdout) return undefined; + try { + return JSON.parse(stdout) as unknown; + } catch { + return undefined; + } +} + +function asObject(input: unknown): Record | undefined { + return input !== null && typeof input === "object" ? (input as Record) : undefined; +} + +function asArray(input: unknown): unknown[] { + return Array.isArray(input) ? input : []; +} + +function asString(input: unknown): string | undefined { + return typeof input === "string" && input.trim() !== "" ? input : undefined; +} + +function asBoolean(input: unknown): boolean | undefined { + return typeof input === "boolean" ? input : undefined; +} + +function asNumber(input: unknown): number | undefined { + return typeof input === "number" && Number.isFinite(input) ? input : undefined; +} + +function normalizeSeverity(input: string | undefined): "critical" | "warn" | "info" { + const normalized = (input ?? "").trim().toLowerCase(); + if (normalized === "critical" || normalized === "error") return "critical"; + if (normalized === "warn" || normalized === "warning" || normalized === "recommended") return "warn"; + return "info"; +} + +function foldInsightStatuses(statuses: OpenClawInsightStatus[]): OpenClawInsightStatus { + if (statuses.some((item) => item === "blocked")) return "blocked"; + if (statuses.some((item) => item === "warn")) return "warn"; + if (statuses.some((item) => item === "info")) return "info"; + if (statuses.some((item) => item === "ok")) return "ok"; + return "unknown"; +} + +function insightStatusRank(status: OpenClawInsightStatus): number { + if (status === "blocked") return 0; + if (status === "warn") return 1; + if (status === "info") return 2; + if (status === "ok") return 3; + return 4; +} diff --git a/src/ui/server.ts b/src/ui/server.ts index 68956dd..8cb6653 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -38,6 +38,18 @@ import { normalizeToken, readAuthorizationBearer, } from "../runtime/local-token-auth"; +import { + loadCachedOpenClawConnectionSummary, + loadCachedOpenClawMemorySummary, + primeOpenClawCliInsights, + loadCachedOpenClawSecuritySummary, + loadCachedOpenClawUpdateSummary, + type OpenClawConnectionSummary, + type OpenClawInsightStatus, + type OpenClawMemorySummary, + type OpenClawSecuritySummary, + type OpenClawUpdateSummary, +} from "../runtime/openclaw-cli-insights"; import { appendOperationAudit } from "../runtime/operation-audit"; import { ApprovalActionService } from "../runtime/approval-action-service"; import { buildActionQueueLinks } from "../runtime/action-queue-links"; @@ -1665,6 +1677,7 @@ export function startUiServer(port: number, toolClient: ToolClient): Server { server.listen(port, "127.0.0.1", () => { console.log(`[mission-control] ui listening at http://127.0.0.1:${port}`); + void Promise.resolve().then(() => primeOpenClawCliInsights()); void primeUiRenderCaches(toolClient); }); return server; @@ -2627,6 +2640,584 @@ function renderGlobalVisibilityStripCard(model: GlobalVisibilityViewModel, langu `; } +function insightStatusLabel(status: OpenClawInsightStatus, language: UiLanguage): string { + if (status === "ok") return pickUiText(language, "Healthy", "正常"); + if (status === "warn") return pickUiText(language, "Attention", "需关注"); + if (status === "blocked") return pickUiText(language, "Blocked", "阻塞"); + if (status === "info") return pickUiText(language, "Partial", "部分可用"); + return pickUiText(language, "Unknown", "未知"); +} + +function connectorInsightStatus(snapshot: UsageCostSnapshot, language: UiLanguage): { + status: OpenClawInsightStatus; + connectedCount: number; + totalCount: number; + missingCount: number; + detail: string; + value: string; +} { + const statuses = [ + snapshot.connectors.modelContextCatalog, + snapshot.connectors.digestHistory, + snapshot.connectors.requestCounts, + snapshot.connectors.budgetLimit, + snapshot.connectors.providerAttribution, + snapshot.connectors.subscriptionUsage, + ]; + const totalCount = statuses.length; + const connectedCount = statuses.filter((item) => item === "connected").length; + const partialCount = statuses.filter((item) => item === "partial").length; + const missingCount = totalCount - connectedCount; + + const connectorGapLabel = (id: string): string => { + if (id === "subscription_usage") return pickUiText(language, "subscription / billing snapshot", "订阅 / 账单快照"); + if (id === "cost_budget_limit") return pickUiText(language, "budget limit", "预算限额"); + if (id === "request_counter") return pickUiText(language, "request counts", "请求计数"); + if (id === "digest_history") return pickUiText(language, "trend history", "趋势历史"); + if (id === "context_catalog") return pickUiText(language, "context capacity", "上下文容量"); + if (id === "provider_mapping") return pickUiText(language, "model provider attribution", "模型供应商归因"); + return pickUiText(language, "usage data", "用量数据"); + }; + + const connectorGapImpact = (id: string): string => { + if (id === "subscription_usage") { + return pickUiText(language, "Quota windows will show estimated or disconnected states.", "额度窗口会显示估算或未连接。"); + } + if (id === "cost_budget_limit") { + return pickUiText(language, "Budget risk can only use spend so far, not a real limit.", "预算风险只能看已花金额,暂时还不能按上限预警。"); + } + if (id === "request_counter") { + return pickUiText(language, "Request counts and parts of trend history will stay incomplete.", "请求次数和部分趋势会不完整。"); + } + if (id === "digest_history") { + return pickUiText(language, "7d / 30d trends will stay incomplete.", "7 天 / 30 天趋势会不完整。"); + } + if (id === "context_catalog") { + return pickUiText(language, "Context pressure will miss full capacity data.", "上下文压力会缺少完整容量信息。"); + } + if (id === "provider_mapping") { + return pickUiText(language, "Some attribution will stay under “Unknown provider”.", "部分归因会落到“未知供应商”。"); + } + return pickUiText(language, "Some usage panels will stay incomplete.", "部分用量面板会不完整。"); + }; + + const connectorGapAction = (id: string): string => { + if (id === "subscription_usage") { + return pickUiText(language, "Open Settings and connect the subscription or billing snapshot.", "去设置页补上订阅或账单快照即可。"); + } + if (id === "cost_budget_limit") { + return pickUiText(language, "Open Settings and add a budget limit.", "去设置页补上预算限额即可。"); + } + if (id === "request_counter") { + return pickUiText(language, "Open Settings and connect the request-count source.", "去设置页按建议接通请求计数源即可。"); + } + if (id === "digest_history") { + return pickUiText(language, "Keep monitoring running, or use Settings to complete trend history.", "保持监控持续运行,或按设置页建议补齐趋势历史即可。"); + } + if (id === "context_catalog") { + return pickUiText(language, "Open Settings and connect model context capacity data.", "去设置页补上模型上下文容量数据即可。"); + } + if (id === "provider_mapping") { + return pickUiText(language, "Open Settings and complete the model-to-provider mapping.", "去设置页补上模型到供应商的映射即可。"); + } + return pickUiText(language, "Open Settings and follow the remaining data-connection suggestions.", "去设置页按建议补齐即可。"); + }; + + const firstGap = snapshot.connectors.todos[0]; + let detail = pickUiText( + language, + "All six key usage sources are connected, so usage, quota, and attribution can be trusted.", + "6 项关键用量数据都已接通,用量、额度和归因都可以直接看。", + ); + let value = pickUiText(language, "Complete", "完整"); + + if (connectedCount === totalCount) { + return { status: "ok", connectedCount, totalCount, missingCount: 0, detail, value }; + } + + if (connectedCount === 0 && partialCount === 0) { + detail = pickUiText( + language, + "None of the six key usage sources are connected yet, so the Usage page will stay very limited until you connect them in Settings.", + "6 项关键用量数据都还没接通,所以用量页只会显示很有限的信息。先去设置页按建议接入即可。", + ); + value = pickUiText(language, "Needs setup", "待接入"); + return { status: "blocked", connectedCount, totalCount, missingCount, detail, value }; + } + + if (firstGap) { + const gapLabel = connectorGapLabel(firstGap.id); + const impact = connectorGapImpact(firstGap.id); + const action = connectorGapAction(firstGap.id); + if (missingCount === 1) { + detail = pickUiText( + language, + `${connectedCount} of ${totalCount} key usage sources are connected; the remaining item is ${gapLabel}. ${impact} ${action}`, + `6 项关键用量数据里,已经接上 ${connectedCount} 项,还差 1 项:${gapLabel}。${impact}${action}`, + ); + value = pickUiText(language, "Missing 1", "差 1 项"); + } else { + detail = pickUiText( + language, + `${connectedCount} of ${totalCount} key usage sources are connected, with ${missingCount} items still incomplete. The most important gap right now is ${gapLabel}. ${impact} ${action}`, + `6 项关键用量数据里,已经接上 ${connectedCount} 项,还差 ${missingCount} 项;当前最影响判断的是「${gapLabel}」。${impact}${action}`, + ); + value = pickUiText(language, `Missing ${missingCount}`, `差 ${missingCount} 项`); + } + } else { + detail = pickUiText( + language, + `${connectedCount} of ${totalCount} key usage sources are connected, but ${missingCount} items are still incomplete. Open Settings and finish the remaining data connections.`, + `6 项关键用量数据里,已经接上 ${connectedCount} 项,但还有 ${missingCount} 项没有完整接通。去设置页按建议补齐即可。`, + ); + value = pickUiText(language, `Missing ${missingCount}`, `差 ${missingCount} 项`); + } + + return { + status: "warn", + connectedCount, + totalCount, + missingCount, + detail, + value, + }; +} + +function localizeConnectionInsightValue( + item: OpenClawConnectionSummary["items"][number], + language: UiLanguage, +): string { + if (item.key === "gateway") { + return item.status === "ok" + ? pickUiText(language, "Connected", "已接通") + : pickUiText(language, "Unavailable", "未接通"); + } + if (item.key === "config") { + return item.status === "ok" + ? pickUiText(language, "Ready", "已就绪") + : pickUiText(language, "Needs fix", "待修复"); + } + if (item.value === "loading") { + return pickUiText(language, "Loading", "读取中"); + } + return item.value; +} + +function localizeConnectionInsightDetail( + item: OpenClawConnectionSummary["items"][number], + language: UiLanguage, +): string { + if (item.key === "gateway") { + return item.status === "ok" + ? item.detail + : pickUiText(language, "Gateway is not reachable.", "当前还无法连到 Gateway。"); + } + if (item.key === "config") { + const allowedOriginsMatch = item.detail.match(/^(\d+) allowed origin/); + if (allowedOriginsMatch) { + const count = Number.parseInt(allowedOriginsMatch[1] ?? "0", 10); + return pickUiText( + language, + `${count} allowed origin${count === 1 ? "" : "s"}`, + `已配置 ${count} 个允许来源`, + ); + } + if (item.detail === "Local-only by default") { + return pickUiText(language, "Local-only by default.", "默认仅允许本机访问。"); + } + return pickUiText(language, "openclaw.json is missing or invalid.", "openclaw.json 缺失或配置无效。"); + } + const runtimeVisibleMatch = item.detail.match(/^(\d+) session(?:s)? visible across (\d+) agent/); + if (runtimeVisibleMatch) { + const sessionCount = Number.parseInt(runtimeVisibleMatch[1] ?? "0", 10); + const agentCount = Number.parseInt(runtimeVisibleMatch[2] ?? "0", 10); + return pickUiText( + language, + `${sessionCount} session${sessionCount === 1 ? "" : "s"} visible across ${agentCount} agent${agentCount === 1 ? "" : "s"}`, + `已看到 ${sessionCount} 个会话,覆盖 ${agentCount} 个智能体`, + ); + } + const configuredMatch = item.detail.match(/^(\d+) agent(?:s)? configured, but no recent sessions yet$/); + if (configuredMatch) { + const agentCount = Number.parseInt(configuredMatch[1] ?? "0", 10); + return pickUiText( + language, + `${agentCount} agent${agentCount === 1 ? "" : "s"} configured, but no recent sessions yet`, + `已配置 ${agentCount} 个智能体,但还没有最近会话`, + ); + } + if (item.detail === "Runtime status is still loading") { + return pickUiText(language, "Runtime status is still loading.", "正在读取运行时状态。"); + } + return pickUiText(language, "No runtime sessions are visible yet.", "当前还没有看到运行时会话。"); +} + +function localizeSecurityFinding( + item: OpenClawSecuritySummary["findings"][number], + language: UiLanguage, +): { title: string; detail: string; remediation?: string } { + if (language === "en") { + return { + title: item.title, + detail: item.detail, + remediation: item.remediation, + }; + } + + if (item.checkId === "summary.attack_surface") { + return { + title: "当前暴露面概览", + detail: "当前环境更接近个人助理式单一信任边界,而不是多人共享隔离环境;高权限工具和内部钩子仍然可用。", + }; + } + if (item.checkId === "gateway.trusted_proxies_missing") { + return { + title: "反向代理信任尚未配置", + detail: "如果你通过反向代理暴露控制中心,当前还没有声明可信代理来源,转发头信息可能被错误信任。", + remediation: "如果继续通过反向代理访问,请把可信代理 IP 配进 gateway.trustedProxies;否则保持控制中心仅本机访问。", + }; + } + if (item.checkId === "gateway.tailscale_serve") { + return { + title: "已通过 Tailscale Serve 暴露 Gateway", + detail: "当前 Gateway 已经通过 Tailscale 暴露到 tailnet,可访问范围不再只限这台机器本地。", + }; + } + if (item.checkId === "security.trust_model.multi_user_heuristic") { + return { + title: "检测到可能的多人共享使用场景", + detail: "当前环境看起来不只是单人自用,但 OpenClaw 默认更适合单一可信操作者,不适合把同一 Gateway 当成多人隔离环境。", + remediation: "如果确实给多人使用,最好拆分成独立 Gateway 或独立系统账号;至少先收紧沙箱、文件访问和高权限工具范围。", + }; + } + + return { + title: item.title, + detail: item.detail, + remediation: item.remediation, + }; +} + +function localizeUpdateChannelLabel(label: string | undefined, language: UiLanguage): string { + if (!label) return "-"; + if (language === "en") return label; + if (label === "stable (default)") return "稳定版(默认)"; + if (label === "stable") return "稳定版"; + if (label === "beta") return "测试版"; + if (label === "dev") return "开发版"; + return label; +} + +function localizeUpdateInstallKind(kind: string | undefined, language: UiLanguage): string { + if (!kind) return "-"; + if (language === "en") return kind; + if (kind === "package") return "软件包"; + if (kind === "git") return "Git"; + return kind; +} + +function localizeCurrentVersionValue(summary: OpenClawUpdateSummary, language: UiLanguage): string { + if (summary.currentVersion) return summary.currentVersion; + if (summary.latestVersion) return pickUiText(language, "Loading", "读取中"); + return "-"; +} + +function renderOpenClawConnectionCard( + summary: OpenClawConnectionSummary | undefined, + usageCost: UsageCostSnapshot, + language: UiLanguage, +): string { + if (!summary) { + return `
+
+

${escapeHtml(pickUiText(language, "Connection health", "接线状态"))}

+
${badge("info", pickUiText(language, "Loading", "读取中"))}
+
+
${escapeHtml(pickUiText(language, "Connection signals are loading.", "正在读取接线状态。"))}
+
`; + } + + const usageSourceSummary = connectorInsightStatus(usageCost, language); + const rows = [ + ...summary.items.map((item) => ({ + label: + item.key === "gateway" + ? pickUiText(language, "Gateway", "网关") + : item.key === "config" + ? pickUiText(language, "Config", "配置") + : pickUiText(language, "Runtime", "运行时"), + status: item.status, + detail: localizeConnectionInsightDetail(item, language), + value: localizeConnectionInsightValue(item, language), + })), + { + label: pickUiText(language, "Usage sources", "用量数据"), + status: usageSourceSummary.status, + detail: usageSourceSummary.detail, + value: usageSourceSummary.value, + }, + ]; + const connectedCount = rows.filter((item) => item.status === "ok").length; + const overallStatus = + summary.status === "blocked" || usageSourceSummary.status === "blocked" + ? "blocked" + : summary.status === "warn" || usageSourceSummary.status === "warn" + ? "warn" + : summary.status === "info" + ? "info" + : "ok"; + const headline = + overallStatus === "ok" + ? pickUiText(language, "Control Center is fully connected to this OpenClaw environment.", "控制中心已经完整接上这台 OpenClaw 环境。") + : overallStatus === "blocked" + ? pickUiText(language, "Some core links are still blocked.", "有核心接线还没有打通。") + : pickUiText(language, "Control Center is usable, but some panels are still running in partial mode.", "控制中心已经可用,但有些面板仍处于部分接线状态。"); + + return `
+
+
+

${escapeHtml(pickUiText(language, "Connection health", "接线状态"))}

+
${escapeHtml(headline)}
+
+ ${escapeHtml( + pickUiText(language, "Open settings", "查看设置"), + )} +
+
+
${escapeHtml(pickUiText(language, "Healthy links", "已接通"))}${connectedCount}/${rows.length}
+
${escapeHtml(pickUiText(language, "Updated", "更新"))}${escapeHtml(formatTimeAgoFromNow(summary.generatedAt, language))}
+
+
${rows + .map( + (item) => `
+
+ ${escapeHtml(item.label)} +
${badge(item.status, insightStatusLabel(item.status, language))} ${escapeHtml(item.detail)}
+
+
${escapeHtml(item.value)}
+
`, + ) + .join("")}
+
`; +} + +function renderOpenClawSecuritySection( + summary: OpenClawSecuritySummary | undefined, + language: UiLanguage, +): string { + if (!summary) { + return `
+

${escapeHtml(pickUiText(language, "Security risk summary", "安全风险摘要"))}

+
${escapeHtml(pickUiText(language, "Security audit is loading.", "正在读取安全审计。"))}
+
`; + } + + const headline = + summary.status === "blocked" + ? pickUiText(language, "There are critical security issues to address.", "当前有需要立刻处理的安全风险。") + : summary.status === "warn" + ? pickUiText(language, "There are a few configuration risks worth reviewing.", "当前有几项配置风险值得检查。") + : summary.status === "info" + ? pickUiText(language, "Security looks stable, with informational notes only.", "当前整体安全稳定,只有信息提示。") + : pickUiText(language, "No actionable security risk is visible right now.", "当前没有需要处理的安全风险。"); + const findingsHtml = + summary.findings.length === 0 + ? `
${escapeHtml(pickUiText(language, "No audit findings yet.", "当前没有审计结果。"))}
` + : `
    ${summary.findings + .slice(0, 4) + .map((item) => { + const tone = item.severity === "critical" ? "blocked" : item.severity === "warn" ? "warn" : "info"; + const localized = localizeSecurityFinding(item, language); + return `
  • ${badge(tone, insightStatusLabel(tone, language))} ${escapeHtml(localized.title)}
    ${escapeHtml( + safeTruncate(normalizeInlineText(localized.detail), 200), + )}
    ${ + localized.remediation + ? `
    ${escapeHtml(pickUiText(language, "Next step", "下一步"))}:${escapeHtml( + safeTruncate(normalizeInlineText(localized.remediation), 180), + )}
    ` + : "" + }
  • `; + }) + .join("")}
`; + + return `
+
+
+

${escapeHtml(pickUiText(language, "Security risk summary", "安全风险摘要"))}

+
${escapeHtml(headline)}
+
+
${badge(summary.status, insightStatusLabel(summary.status, language))}
+
+
+
${escapeHtml(pickUiText(language, "Critical", "高风险"))}${summary.counts.critical}
+
${escapeHtml(pickUiText(language, "Warnings", "需关注"))}${summary.counts.warn}
+
${escapeHtml(pickUiText(language, "Info", "提示"))}${summary.counts.info}
+
+ ${findingsHtml} +
`; +} + +function renderOpenClawUpdateSection( + summary: OpenClawUpdateSummary | undefined, + language: UiLanguage, +): string { + if (!summary) { + return `
+

${escapeHtml(pickUiText(language, "Update status", "更新状态"))}

+
${escapeHtml(pickUiText(language, "Update status is loading.", "正在读取更新状态。"))}
+
`; + } + + const headline = summary.updateAvailable + ? pickUiText(language, "A newer OpenClaw version is available.", "发现了更新版本。") + : pickUiText(language, "This OpenClaw runtime is already up to date.", "当前 OpenClaw 已是最新。"); + return `
+
+
+

${escapeHtml(pickUiText(language, "Update status", "更新状态"))}

+
${escapeHtml(headline)}
+
+
${badge(summary.status, insightStatusLabel(summary.status, language))}
+
+
+
${escapeHtml(pickUiText(language, "Current", "当前版本"))}${escapeHtml(localizeCurrentVersionValue(summary, language))}
+
${escapeHtml(pickUiText(language, "Latest", "最新版本"))}${escapeHtml(summary.latestVersion ?? "-")}
+
${escapeHtml(pickUiText(language, "Channel", "更新通道"))}${escapeHtml(localizeUpdateChannelLabel(summary.channelLabel, language))}
+
+
${escapeHtml(pickUiText(language, "Install method", "安装方式"))} ${escapeHtml(localizeUpdateInstallKind(summary.installKind, language))} · ${escapeHtml( + pickUiText(language, "Package manager", "包管理器"), + )} ${escapeHtml(summary.packageManager ?? "-")}
+
`; +} + +function renderContextPressureCard(snapshot: UsageCostSnapshot, language: UiLanguage): string { + if (snapshot.contextWindows.length === 0) { + return `
+

${escapeHtml(pickUiText(language, "Context pressure", "上下文压力"))}

+
${escapeHtml(pickUiText(language, "No session context data yet.", "当前还没有会话上下文数据。"))}
+
`; + } + + const ranked = [...snapshot.contextWindows].sort((a, b) => { + const rankA = a.thresholdState === "critical" ? 0 : a.thresholdState === "warn" ? 1 : a.thresholdState === "ok" ? 2 : 3; + const rankB = b.thresholdState === "critical" ? 0 : b.thresholdState === "warn" ? 1 : b.thresholdState === "ok" ? 2 : 3; + if (rankA !== rankB) return rankA - rankB; + return (b.usagePercent ?? 0) - (a.usagePercent ?? 0); + }); + const criticalCount = ranked.filter((item) => item.thresholdState === "critical").length; + const warnCount = ranked.filter((item) => item.thresholdState === "warn").length; + const topUsage = ranked[0]; + const headline = + criticalCount > 0 + ? pickUiText(language, "Some sessions are close to their context ceiling.", "有会话已经接近上下文上限。") + : warnCount > 0 + ? pickUiText(language, "A few sessions are worth watching.", "有几条会话值得继续盯住。") + : pickUiText(language, "Context pressure looks healthy right now.", "当前上下文压力整体正常。"); + + return `
+
+
+

${escapeHtml(pickUiText(language, "Context pressure", "上下文压力"))}

+
${escapeHtml(headline)}
+
+
${badge( + criticalCount > 0 ? "warn" : warnCount > 0 ? "info" : "ok", + criticalCount > 0 ? pickUiText(language, "High", "较高") : warnCount > 0 ? pickUiText(language, "Watch", "观察中") : pickUiText(language, "Healthy", "正常"), + )}
+
+
+
${escapeHtml(pickUiText(language, "Critical", "临界"))}${criticalCount}
+
${escapeHtml(pickUiText(language, "Warning", "预警"))}${warnCount}
+
${escapeHtml(pickUiText(language, "Highest usage", "最高占比"))}${escapeHtml( + topUsage?.usagePercent !== undefined ? `${topUsage.usagePercent.toFixed(1)}%` : "-", + )}
+
+ +
`; +} + +function renderMemoryStateSection(summary: OpenClawMemorySummary | undefined, language: UiLanguage): string { + if (!summary) { + return `
+

${escapeHtml(pickUiText(language, "Memory status", "记忆状态"))}

+
${escapeHtml(pickUiText(language, "Memory status is loading.", "正在读取记忆状态。"))}
+
`; + } + + const headline = + summary.status === "blocked" + ? pickUiText(language, "Some agents still do not have usable memory.", "有智能体的记忆还不可用。") + : summary.status === "warn" + ? pickUiText(language, "Memory works, but some agents still need attention.", "记忆整体可用,但有些智能体还需要检查。") + : pickUiText(language, "Memory looks healthy for the visible agents.", "当前可见智能体的记忆状态正常。"); + + return `
+
+
+

${escapeHtml(pickUiText(language, "Memory status", "记忆状态"))}

+
${escapeHtml(headline)}
+
+
${badge(summary.status, insightStatusLabel(summary.status, language))}
+
+
+
${escapeHtml(pickUiText(language, "Healthy", "正常"))}${summary.okCount}
+
${escapeHtml(pickUiText(language, "Needs attention", "需关注"))}${summary.warnCount}
+
${escapeHtml(pickUiText(language, "Unavailable", "不可用"))}${summary.blockedCount}
+
+
${summary.agents + .map((item) => { + const detailParts = [ + `${formatInt(item.files)} ${pickUiText(language, "files", "份记忆")}`, + `${formatInt(item.chunks)} ${pickUiText(language, "chunks", "个块")}`, + item.searchable ? pickUiText(language, "searchable", "可搜索") : pickUiText(language, "search not ready", "搜索未就绪"), + ]; + if (item.issuesCount > 0) { + detailParts.push(pickUiText(language, `${item.issuesCount} issue(s)`, `${item.issuesCount} 个异常`)); + } else if (item.dirty) { + detailParts.push(pickUiText(language, "refresh pending", "待刷新")); + } + if (item.lastUpdateAt) { + detailParts.push( + pickUiText(language, `updated ${formatTimeAgoFromNow(item.lastUpdateAt, language)}`, `${formatTimeAgoFromNow(item.lastUpdateAt, language)} 更新`), + ); + } + return `
+
+ ${escapeHtml(humanizeOperatorLabel(item.agentId))} +
${badge(item.status, insightStatusLabel(item.status, language))} ${escapeHtml(detailParts.join(" · "))}
+
+
${escapeHtml(item.searchable ? pickUiText(language, "Ready", "可用") : pickUiText(language, "Check", "检查"))}
+
`; + }) + .join("")}
+
`; +} + function scoreCoverageStatus(status: DataCoverageStatus): number { if (status === "connected") return 100; if (status === "partial") return 55; @@ -3597,6 +4188,10 @@ async function renderHtml( const needsTeamSnapshot = activeSection === "team"; const needsMemoryFiles = activeSection === "memory"; const needsWorkspaceFiles = activeSection === "docs"; + const needsConnectionHealth = activeSection === "settings"; + const needsSecuritySummary = activeSection === "settings"; + const needsUpdateSummary = activeSection === "settings"; + const needsMemoryState = activeSection === "memory"; markRenderPhase("snapshot"); const exceptions = commanderExceptions(snapshot); const exceptionsFeed = commanderExceptionsFeed(snapshot); @@ -3634,7 +4229,7 @@ async function renderHtml( loadCachedOfficeSessionPresence(), ]); markRenderPhase("shared-data"); - const [teamSnapshot, memoryFiles, memoryFacetOptions, workspaceFiles, workspaceFacetOptions, taskEvidenceItems] = await Promise.all([ + const [teamSnapshot, memoryFiles, memoryFacetOptions, workspaceFiles, workspaceFacetOptions, taskEvidenceItems, connectionHealthSummary, securitySummary, updateSummary, memoryStateSummary] = await Promise.all([ needsTeamSnapshot ? loadTeamSnapshot(officeRoster) : Promise.resolve({ @@ -3655,6 +4250,10 @@ async function renderHtml( 24, ) : Promise.resolve([]), + needsConnectionHealth ? loadCachedOpenClawConnectionSummary() : Promise.resolve(undefined), + needsSecuritySummary ? loadCachedOpenClawSecuritySummary() : Promise.resolve(undefined), + needsUpdateSummary ? loadCachedOpenClawUpdateSummary() : Promise.resolve(undefined), + needsMemoryState ? loadCachedOpenClawMemorySummary() : Promise.resolve(undefined), ]); markRenderPhase("section-assets"); const usageToday = usageCost.periods.find((item) => item.key === "today"); @@ -4390,6 +4989,11 @@ async function renderHtml( .join("")}`; const subscriptionSidebarRows = renderSubscriptionSidebarSummary(usageCost.subscription, options.language); const usageDetailHref = buildHomeHref({ quick: "all" }, options.compactStatusStrip, "usage-cost", options.language, options.usageView); + const connectionHealthCard = renderOpenClawConnectionCard(connectionHealthSummary, usageCost, options.language); + const securityRiskSection = renderOpenClawSecuritySection(securitySummary, options.language); + const updateStatusSection = renderOpenClawUpdateSection(updateSummary, options.language); + const contextPressureCard = renderContextPressureCard(usageCost, options.language); + const memoryStateSection = renderMemoryStateSection(memoryStateSummary, options.language); const overviewUsagePeriods = isTodayUsageView ? usageCost.periods.filter((item) => item.key === "today") : usageCost.periods.filter((item) => item.key === "today" || item.key === "7d"); @@ -4909,6 +5513,7 @@ async function renderHtml(
${escapeHtml(t("Edits here sync directly back to the real memory files on the OpenClaw machine.", "这里的编辑会直接同步到 OpenClaw 机器上的真实记忆文件。"))}
${memoryWorkbench} + ${memoryStateSection} `; const mainDocumentCount = workspaceFiles.filter((entry) => entry.facetKey === "main").length; const workspaceWorkbench = needsWorkspaceFiles @@ -4970,6 +5575,7 @@ async function renderHtml(
${usageCronAgentShareHtml}
+ ${contextPressureCard}
${escapeHtml(t("Advanced detail (attribution / model / context / budget)", "高级明细(归因/模型/上下文/预算)"))}
@@ -5258,6 +5864,9 @@ async function renderHtml(
`; const settingsSection = ` + ${connectionHealthCard} + ${securityRiskSection} + ${updateStatusSection}

安全开关

diff --git a/test/openclaw-cli-insights.test.ts b/test/openclaw-cli-insights.test.ts new file mode 100644 index 0000000..b294cfc --- /dev/null +++ b/test/openclaw-cli-insights.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + recoverOpenClawCommandJson, + summarizeOpenClawConnection, + summarizeOpenClawMemory, + summarizeOpenClawSecurity, + summarizeOpenClawUpdate, +} from "../src/runtime/openclaw-cli-insights"; + +test("summarizeOpenClawConnection reports gateway, config, runtime, and blocked states", () => { + const summary = summarizeOpenClawConnection( + { + sessions: { count: 12 }, + agents: { agents: [{ agentId: "main", sessionsCount: 12 }, { agentId: "coq", sessionsCount: 0 }] }, + }, + { + service: { runtime: { status: "running" } }, + rpc: { ok: true }, + gateway: { probeUrl: "ws://127.0.0.1:18789" }, + config: { + cli: { exists: true, valid: true, controlUi: { allowedOrigins: ["https://example.com"] } }, + daemon: { exists: true, valid: true }, + }, + }, + ); + + assert.equal(summary.status, "ok"); + assert.equal(summary.items[0]?.key, "gateway"); + assert.equal(summary.items[0]?.status, "ok"); + assert.equal(summary.items[1]?.key, "config"); + assert.equal(summary.items[1]?.value, "Ready"); + assert.equal(summary.items[2]?.key, "runtime"); + assert.equal(summary.items[2]?.value, "12"); +}); + +test("summarizeOpenClawUpdate distinguishes current and latest versions", () => { + const summary = summarizeOpenClawUpdate( + { runtimeVersion: "2026.3.11" }, + { + update: { installKind: "package", packageManager: "pnpm", registry: { latestVersion: "2026.3.12" } }, + channel: { label: "stable (default)" }, + availability: { available: true, latestVersion: "2026.3.12" }, + }, + ); + + assert.equal(summary.status, "info"); + assert.equal(summary.currentVersion, "2026.3.11"); + assert.equal(summary.latestVersion, "2026.3.12"); + assert.equal(summary.updateAvailable, true); + assert.equal(summary.channelLabel, "stable (default)"); +}); + +test("summarizeOpenClawConnection and update keep loading semantics when status payload is missing", () => { + const connection = summarizeOpenClawConnection( + {}, + { + service: { runtime: { status: "running" } }, + rpc: { ok: true }, + gateway: { probeUrl: "ws://127.0.0.1:18789" }, + config: { + cli: { exists: true, valid: true, controlUi: { allowedOrigins: ["https://example.com"] } }, + daemon: { exists: true, valid: true }, + }, + }, + ); + const update = summarizeOpenClawUpdate( + {}, + { + availability: { available: true, latestVersion: "2026.3.12" }, + channel: { label: "stable (default)" }, + }, + ); + + assert.equal(connection.items[2]?.status, "info"); + assert.equal(connection.items[2]?.value, "loading"); + assert.equal(connection.items[2]?.detail, "Runtime status is still loading"); + assert.equal(update.status, "info"); + assert.equal(update.currentVersion, undefined); + assert.equal(update.latestVersion, "2026.3.12"); +}); + +test("summarizeOpenClawSecurity keeps counts and remediation", () => { + const summary = summarizeOpenClawSecurity({ + summary: { critical: 1, warn: 2, info: 1 }, + findings: [ + { + checkId: "gateway.trusted_proxies_missing", + severity: "warn", + title: "Reverse proxy headers are not trusted", + detail: "gateway.trustedProxies is empty", + remediation: "Set trusted proxies", + }, + ], + }); + + assert.equal(summary.status, "blocked"); + assert.equal(summary.counts.critical, 1); + assert.equal(summary.counts.warn, 2); + assert.equal(summary.findings[0]?.remediation, "Set trusted proxies"); +}); + +test("summarizeOpenClawMemory classifies searchable, warning, and blocked agents", () => { + const summary = summarizeOpenClawMemory([ + { + agentId: "main", + status: { + files: 12, + chunks: 12, + dirty: false, + vector: { available: true }, + custom: { qmd: { lastUpdateAt: "2026-03-12T10:00:00.000Z" } }, + }, + scan: { issues: [] }, + }, + { + agentId: "coq", + status: { + files: 4, + chunks: 4, + dirty: true, + vector: { available: true }, + }, + scan: { issues: [] }, + }, + { + agentId: "otter", + status: { + files: 0, + chunks: 0, + dirty: false, + vector: { available: false }, + }, + scan: { issues: [{ code: "missing" }] }, + }, + ]); + + assert.equal(summary.status, "blocked"); + assert.equal(summary.okCount, 1); + assert.equal(summary.warnCount, 1); + assert.equal(summary.blockedCount, 1); + assert.equal(summary.agents[0]?.agentId, "otter"); + assert.equal(summary.agents[2]?.agentId, "main"); +}); + +test("recoverOpenClawCommandJson keeps valid stdout JSON from failed commands", () => { + const recovered = recoverOpenClawCommandJson({ + code: 1, + stdout: '{ "runtimeVersion": "2026.3.11" }', + }); + + assert.deepEqual(recovered, { runtimeVersion: "2026.3.11" }); + assert.equal(recoverOpenClawCommandJson({ code: 1, stdout: "not-json" }), undefined); +}); diff --git a/test/ui-render-smoke.test.ts b/test/ui-render-smoke.test.ts index 89f2acf..1490fa0 100644 --- a/test/ui-render-smoke.test.ts +++ b/test/ui-render-smoke.test.ts @@ -387,6 +387,30 @@ test("usage dashboard includes token type share and cron token share sections", assert(source.includes("selectedUsageBreakdown.byCronAgent")); }); +test("dashboard wires CLI insight cards into overview, usage, memory, and settings", async () => { + const source = await readFile("src/ui/server.ts", "utf8"); + assert(source.includes('id="overview-connection-health"')); + assert(source.includes('pickUiText(language, "Connection health", "接线状态")')); + assert(source.includes('pickUiText(language, "Gateway", "网关")')); + assert(source.includes('6 项关键用量数据里,已经接上 ${connectedCount} 项,还差 1 项:${gapLabel}。${impact}${action}')); + assert(source.includes("去设置页补上订阅或账单快照即可。")); + assert(source.includes('const needsConnectionHealth = activeSection === "settings";')); + assert(source.includes("const settingsSection = `\n ${connectionHealthCard}")); + assert(source.includes('id="session-context-pressure"')); + assert(source.includes('pickUiText(language, "Context pressure", "上下文压力")')); + assert(source.includes("\n ${contextPressureCard}\n
")); + assert(source.includes('id="memory-status-card"')); + assert(source.includes('pickUiText(language, "Memory status", "记忆状态")')); + assert(source.includes("${memoryWorkbench}\n ${memoryStateSection}")); + assert(source.includes('id="security-risk-summary"')); + assert(source.includes('pickUiText(language, "Security risk summary", "安全风险摘要")')); + assert(source.includes('title: "反向代理信任尚未配置"')); + assert(source.includes('title: "检测到可能的多人共享使用场景"')); + assert(source.includes('id="update-status-card"')); + assert(source.includes('pickUiText(language, "Update status", "更新状态")')); + assert(source.includes('if (label === "stable (default)") return "稳定版(默认)";')); +}); + test("memory and workspace sections expose editable file workbenches", async () => { const source = (await readFile("src/ui/server.ts", "utf8")).replace(/\r\n/g, "\n"); assert(source.includes("/api/files"));