Add operator insight cards and cut v0.1.1
This commit is contained in:
1 parent
1ba2ad0322
commit
fd0ac1761a
11 files changed
+1360
-11
No files matched your search
@@ -13,6 +13,7 @@ pnpm-debug.log*
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.pr-reviews/
|
||||
plans/
|
||||
workflows/
|
||||
mission.config.json
|
||||
|
||||
+10
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
### 设置
|
||||
- 展示安全模式、连接器状态和数据链路预期。
|
||||
- 会明确告诉你哪些数据已经接上,哪些还只是部分可见,哪些高风险动作是故意关闭的。
|
||||
- 现在还包含 `接线状态`、`安全风险摘要` 和 `更新状态` 三张关键卡片。
|
||||
- 最适合排查环境配置、解释为什么某些信号缺失。
|
||||
|
||||
## 核心约束
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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/**' \
|
||||
|
||||
+17
-7
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
</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 {
|
||||
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<TeamSnapshot>({
|
||||
@@ -3655,6 +4250,10 @@ async function renderHtml(
|
||||
24,
|
||||
)
|
||||
: 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");
|
||||
const usageToday = usageCost.periods.find((item) => item.key === "today");
|
||||
@@ -4390,6 +4989,11 @@ async function renderHtml(
|
||||
.join("")}</div>`;
|
||||
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(
|
||||
<div class="meta">${escapeHtml(t("Edits here sync directly back to the real memory files on the OpenClaw machine.", "这里的编辑会直接同步到 OpenClaw 机器上的真实记忆文件。"))}</div>
|
||||
</section>
|
||||
${memoryWorkbench}
|
||||
${memoryStateSection}
|
||||
`;
|
||||
const mainDocumentCount = workspaceFiles.filter((entry) => entry.facetKey === "main").length;
|
||||
const workspaceWorkbench = needsWorkspaceFiles
|
||||
@@ -4970,6 +5575,7 @@ async function renderHtml(
|
||||
<div class="fold-body">${usageCronAgentShareHtml}</div>
|
||||
</details>
|
||||
</section>
|
||||
${contextPressureCard}
|
||||
<details class="card compact-details">
|
||||
<summary>${escapeHtml(t("Advanced detail (attribution / model / context / budget)", "高级明细(归因/模型/上下文/预算)"))}</summary>
|
||||
<div class="fold-body">
|
||||
@@ -5258,6 +5864,9 @@ async function renderHtml(
|
||||
</details>
|
||||
`;
|
||||
const settingsSection = `
|
||||
${connectionHealthCard}
|
||||
${securityRiskSection}
|
||||
${updateStatusSection}
|
||||
<section class="card">
|
||||
<h2>安全开关</h2>
|
||||
<table>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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("</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 () => {
|
||||
const source = (await readFile("src/ui/server.ts", "utf8")).replace(/\r\n/g, "\n");
|
||||
assert(source.includes("/api/files"));
|
||||
|
||||
Reference in new issue
Block a user