Add operator insight cards and cut v0.1.1

This commit is contained in:
OpenClaw Local committed 2026-03-13 22:08:48 +01:00
1 parent 1ba2ad0322
commit fd0ac1761a
11 files changed
+1358 -9

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ pnpm-debug.log*
.env .env
.env.local .env.local
.env.*.local .env.*.local
.pr-reviews/
plans/ plans/
workflows/ workflows/
mission.config.json mission.config.json
+10 -1
View File
@@ -21,6 +21,13 @@ Language: **English** | [中文](README.md)
- `Tasks`: current work, approvals, execution chains, and runtime evidence - `Tasks`: current work, approvals, execution chains, and runtime evidence
- `Documents` and `Memory`: source-backed workbenches scoped to active OpenClaw agents - `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 ## Who it is for
- OpenClaw users who want one local control center for observability, usage, tasks, approvals, replay, documents, and memory - 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 - teams running OpenClaw on one machine or a reachable local environment
@@ -67,7 +74,7 @@ Then open:
### Usage ### Usage
- Shows today, 7-day, and 30-day usage and spend trends. - 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. - Best when you want to know whether spend or quota is becoming risky.
### Staff ### Staff
@@ -78,6 +85,7 @@ Then open:
### Memory ### Memory
- A source-backed workbench for daily and long-term memory files. - 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. - 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. - Best when you want to inspect or edit memory that the current OpenClaw team is actually using.
### Documents ### Documents
@@ -93,6 +101,7 @@ Then open:
### Settings ### Settings
- Shows safety mode, connector status, and data-link expectations. - 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. - 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. - Best when you want to verify environment setup or explain why a signal is missing.
For: For:
+10 -1
View File
@@ -21,6 +21,13 @@ OpenClaw 的安全优先、本地优先控制中心。
- `任务`:当前任务、审批、执行链和运行证据 - `任务`:当前任务、审批、执行链和运行证据
- `文档` 与 `记忆`:按活跃 OpenClaw agent 范围展示的源文件工作台 - `文档` 与 `记忆`:按活跃 OpenClaw agent 范围展示的源文件工作台
## 这个版本新增了什么
- `设置`:新增 `接线状态`,直接告诉你哪些数据已经接好、哪些还差一步,以及该去哪里补。
- `设置`:新增 `安全风险摘要`,把当前风险、影响和下一步建议翻译成人话。
- `设置`:新增 `更新状态`,直接看当前版本、最新版本、更新通道和安装方式。
- `用量`:新增 `上下文压力`,直接看哪些会话更接近上下文上限,哪里可能变慢或变贵。
- `记忆`:新增 `记忆状态`,直接看每个智能体的记忆是否可用、可搜索、需不需要检查。
## 适合谁 ## 适合谁
- 已经在用 OpenClaw、想要一个统一控制中心的团队或个人 - 已经在用 OpenClaw、想要一个统一控制中心的团队或个人
- 在同一台机器或可达本地环境里运行 OpenClaw 的使用者 - 在同一台机器或可达本地环境里运行 OpenClaw 的使用者
@@ -67,7 +74,7 @@ UI_MODE=true npm run dev
### 用量 ### 用量
- 展示今日、7 天、30 天的用量和花费趋势。 - 展示今日、7 天、30 天的用量和花费趋势。
- 包含订阅窗口、配额消耗、用量结构和数据连接状态。 - 包含订阅窗口、配额消耗、用量结构、上下文压力和数据连接状态。
- 最适合判断花费或额度是否开始有风险。 - 最适合判断花费或额度是否开始有风险。
### 员工 ### 员工
@@ -78,6 +85,7 @@ UI_MODE=true npm run dev
### 记忆 ### 记忆
- 一个直接基于源文件的记忆工作台,用来查看和编辑每日记忆与长期记忆。 - 一个直接基于源文件的记忆工作台,用来查看和编辑每日记忆与长期记忆。
- 范围跟随 `openclaw.json` 里的活跃 agent,不会把已删除 agent 继续显示出来。 - 范围跟随 `openclaw.json` 里的活跃 agent,不会把已删除 agent 继续显示出来。
- 现在还会直接告诉你每个智能体的记忆是否正常、可搜索、是否需要检查。
- 最适合查看或维护当前 OpenClaw 团队真实在用的记忆内容。 - 最适合查看或维护当前 OpenClaw 团队真实在用的记忆内容。
### 文档 ### 文档
@@ -93,6 +101,7 @@ UI_MODE=true npm run dev
### 设置 ### 设置
- 展示安全模式、连接器状态和数据链路预期。 - 展示安全模式、连接器状态和数据链路预期。
- 会明确告诉你哪些数据已经接上,哪些还只是部分可见,哪些高风险动作是故意关闭的。 - 会明确告诉你哪些数据已经接上,哪些还只是部分可见,哪些高风险动作是故意关闭的。
- 现在还包含 `接线状态`、`安全风险摘要` 和 `更新状态` 三张关键卡片。
- 最适合排查环境配置、解释为什么某些信号缺失。 - 最适合排查环境配置、解释为什么某些信号缺失。
## 核心约束 ## 核心约束
+37
View File
@@ -1,5 +1,42 @@
# Progress # 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 ## Phase 152 (Session-preview cold-start optimization without UI changes) — Completed
- Scope: - Scope:
- Reduce the overview cold-start stall caused by session preview history reads without changing page structure, copy, or data semantics. - Reduce the overview cold-start stall caused by session preview history reads without changing page structure, copy, or data semantics.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "openclaw-control-center", "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.", "description": "Safety-first local control center for OpenClaw observability, task operations, and operator review.",
"license": "MIT", "license": "MIT",
"type": "commonjs", "type": "commonjs",
+1
View File
@@ -10,6 +10,7 @@ while IFS= read -r file; do
done < <( done < <(
rg --files --hidden \ rg --files --hidden \
-g '!.git/**' \ -g '!.git/**' \
-g '!.pr-reviews/**' \
-g '!node_modules/**' \ -g '!node_modules/**' \
-g '!dist/**' \ -g '!dist/**' \
-g '!runtime/**' \ -g '!runtime/**' \
+15 -5
View File
@@ -12,6 +12,7 @@ const fs = require("node:fs");
const PORT = process.env.UI_SMOKE_PORT || "4516"; const PORT = process.env.UI_SMOKE_PORT || "4516";
const WAIT_SECONDS = Number(process.env.UI_SMOKE_WAIT_SECONDS || "10"); 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 ROOT = path.resolve(__dirname, "..");
const LOG_DIR = path.join(ROOT, "runtime"); const LOG_DIR = path.join(ROOT, "runtime");
const LOG_FILE = path.join(LOG_DIR, `ui-smoke-${PORT}.log`); const LOG_FILE = path.join(LOG_DIR, `ui-smoke-${PORT}.log`);
@@ -44,7 +45,7 @@ function fetch(urlPath) {
res.on("end", () => resolve(data)); res.on("end", () => resolve(data));
}); });
req.on("error", reject); 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; const deadline = Date.now() + WAIT_SECONDS * 1000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
await fetch("/"); await fetch("/healthz");
return; return;
} catch (_) { } catch (_) {
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
@@ -64,12 +65,21 @@ async function waitForUI() {
} }
async function checkPage(urlPath, keywords, label) { async function checkPage(urlPath, keywords, label) {
const deadline = Date.now() + PAGE_WAIT_SECONDS * 1000;
let lastError = null;
while (Date.now() < deadline) {
try {
const body = await fetch(urlPath); const body = await fetch(urlPath);
const found = keywords.some((kw) => body.includes(kw)); const found = keywords.some((kw) => body.includes(kw));
if (!found) { if (found) return;
console.error(`FAIL: ${label} — none of [${keywords.join(", ")}] found in response (${body.length} bytes).`); lastError = new Error(`none of [${keywords.join(", ")}] found in response (${body.length} bytes)`);
cleanup(1); } 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() { async function main() {
+495
View File
@@ -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<T> {
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<unknown> | undefined;
let statusInFlight: Promise<unknown> | undefined;
let gatewayCache: TimedSourceCache<unknown> | undefined;
let gatewayInFlight: Promise<unknown> | undefined;
let securityCache: TimedSourceCache<unknown> | undefined;
let securityInFlight: Promise<unknown> | undefined;
let updateCache: TimedSourceCache<unknown> | undefined;
let updateInFlight: Promise<unknown> | undefined;
let memoryCache: TimedSourceCache<unknown> | undefined;
let memoryInFlight: Promise<unknown> | undefined;
export function primeOpenClawCliInsights(): void {
void loadCachedOpenClawConnectionSummary();
void loadCachedOpenClawUpdateSummary();
void loadCachedOpenClawSecuritySummary();
void loadCachedOpenClawMemorySummary();
}
export async function loadCachedOpenClawConnectionSummary(): Promise<OpenClawConnectionSummary> {
const [statusJson, gatewayJson] = await Promise.all([
loadCachedOpenClawStatusJson(),
loadCachedOpenClawGatewayStatusJson(),
]);
return summarizeOpenClawConnection(statusJson, gatewayJson);
}
export async function loadCachedOpenClawUpdateSummary(): Promise<OpenClawUpdateSummary> {
const [statusJson, updateJson] = await Promise.all([
loadCachedOpenClawStatusJson(),
loadCachedOpenClawUpdateStatusJson(),
]);
return summarizeOpenClawUpdate(statusJson, updateJson);
}
export async function loadCachedOpenClawSecuritySummary(): Promise<OpenClawSecuritySummary> {
const securityJson = await loadCachedOpenClawSecurityAuditJson();
return summarizeOpenClawSecurity(securityJson);
}
export async function loadCachedOpenClawMemorySummary(): Promise<OpenClawMemorySummary> {
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<unknown> {
return loadSourceWithCache(
statusCache,
statusInFlight,
() => runOpenClawJson(["status", "--json"], {}),
(value) => {
statusCache = value;
},
(value) => {
statusInFlight = value;
},
);
}
async function loadCachedOpenClawGatewayStatusJson(): Promise<unknown> {
return loadSourceWithCache(
gatewayCache,
gatewayInFlight,
() => runOpenClawJson(["gateway", "status", "--json"], {}),
(value) => {
gatewayCache = value;
},
(value) => {
gatewayInFlight = value;
},
);
}
async function loadCachedOpenClawSecurityAuditJson(): Promise<unknown> {
return loadSourceWithCache(
securityCache,
securityInFlight,
() => runOpenClawJson(["security", "audit", "--json"], {}),
(value) => {
securityCache = value;
},
(value) => {
securityInFlight = value;
},
);
}
async function loadCachedOpenClawUpdateStatusJson(): Promise<unknown> {
return loadSourceWithCache(
updateCache,
updateInFlight,
() => runOpenClawJson(["update", "status", "--json"], {}),
(value) => {
updateCache = value;
},
(value) => {
updateInFlight = value;
},
);
}
async function loadCachedOpenClawMemoryStatusJson(): Promise<unknown> {
return loadSourceWithCache(
memoryCache,
memoryInFlight,
() => runOpenClawJson(["memory", "status", "--json"], []),
(value) => {
memoryCache = value;
},
(value) => {
memoryInFlight = value;
},
);
}
async function loadSourceWithCache<T>(
cache: TimedSourceCache<T> | undefined,
inFlight: Promise<T> | undefined,
loader: () => Promise<T>,
assignCache: (value: TimedSourceCache<T> | undefined) => void,
assignInFlight: (value: Promise<T> | undefined) => void,
): Promise<T> {
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<unknown> {
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<string, unknown> | undefined {
return input !== null && typeof input === "object" ? (input as Record<string, unknown>) : 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;
}
+610 -1
View File
@@ -38,6 +38,18 @@ import {
normalizeToken, normalizeToken,
readAuthorizationBearer, readAuthorizationBearer,
} from "../runtime/local-token-auth"; } 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 { appendOperationAudit } from "../runtime/operation-audit";
import { ApprovalActionService } from "../runtime/approval-action-service"; import { ApprovalActionService } from "../runtime/approval-action-service";
import { buildActionQueueLinks } from "../runtime/action-queue-links"; 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", () => { server.listen(port, "127.0.0.1", () => {
console.log(`[mission-control] ui listening at http://127.0.0.1:${port}`); console.log(`[mission-control] ui listening at http://127.0.0.1:${port}`);
void Promise.resolve().then(() => primeOpenClawCliInsights());
void primeUiRenderCaches(toolClient); void primeUiRenderCaches(toolClient);
}); });
return server; return server;
@@ -2627,6 +2640,584 @@ function renderGlobalVisibilityStripCard(model: GlobalVisibilityViewModel, langu
</section>`; </section>`;
} }
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 `<article class="card" id="overview-connection-health">
<div class="overview-command-head">
<h2>${escapeHtml(pickUiText(language, "Connection health", "接线状态"))}</h2>
<div>${badge("info", pickUiText(language, "Loading", "读取中"))}</div>
</div>
<div class="empty-state">${escapeHtml(pickUiText(language, "Connection signals are loading.", "正在读取接线状态。"))}</div>
</article>`;
}
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 `<article class="card" id="overview-connection-health">
<div class="overview-command-head">
<div>
<h2>${escapeHtml(pickUiText(language, "Connection health", "接线状态"))}</h2>
<div class="meta">${escapeHtml(headline)}</div>
</div>
<a class="btn" href="${escapeHtml(buildHomeHref({ quick: "all" }, true, "settings", language))}">${escapeHtml(
pickUiText(language, "Open settings", "查看设置"),
)}</a>
</div>
<div class="status-strip">
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Healthy links", "已接通"))}</span><strong>${connectedCount}/${rows.length}</strong></div>
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Updated", "更新"))}</span><strong>${escapeHtml(formatTimeAgoFromNow(summary.generatedAt, language))}</strong></div>
</div>
<div class="decision-list">${rows
.map(
(item) => `<div class="decision-row">
<div class="decision-row-copy">
<strong>${escapeHtml(item.label)}</strong>
<div class="meta">${badge(item.status, insightStatusLabel(item.status, language))} ${escapeHtml(item.detail)}</div>
</div>
<div class="decision-row-value">${escapeHtml(item.value)}</div>
</div>`,
)
.join("")}</div>
</article>`;
}
function renderOpenClawSecuritySection(
summary: OpenClawSecuritySummary | undefined,
language: UiLanguage,
): string {
if (!summary) {
return `<section class="card" id="security-risk-summary">
<h2>${escapeHtml(pickUiText(language, "Security risk summary", "安全风险摘要"))}</h2>
<div class="empty-state">${escapeHtml(pickUiText(language, "Security audit is loading.", "正在读取安全审计。"))}</div>
</section>`;
}
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
? `<div class="empty-state">${escapeHtml(pickUiText(language, "No audit findings yet.", "当前没有审计结果。"))}</div>`
: `<ul class="story-list">${summary.findings
.slice(0, 4)
.map((item) => {
const tone = item.severity === "critical" ? "blocked" : item.severity === "warn" ? "warn" : "info";
const localized = localizeSecurityFinding(item, language);
return `<li>${badge(tone, insightStatusLabel(tone, language))} <strong>${escapeHtml(localized.title)}</strong><div class="meta">${escapeHtml(
safeTruncate(normalizeInlineText(localized.detail), 200),
)}</div>${
localized.remediation
? `<div class="meta">${escapeHtml(pickUiText(language, "Next step", "下一步"))}:${escapeHtml(
safeTruncate(normalizeInlineText(localized.remediation), 180),
)}</div>`
: ""
}</li>`;
})
.join("")}</ul>`;
return `<section class="card" id="security-risk-summary">
<div class="overview-command-head">
<div>
<h2>${escapeHtml(pickUiText(language, "Security risk summary", "安全风险摘要"))}</h2>
<div class="meta">${escapeHtml(headline)}</div>
</div>
<div>${badge(summary.status, insightStatusLabel(summary.status, language))}</div>
</div>
<div class="status-strip">
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Critical", "高风险"))}</span><strong>${summary.counts.critical}</strong></div>
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Warnings", "需关注"))}</span><strong>${summary.counts.warn}</strong></div>
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Info", "提示"))}</span><strong>${summary.counts.info}</strong></div>
</div>
${findingsHtml}
</section>`;
}
function renderOpenClawUpdateSection(
summary: OpenClawUpdateSummary | undefined,
language: UiLanguage,
): string {
if (!summary) {
return `<section class="card" id="update-status-card">
<h2>${escapeHtml(pickUiText(language, "Update status", "更新状态"))}</h2>
<div class="empty-state">${escapeHtml(pickUiText(language, "Update status is loading.", "正在读取更新状态。"))}</div>
</section>`;
}
const headline = summary.updateAvailable
? pickUiText(language, "A newer OpenClaw version is available.", "发现了更新版本。")
: pickUiText(language, "This OpenClaw runtime is already up to date.", "当前 OpenClaw 已是最新。");
return `<section class="card" id="update-status-card">
<div class="overview-command-head">
<div>
<h2>${escapeHtml(pickUiText(language, "Update status", "更新状态"))}</h2>
<div class="meta">${escapeHtml(headline)}</div>
</div>
<div>${badge(summary.status, insightStatusLabel(summary.status, language))}</div>
</div>
<div class="status-strip">
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Current", "当前版本"))}</span><strong>${escapeHtml(localizeCurrentVersionValue(summary, language))}</strong></div>
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Latest", "最新版本"))}</span><strong>${escapeHtml(summary.latestVersion ?? "-")}</strong></div>
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Channel", "更新通道"))}</span><strong>${escapeHtml(localizeUpdateChannelLabel(summary.channelLabel, language))}</strong></div>
</div>
<div class="meta">${escapeHtml(pickUiText(language, "Install method", "安装方式"))} ${escapeHtml(localizeUpdateInstallKind(summary.installKind, language))} · ${escapeHtml(
pickUiText(language, "Package manager", "包管理器"),
)} ${escapeHtml(summary.packageManager ?? "-")}</div>
</section>`;
}
function renderContextPressureCard(snapshot: UsageCostSnapshot, language: UiLanguage): string {
if (snapshot.contextWindows.length === 0) {
return `<section class="card" id="session-context-pressure">
<h2>${escapeHtml(pickUiText(language, "Context pressure", "上下文压力"))}</h2>
<div class="empty-state">${escapeHtml(pickUiText(language, "No session context data yet.", "当前还没有会话上下文数据。"))}</div>
</section>`;
}
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 `<section class="card" id="session-context-pressure">
<div class="overview-command-head">
<div>
<h2>${escapeHtml(pickUiText(language, "Context pressure", "上下文压力"))}</h2>
<div class="meta">${escapeHtml(headline)}</div>
</div>
<div>${badge(
criticalCount > 0 ? "warn" : warnCount > 0 ? "info" : "ok",
criticalCount > 0 ? pickUiText(language, "High", "较高") : warnCount > 0 ? pickUiText(language, "Watch", "观察中") : pickUiText(language, "Healthy", "正常"),
)}</div>
</div>
<div class="status-strip">
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Critical", "临界"))}</span><strong>${criticalCount}</strong></div>
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Warning", "预警"))}</span><strong>${warnCount}</strong></div>
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Highest usage", "最高占比"))}</span><strong>${escapeHtml(
topUsage?.usagePercent !== undefined ? `${topUsage.usagePercent.toFixed(1)}%` : "-",
)}</strong></div>
</div>
<div class="decision-list">${ranked
.slice(0, 6)
.map((item) => {
const href = buildSessionDetailHref(item.sessionKey, language);
const tone = item.thresholdState === "critical" ? "warn" : item.thresholdState === "warn" ? "info" : item.thresholdState === "ok" ? "ok" : "blocked";
const usageText =
item.usagePercent !== undefined ? `${item.usagePercent.toFixed(1)}%` : pickUiText(language, "Unavailable", "不可用");
return `<a class="decision-row" href="${escapeHtml(href)}">
<div class="decision-row-copy">
<strong>${escapeHtml(simplifyUsageLabel(item.sessionLabel))}</strong>
<div class="meta">${badge(
tone,
item.thresholdState === "critical"
? pickUiText(language, "Critical", "临界")
: item.thresholdState === "warn"
? pickUiText(language, "Warning", "预警")
: item.thresholdState === "ok"
? pickUiText(language, "Healthy", "正常")
: pickUiText(language, "Unknown", "未知"),
)} ${escapeHtml(humanizeOperatorLabel(item.agentId))} · ${escapeHtml(item.model)}</div>
<div class="meta">${escapeHtml(pickUiText(language, "Used", "已使用"))} ${formatInt(item.usedTokens)} / ${item.contextLimitTokens ? formatInt(item.contextLimitTokens) : "-"} · ${escapeHtml(item.paceLabel)}</div>
</div>
<div class="decision-row-value">${escapeHtml(usageText)}</div>
</a>`;
})
.join("")}</div>
</section>`;
}
function renderMemoryStateSection(summary: OpenClawMemorySummary | undefined, language: UiLanguage): string {
if (!summary) {
return `<section class="card" id="memory-status-card">
<h2>${escapeHtml(pickUiText(language, "Memory status", "记忆状态"))}</h2>
<div class="empty-state">${escapeHtml(pickUiText(language, "Memory status is loading.", "正在读取记忆状态。"))}</div>
</section>`;
}
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 `<section class="card" id="memory-status-card">
<div class="overview-command-head">
<div>
<h2>${escapeHtml(pickUiText(language, "Memory status", "记忆状态"))}</h2>
<div class="meta">${escapeHtml(headline)}</div>
</div>
<div>${badge(summary.status, insightStatusLabel(summary.status, language))}</div>
</div>
<div class="status-strip">
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Healthy", "正常"))}</span><strong>${summary.okCount}</strong></div>
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Needs attention", "需关注"))}</span><strong>${summary.warnCount}</strong></div>
<div class="status-chip"><span>${escapeHtml(pickUiText(language, "Unavailable", "不可用"))}</span><strong>${summary.blockedCount}</strong></div>
</div>
<div class="decision-list">${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 `<div class="decision-row">
<div class="decision-row-copy">
<strong>${escapeHtml(humanizeOperatorLabel(item.agentId))}</strong>
<div class="meta">${badge(item.status, insightStatusLabel(item.status, language))} ${escapeHtml(detailParts.join(" · "))}</div>
</div>
<div class="decision-row-value">${escapeHtml(item.searchable ? pickUiText(language, "Ready", "可用") : pickUiText(language, "Check", "检查"))}</div>
</div>`;
})
.join("")}</div>
</section>`;
}
function scoreCoverageStatus(status: DataCoverageStatus): number { function scoreCoverageStatus(status: DataCoverageStatus): number {
if (status === "connected") return 100; if (status === "connected") return 100;
if (status === "partial") return 55; if (status === "partial") return 55;
@@ -3597,6 +4188,10 @@ async function renderHtml(
const needsTeamSnapshot = activeSection === "team"; const needsTeamSnapshot = activeSection === "team";
const needsMemoryFiles = activeSection === "memory"; const needsMemoryFiles = activeSection === "memory";
const needsWorkspaceFiles = activeSection === "docs"; const needsWorkspaceFiles = activeSection === "docs";
const needsConnectionHealth = activeSection === "settings";
const needsSecuritySummary = activeSection === "settings";
const needsUpdateSummary = activeSection === "settings";
const needsMemoryState = activeSection === "memory";
markRenderPhase("snapshot"); markRenderPhase("snapshot");
const exceptions = commanderExceptions(snapshot); const exceptions = commanderExceptions(snapshot);
const exceptionsFeed = commanderExceptionsFeed(snapshot); const exceptionsFeed = commanderExceptionsFeed(snapshot);
@@ -3634,7 +4229,7 @@ async function renderHtml(
loadCachedOfficeSessionPresence(), loadCachedOfficeSessionPresence(),
]); ]);
markRenderPhase("shared-data"); 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 needsTeamSnapshot
? loadTeamSnapshot(officeRoster) ? loadTeamSnapshot(officeRoster)
: Promise.resolve<TeamSnapshot>({ : Promise.resolve<TeamSnapshot>({
@@ -3655,6 +4250,10 @@ async function renderHtml(
24, 24,
) )
: Promise.resolve<SessionConversationListItem[]>([]), : Promise.resolve<SessionConversationListItem[]>([]),
needsConnectionHealth ? loadCachedOpenClawConnectionSummary() : Promise.resolve<OpenClawConnectionSummary | undefined>(undefined),
needsSecuritySummary ? loadCachedOpenClawSecuritySummary() : Promise.resolve<OpenClawSecuritySummary | undefined>(undefined),
needsUpdateSummary ? loadCachedOpenClawUpdateSummary() : Promise.resolve<OpenClawUpdateSummary | undefined>(undefined),
needsMemoryState ? loadCachedOpenClawMemorySummary() : Promise.resolve<OpenClawMemorySummary | undefined>(undefined),
]); ]);
markRenderPhase("section-assets"); markRenderPhase("section-assets");
const usageToday = usageCost.periods.find((item) => item.key === "today"); const usageToday = usageCost.periods.find((item) => item.key === "today");
@@ -4390,6 +4989,11 @@ async function renderHtml(
.join("")}</div>`; .join("")}</div>`;
const subscriptionSidebarRows = renderSubscriptionSidebarSummary(usageCost.subscription, options.language); const subscriptionSidebarRows = renderSubscriptionSidebarSummary(usageCost.subscription, options.language);
const usageDetailHref = buildHomeHref({ quick: "all" }, options.compactStatusStrip, "usage-cost", options.language, options.usageView); 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 const overviewUsagePeriods = isTodayUsageView
? usageCost.periods.filter((item) => item.key === "today") ? usageCost.periods.filter((item) => item.key === "today")
: usageCost.periods.filter((item) => item.key === "today" || item.key === "7d"); : usageCost.periods.filter((item) => item.key === "today" || item.key === "7d");
@@ -4909,6 +5513,7 @@ async function renderHtml(
<div class="meta">${escapeHtml(t("Edits here sync directly back to the real memory files on the OpenClaw machine.", "这里的编辑会直接同步到 OpenClaw 机器上的真实记忆文件。"))}</div> <div class="meta">${escapeHtml(t("Edits here sync directly back to the real memory files on the OpenClaw machine.", "这里的编辑会直接同步到 OpenClaw 机器上的真实记忆文件。"))}</div>
</section> </section>
${memoryWorkbench} ${memoryWorkbench}
${memoryStateSection}
`; `;
const mainDocumentCount = workspaceFiles.filter((entry) => entry.facetKey === "main").length; const mainDocumentCount = workspaceFiles.filter((entry) => entry.facetKey === "main").length;
const workspaceWorkbench = needsWorkspaceFiles const workspaceWorkbench = needsWorkspaceFiles
@@ -4970,6 +5575,7 @@ async function renderHtml(
<div class="fold-body">${usageCronAgentShareHtml}</div> <div class="fold-body">${usageCronAgentShareHtml}</div>
</details> </details>
</section> </section>
${contextPressureCard}
<details class="card compact-details"> <details class="card compact-details">
<summary>${escapeHtml(t("Advanced detail (attribution / model / context / budget)", "高级明细(归因/模型/上下文/预算)"))}</summary> <summary>${escapeHtml(t("Advanced detail (attribution / model / context / budget)", "高级明细(归因/模型/上下文/预算)"))}</summary>
<div class="fold-body"> <div class="fold-body">
@@ -5258,6 +5864,9 @@ async function renderHtml(
</details> </details>
`; `;
const settingsSection = ` const settingsSection = `
${connectionHealthCard}
${securityRiskSection}
${updateStatusSection}
<section class="card"> <section class="card">
<h2>安全开关</h2> <h2>安全开关</h2>
<table> <table>
+154
View File
@@ -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);
});
+24
View File
@@ -387,6 +387,30 @@ test("usage dashboard includes token type share and cron token share sections",
assert(source.includes("selectedUsageBreakdown.byCronAgent")); 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("</section>\n ${contextPressureCard}\n <details class=\"card compact-details\">"));
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 () => { test("memory and workspace sections expose editable file workbenches", async () => {
const source = (await readFile("src/ui/server.ts", "utf8")).replace(/\r\n/g, "\n"); const source = (await readFile("src/ui/server.ts", "utf8")).replace(/\r\n/g, "\n");
assert(source.includes("/api/files")); assert(source.includes("/api/files"));