Add operator insight cards and cut v0.1.1
This commit is contained in:
1 parent
1ba2ad0322
commit
fd0ac1761a
11 files changed
+1358
-9
No files matched your search
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|||||||
### 设置
|
### 设置
|
||||||
- 展示安全模式、连接器状态和数据链路预期。
|
- 展示安全模式、连接器状态和数据链路预期。
|
||||||
- 会明确告诉你哪些数据已经接上,哪些还只是部分可见,哪些高风险动作是故意关闭的。
|
- 会明确告诉你哪些数据已经接上,哪些还只是部分可见,哪些高风险动作是故意关闭的。
|
||||||
|
- 现在还包含 `接线状态`、`安全风险摘要` 和 `更新状态` 三张关键卡片。
|
||||||
- 最适合排查环境配置、解释为什么某些信号缺失。
|
- 最适合排查环境配置、解释为什么某些信号缺失。
|
||||||
|
|
||||||
## 核心约束
|
## 核心约束
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
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>
|
||||||
|
|||||||
@@ -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"));
|
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"));
|
||||||
|
|||||||
Reference in new issue
Block a user