mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 02:18:06 +00:00
feat(status+webui): 运行态图形化 —— 排队/四级中断队列/中断栈/驻留子/通道拓扑
需求:首页不该只有文字,要能一眼看出内核在忙什么——排队消息数、各级中断
排队与中断栈、驻留子 agent 数量;这些要向**内部 SDK 暴露接口**,供 WebUI 等应用
展示;通道划分也要能画出来。
## 一、内核状态面(internal/sdk,内部 SDK,不受公开 SDK 冻结约束)
* SchedulerStatus 补:
- interrupt_queues[5]:**四级中断队列各自的深度**(下标即级别 1..4,下标 0 恒 0,
这样 level 能直接当数组下标用)。此前只有 pending_interrupts 总数,
看不出"堵在 L1 还是 L4"——四级是抢占优先级,堵在哪级是完全不同的运行状态。
- immediate:刚抢占成功、下一个安全点立即运行的那个中断(此前完全不可见)。
- suspend_frames:中断栈的帧(栈底→栈顶,只给任务标识),depth 之外还能看出
"谁被谁打断"。
- interrupts_by_level / preempts_by_level:各级累计登记数与抢占成功数。
* KernelStatus 补 residents(驻留子运行时视图:状态/轮次/上下文已满/输入通道/允许输出)。
刻意**不带**每个驻留子的 inputch 登记明细——状态面会被反复轮询,明细会让
每次 /status 背上几十 KB;只给表大小,要明细走专门接口。
* ChannelInfo 补 direction(in/out/io)、description、tools、output_caps、caps_text。
此前 collectKernelStatus 只透传 Name/Type,把描述/工具/能力**全丢了**,
前端只能画出一排光秃秃的名字。
## 二、WebUI
* 新增只读 `/api/v1/runtime`:只回运行态三件事(scheduler/residents/channels),
实测 **1.0KB**(/kernel 是 30KB 级)——所以能 3 秒轮询做"实时"感,
而不必反复拉全量状态。
* 首页新增「运行态」面板(纯 CSS + 内联 SVG,前端仍无构建链):
- 四个数字块:排队任务 / 待处理中断 / 中断栈(深度/上限) / 驻留子 Agent,带占比条;
- 四级中断队列条形图:每级"深度 · 登记/抢占",四级语义**照抄内核**
(L4 内核独占 / L3 交互 / L2 消息 / L1 后台),不自己起名字;
- 中断栈层叠图(栈顶在上)+ ⚡立即运行项;
- 通道拓扑:输入通道 → 内核 → 输出通道,双向通道两侧都出现,能力以胶囊标签显示。
* 3 秒轮询只在总览页可见时才发请求;切回总览时 renderAll 会立刻补一次。
## 验证
* 新增 TestSchedulerStatusExposesLevelsAndStack(四级队列/立即项/栈帧/各级计数映射,
并断言"未使用的级别必须为 0"与"下标 0 恒 0")、TestChannelInfoCarriesTopology、
TestRuntimeEndpoint(形状 + 不携带 tools/plugins + 无状态源时 503)。
* go build / vet / agent+core+sdk+plugin+webui 全量测试绿。
* 真实浏览器实测(CDP 驱动,注入运行态样本走真实渲染路径):
数字块 [3, 5, 2/4, 2];四级条 L4=1/L3=2/L2=1/L1=1 与数据一致;
栈帧按"栈底→栈顶"渲染且标出栈顶;通道左右分列、io 通道两侧都出现。
This commit is contained in:
@ -2097,3 +2097,190 @@
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 127, 172, 0.35) transparent;
|
||||
}
|
||||
|
||||
/* ===== 运行态面板(图形化总览)=====
|
||||
目标是"一眼看懂内核现在在干什么":排队/中断/中断栈/驻留子四个数字,
|
||||
四级中断队列的条形,中断栈的层叠图,以及通道拓扑。全部用纯 CSS + 内联 SVG,
|
||||
不引入任何依赖(前端刻意没有构建链)。 */
|
||||
.rt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
.rt-tile {
|
||||
position: relative;
|
||||
padding: 12px 14px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rt-tile .rt-num {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.rt-tile .rt-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.rt-tile .rt-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.rt-tile.rt-active {
|
||||
border-color: var(--sakura-400);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.rt-tile.rt-warn .rt-num {
|
||||
color: #ffb86b;
|
||||
}
|
||||
/* 底部细条:把"占了多少"用长度表达,比纯数字更快读懂 */
|
||||
.rt-bar {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rt-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--sakura-400), var(--frost-300));
|
||||
transition: width 0.35s var(--ease-out);
|
||||
}
|
||||
.rt-levels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.rt-level {
|
||||
display: grid;
|
||||
grid-template-columns: 74px 1fr 96px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.rt-level .rt-lv-name {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rt-level .rt-lv-track {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
.rt-level .rt-lv-track > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
transition: width 0.35s var(--ease-out);
|
||||
}
|
||||
.rt-level .rt-lv-meta {
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-lv-4 > i { background: #ff5c7a; }
|
||||
.rt-lv-3 > i { background: #ffa657; }
|
||||
.rt-lv-2 > i { background: var(--sakura-400); }
|
||||
.rt-lv-1 > i { background: var(--frost-300); }
|
||||
/* 中断栈:栈顶在上,做出"层叠现场"的观感 */
|
||||
.rt-stack {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 4px;
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
.rt-frame {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-left: 3px solid var(--sakura-400);
|
||||
}
|
||||
.rt-frame .rt-frame-top {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.rt-empty {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
/* 通道拓扑:输入 → 内核 → 输出 */
|
||||
.rt-topo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.rt-topo-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.rt-topo-col.rt-right {
|
||||
text-align: right;
|
||||
}
|
||||
.rt-chan {
|
||||
padding: 6px 9px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
cursor: default;
|
||||
}
|
||||
.rt-chan .rt-chan-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.rt-chan .rt-chan-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.rt-chan .rt-caps {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.rt-cap {
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(136, 192, 208, 0.16);
|
||||
color: var(--frost-300);
|
||||
}
|
||||
.rt-core {
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
background: radial-gradient(circle at 50% 40%, rgba(243, 59, 124, 0.35), rgba(24, 24, 38, 0.9));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.rt-section-title {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.4px;
|
||||
margin: 12px 0 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
kernel: null,
|
||||
settings: {},
|
||||
meta: {},
|
||||
runtime: null, // /api/v1/runtime 的运行态小快照(调度器/驻留子/通道)
|
||||
pluginMeta: {},
|
||||
disabledPlugins: [],
|
||||
settingsPlugins: ["core"],
|
||||
@ -464,6 +465,11 @@
|
||||
} catch (e) {
|
||||
console.error("renderOverview", e);
|
||||
}
|
||||
try {
|
||||
await loadRuntime();
|
||||
} catch (e) {
|
||||
console.error("loadRuntime", e);
|
||||
}
|
||||
try {
|
||||
renderChat();
|
||||
} catch (e) {
|
||||
@ -559,10 +565,175 @@
|
||||
|
||||
// ===== Overview =====
|
||||
|
||||
// ===== 运行态面板:把内核的调度器/驻留子/通道画出来 =====
|
||||
//
|
||||
// 数据来自 /api/v1/runtime(内核 internal/sdk 暴露的 KernelStatus 子集),
|
||||
// 每 3 秒刷一次——运行态要"实时",而 /kernel 是 30KB 级的全量状态,不适合秒级轮询。
|
||||
//
|
||||
// 四个数字块回答"现在忙不忙":排队任务、待处理中断、中断栈深度、驻留子数量;
|
||||
// 下面的四级条形回答"堵在哪一级";中断栈图回答"谁打断了谁";
|
||||
// 通道拓扑回答"消息从哪儿进、能往哪儿出"。
|
||||
// 四级语义直接照抄内核(internal/agent/core/scheduler.go 的 Level 定义),
|
||||
// 别自己起名字——前端叫法一旦和内核不一致,看板就成了误导。
|
||||
var RT_LEVELS = [
|
||||
{ lv: 4, name: "L4 内核独占", cls: "rt-lv-4" },
|
||||
{ lv: 3, name: "L3 交互", cls: "rt-lv-3" },
|
||||
{ lv: 2, name: "L2 消息", cls: "rt-lv-2" },
|
||||
{ lv: 1, name: "L1 后台", cls: "rt-lv-1" },
|
||||
];
|
||||
var RT_CAP_NAMES = { 1: "text", 2: "file", 4: "image", 8: "audio", 16: "structured" };
|
||||
|
||||
function rtTile(num, label, sub, pct, active, warn) {
|
||||
return (
|
||||
'<div class="rt-tile' +
|
||||
(active ? " rt-active" : "") +
|
||||
(warn ? " rt-warn" : "") +
|
||||
'"><div class="rt-num">' +
|
||||
num +
|
||||
'</div><div class="rt-label">' +
|
||||
label +
|
||||
"</div>" +
|
||||
(sub ? '<div class="rt-sub">' + sub + "</div>" : "") +
|
||||
'<div class="rt-bar"><i style="width:' +
|
||||
Math.max(0, Math.min(100, pct || 0)) +
|
||||
'%"></i></div></div>'
|
||||
);
|
||||
}
|
||||
|
||||
function rtCapsHtml(caps) {
|
||||
var out = "";
|
||||
Object.keys(RT_CAP_NAMES).forEach(function (bit) {
|
||||
if (caps & Number(bit)) {
|
||||
out += '<span class="rt-cap">' + RT_CAP_NAMES[bit] + "</span>";
|
||||
}
|
||||
});
|
||||
return out ? '<span class="rt-caps">' + out + "</span>" : "";
|
||||
}
|
||||
|
||||
function renderRuntime() {
|
||||
var el = document.getElementById("rt-panel");
|
||||
if (!el) return;
|
||||
var rt = state.runtime;
|
||||
if (!rt) {
|
||||
el.innerHTML = '<div class="rt-empty">' + __("运行态数据不可用", "runtime unavailable") + "</div>";
|
||||
return;
|
||||
}
|
||||
var sc = rt.scheduler || {};
|
||||
var residents = rt.residents || [];
|
||||
var channels = rt.channels || [];
|
||||
var q = sc.interrupt_queues || [0, 0, 0, 0, 0];
|
||||
var pending = sc.pending_interrupts || 0;
|
||||
var ready = sc.ready_queue_depth || 0;
|
||||
var stack = sc.suspend_stack || 0;
|
||||
var maxStack = sc.max_suspend_depth || 4;
|
||||
var frames = sc.suspend_frames || [];
|
||||
var byLv = sc.interrupts_by_level || [];
|
||||
var preLv = sc.preempts_by_level || [];
|
||||
|
||||
var html = '<div class="card"><h2>' + __("运行态", "Runtime") + "</h2>";
|
||||
// 四个数字块
|
||||
html += '<div class="rt-grid">';
|
||||
html += rtTile(ready, __("排队任务", "Ready queue"), __("等待执行的输入", "inputs waiting"), ready ? Math.min(100, ready * 20) : 0, ready > 0);
|
||||
html += rtTile(pending, __("待处理中断", "Pending interrupts"), __("四级队列 + 立即抢占", "queued + immediate"), pending ? Math.min(100, pending * 25) : 0, pending > 0, pending > 0);
|
||||
html += rtTile(stack + "/" + maxStack, __("中断栈", "Interrupt stack"), __("嵌套抢占的现场", "nested frames"), (stack / (maxStack || 4)) * 100, stack > 0);
|
||||
var rFull = residents.filter(function (r) { return r.context_full; }).length;
|
||||
html += rtTile(residents.length, __("驻留子 Agent", "Resident agents"), rFull ? rFull + __(" 个上下文已满", " context-full") : __("常驻子任务", "long-lived children"), residents.length ? Math.min(100, residents.length * 20) : 0, residents.length > 0, rFull > 0);
|
||||
html += "</div>";
|
||||
|
||||
// 四级中断队列
|
||||
html += '<div class="rt-section-title">' + __("中断队列(按级别)", "Interrupt queues by level") + "</div>";
|
||||
var maxQ = Math.max(1, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0);
|
||||
html += '<div class="rt-levels">';
|
||||
RT_LEVELS.forEach(function (L) {
|
||||
var depth = q[L.lv] || 0;
|
||||
var reg = byLv[L.lv] || 0;
|
||||
var pre = preLv[L.lv] || 0;
|
||||
html +=
|
||||
'<div class="rt-level"><span class="rt-lv-name">' + L.name + "</span>" +
|
||||
'<span class="rt-lv-track ' + L.cls + '"><i style="width:' +
|
||||
(depth ? Math.max(4, (depth / maxQ) * 100) : 0) +
|
||||
'%"></i></span>' +
|
||||
'<span class="rt-lv-meta">' + depth + " · " +
|
||||
__("登记", "reg") + " " + reg + " / " + __("抢占", "pre") + " " + pre +
|
||||
"</span></div>";
|
||||
});
|
||||
html += "</div>";
|
||||
if (sc.immediate) {
|
||||
html += '<div class="rt-frame">⚡ ' + __("立即运行", "immediate") + ":" +
|
||||
escHtml(sc.immediate.kind || "") + " #" + sc.immediate.id +
|
||||
'<span class="rt-frame-top">L' + (sc.immediate.level || 0) + "</span></div>";
|
||||
}
|
||||
|
||||
// 中断栈(栈顶在上 → 用 column-reverse)
|
||||
html += '<div class="rt-section-title">' + __("中断栈(栈顶在上)", "Interrupt stack (top first)") + "</div>";
|
||||
if (frames.length) {
|
||||
html += '<div class="rt-stack">';
|
||||
frames.forEach(function (f, i) {
|
||||
var t = (f && f.task) || {};
|
||||
html += '<div class="rt-frame">' + escHtml(t.kind || "") + " #" + (t.id || "?") +
|
||||
'<span class="rt-frame-top">L' + (t.level || 0) +
|
||||
(i === frames.length - 1 ? " · " + __("栈顶", "top") : "") + "</span></div>";
|
||||
});
|
||||
html += "</div>";
|
||||
} else {
|
||||
html += '<div class="rt-empty">' + __("中断栈为空(当前无被抢占的现场)", "stack empty (nothing preempted)") + "</div>";
|
||||
}
|
||||
if (sc.running) {
|
||||
html += '<div class="rt-empty">' + __("正在运行", "running") + ":" +
|
||||
escHtml(sc.running.kind || "") + " #" + sc.running.id + " (L" + (sc.running.level || 0) + ")</div>";
|
||||
}
|
||||
|
||||
// 通道拓扑
|
||||
html += '<div class="rt-section-title">' + __("通道拓扑", "Channel topology") + "</div>";
|
||||
var ins = channels.filter(function (c) { return c.direction === "in" || c.direction === "io"; });
|
||||
var outs = channels.filter(function (c) { return c.direction === "out" || c.direction === "io"; });
|
||||
function chanHtml(c) {
|
||||
return '<div class="rt-chan" title="' + escHtml(c.description || "") + '">' +
|
||||
'<span class="rt-chan-name">' + escHtml(c.name) + "</span>" +
|
||||
rtCapsHtml(c.output_caps || 0) +
|
||||
'<div class="rt-chan-sub">' +
|
||||
escHtml((c.tools || []).length ? (c.tools || []).length + " " + __("个工具", "tools") : (c.description || "").slice(0, 26)) +
|
||||
"</div></div>";
|
||||
}
|
||||
html += '<div class="rt-topo">';
|
||||
html += '<div class="rt-topo-col">' + (ins.length ? ins.map(chanHtml).join("") : '<div class="rt-empty">' + __("无输入通道", "no input channel") + "</div>") + "</div>";
|
||||
html += '<div class="rt-core">' + __("内核", "Kernel") + "</div>";
|
||||
html += '<div class="rt-topo-col rt-right">' + (outs.length ? outs.map(chanHtml).join("") : '<div class="rt-empty">' + __("无输出通道", "no output channel") + "</div>") + "</div>";
|
||||
html += "</div>";
|
||||
|
||||
html += "</div>";
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
// loadRuntime 拉运行态小快照并就地重绘面板(约 2KB,可秒级轮询)。
|
||||
async function loadRuntime() {
|
||||
try {
|
||||
var rt = await api("/runtime");
|
||||
state.runtime = rt;
|
||||
renderRuntime();
|
||||
} catch (e) {
|
||||
state.runtime = null;
|
||||
}
|
||||
}
|
||||
|
||||
// startRuntimeTicker 起 3s 轮询:运行态只在总览页可见时才有意义,
|
||||
// 其它页签上不浪费请求。切回总览时 renderAll 会立刻再拉一次,不必等下一拍。
|
||||
function startRuntimeTicker() {
|
||||
if (state._runtimeTicker) return;
|
||||
state._runtimeTicker = setInterval(function () {
|
||||
var tab = document.querySelector("#tab-overview");
|
||||
if (tab && tab.classList.contains("active")) {
|
||||
loadRuntime();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
var s = state.status || {};
|
||||
var k = state.kernel;
|
||||
var html =
|
||||
'<div id="rt-panel"></div>' +
|
||||
'<div class="card"><h2>' +
|
||||
__("系统概览", "System Overview") +
|
||||
"</h2>" +
|
||||
@ -4404,6 +4575,7 @@
|
||||
renderAll();
|
||||
connectSSE();
|
||||
startUptimeTicker();
|
||||
startRuntimeTicker();
|
||||
maybeShowPersonaWizard();
|
||||
})();
|
||||
setInterval(renderAll, 15000);
|
||||
|
||||
@ -437,6 +437,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/terminals", h.requireAPI(h.handleTerminals))
|
||||
mux.HandleFunc("/api/v1/cmd/history", h.requireAPI(h.handleCmdHistory))
|
||||
mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel))
|
||||
// 运行态小快照:调度器(排队/四级中断队列/中断栈)+ 驻留子 + 通道拓扑。
|
||||
// 单独一条是为了让前端能秒级刷新而不必反复拉 30KB 的 /kernel。
|
||||
mux.HandleFunc("/api/v1/runtime", h.requireAPI(h.handleRuntime))
|
||||
mux.HandleFunc("/api/v1/persona", h.requireAPI(h.handlePersona))
|
||||
mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins))
|
||||
mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID))
|
||||
|
||||
@ -238,3 +238,32 @@ func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agen
|
||||
}
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": fmt.Sprintf("agent %s action not implemented by supervisor", action)})
|
||||
}
|
||||
|
||||
// handleRuntime 返回**只含运行态**的小快照:调度器(排队/四级中断队列/中断栈)、
|
||||
// 驻留子 agent、通道拓扑。
|
||||
//
|
||||
// 为什么不复用 /api/v1/kernel:那份是 30KB 级的全量状态(工具、插件、记忆、LLM…),
|
||||
// 拿它做秒级刷新既费带宽也费端上算力。运行态要能「实时看」,所以单开一条
|
||||
// 只读通道,字段直接来自内核暴露的 KernelStatus(internal/sdk),
|
||||
// 这样任何应用(WebUI / GUI / ArkTS)都能拿同一份数据做展示。
|
||||
func (h *Handler) handleRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.status == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "kernel status not available"})
|
||||
return
|
||||
}
|
||||
ks := h.status.GetKernelStatus()
|
||||
if ks == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "kernel status not available"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"uptime": ks.Uptime,
|
||||
"scheduler": ks.Scheduler,
|
||||
"residents": ks.Residents,
|
||||
"channels": ks.Channels,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1387,3 +1387,75 @@ func TestChatHistoryDefaultIsPaged(t *testing.T) {
|
||||
t.Fatalf("limit=5 应回 5 条,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeStatus 是给 /runtime 用的最小内核状态桩。
|
||||
type fakeStatus struct{ ks *sdk.KernelStatus }
|
||||
|
||||
func (f fakeStatus) GetKernelStatus() *sdk.KernelStatus { return f.ks }
|
||||
|
||||
// TestRuntimeEndpoint 钉住运行态小接口的形状:
|
||||
// 只回运行态三件事(调度器/驻留子/通道),且体积远小于 /kernel ——
|
||||
// 前端靠它做秒级刷新,字段一丢图就画不出来。
|
||||
func TestRuntimeEndpoint(t *testing.T) {
|
||||
ks := &sdk.KernelStatus{
|
||||
Uptime: "1m",
|
||||
Scheduler: sdk.SchedulerStatus{
|
||||
ReadyQueueDepth: 2,
|
||||
PendingInterrupts: 1,
|
||||
SuspendStack: 1,
|
||||
MaxSuspendDepth: 4,
|
||||
InterruptQueues: [5]int{0, 0, 0, 0, 1},
|
||||
SuspendFrames: []sdk.SchedulerFrame{{Task: sdk.SchedulerTask{ID: 9, Level: 3, Kind: "input"}}},
|
||||
},
|
||||
Residents: []sdk.ResidentStatus{{ID: "r1", State: "running", Rounds: 3}},
|
||||
Channels: []sdk.ChannelInfo{{Name: "webui", Type: "1", Direction: "out", Ready: true, OutputCaps: 7, CapsText: "[text file image]"}},
|
||||
}
|
||||
s := testSDK(sdk.SDKConfig{
|
||||
Settings: sdk.NewSettings("webui", internalConfig.NewConfigRegistry("")),
|
||||
Config: sdk.NewConfig(&types.Config{}),
|
||||
Status: fakeStatus{ks: ks},
|
||||
})
|
||||
h := NewHandler(s)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/runtime", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleRuntime(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var out struct {
|
||||
Scheduler sdk.SchedulerStatus `json:"scheduler"`
|
||||
Residents []sdk.ResidentStatus `json:"residents"`
|
||||
Channels []sdk.ChannelInfo `json:"channels"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Scheduler.ReadyQueueDepth != 2 || out.Scheduler.PendingInterrupts != 1 {
|
||||
t.Fatalf("调度器字段丢失:%+v", out.Scheduler)
|
||||
}
|
||||
if out.Scheduler.InterruptQueues[4] != 1 {
|
||||
t.Fatalf("四级队列深度丢失:%v", out.Scheduler.InterruptQueues)
|
||||
}
|
||||
if len(out.Scheduler.SuspendFrames) != 1 || out.Scheduler.SuspendFrames[0].Task.ID != 9 {
|
||||
t.Fatalf("中断栈帧丢失:%+v", out.Scheduler.SuspendFrames)
|
||||
}
|
||||
if len(out.Residents) != 1 || out.Residents[0].ID != "r1" {
|
||||
t.Fatalf("驻留子丢失:%+v", out.Residents)
|
||||
}
|
||||
if len(out.Channels) != 1 || out.Channels[0].Direction != "out" {
|
||||
t.Fatalf("通道方向丢失:%+v", out.Channels)
|
||||
}
|
||||
// 只回运行态:不该把 tools/plugins 这类大块带上
|
||||
if strings.Contains(w.Body.String(), "\"tools\"") || strings.Contains(w.Body.String(), "\"plugins\"") {
|
||||
t.Fatal("/runtime 不应携带 tools/plugins(那是 /kernel 的内容)")
|
||||
}
|
||||
// 没有内核状态时给 503,而不是空对象
|
||||
s2 := testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", internalConfig.NewConfigRegistry(""))})
|
||||
h2 := NewHandler(s2)
|
||||
w2 := httptest.NewRecorder()
|
||||
h2.handleRuntime(w2, httptest.NewRequest(http.MethodGet, "/api/v1/runtime", nil))
|
||||
if w2.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("无状态源应回 503,实际 %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user