diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css
index 0caa046..42bcad3 100644
--- a/internal/plugins/webui/dashboard.css
+++ b/internal/plugins/webui/dashboard.css
@@ -2191,6 +2191,20 @@
.rt-lv-3 > i { background: #ffa657; }
.rt-lv-2 > i { background: var(--sakura-400); }
.rt-lv-1 > i { background: var(--frost-300); }
+ /* 拓扑图:整张图为 SVG,文字降到最少,其余信息用图形编码
+ (归属=容器/配色、容量=细条、输出能力=圆点、路由=连线)。 */
+ .rt-svg-wrap {
+ overflow-x: auto;
+ padding: 4px 0 8px;
+ }
+ .rt-svg {
+ min-width: 560px;
+ font-family: inherit;
+ display: block;
+ }
+ .rt-svg text {
+ fill: var(--text-primary);
+ }
/* 第五条:排队队列(无级别)。用虚线分隔 + 中性色,与“有级别”的四条区分开——
它不是优先级,而是另一**类别**(排队 vs 中断),同一套配色会误导。 */
.rt-level.rt-level-queued {
diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js
index 2cdef7e..96e8adc 100644
--- a/internal/plugins/webui/dashboard.js
+++ b/internal/plugins/webui/dashboard.js
@@ -642,6 +642,166 @@
return out ? '' + out + "" : "";
}
+ // 归属配色:根用青色,驻留子轮转其余颜色(同一张图里能一眼分清谁是谁)。
+ var RT_OWNER_COLORS = ["#88c0d0", "#b48ead", "#a3be8c", "#ebcb8b", "#d08770", "#8fbcbb"];
+
+ // rtOwnerGroups 把 inputch 按**归属**分组(owner == 根 agent id 与 "" 归一为根)。
+ function rtOwnerGroups(inputs, residents, rootID) {
+ var order = [];
+ var map = {};
+ inputs.forEach(function (c) {
+ var o = c.owner || "";
+ if (o === rootID) o = "";
+ if (!map[o]) {
+ map[o] = [];
+ order.push(o);
+ }
+ map[o].push(c);
+ });
+ // 驻留子即使一条 inputch 都没划到也要出现——否则「子存在但看不见」
+ // 与「子不存在」无法区分。
+ residents.forEach(function (r) {
+ var o = r.id || "";
+ if (o && o !== rootID && !map[o]) {
+ map[o] = [];
+ order.push(o);
+ }
+ });
+ order.sort(function (a, b) {
+ if (a === "") return -1;
+ if (b === "") return 1;
+ return a < b ? -1 : 1;
+ });
+ var ci = 0;
+ return order.map(function (o) {
+ var res = null;
+ residents.forEach(function (r) {
+ if (r.id === o) res = r;
+ });
+ var list = map[o] || [];
+ return {
+ owner: o,
+ child: o !== "",
+ label: o === "" ? __("根 agent / 内核默认", "root agent / kernel default") : __("驻留子 ", "resident ") + o,
+ color: o === "" ? RT_OWNER_COLORS[0] : RT_OWNER_COLORS[1 + (ci++ % (RT_OWNER_COLORS.length - 1))],
+ list: list,
+ count: list.length,
+ res: res,
+ };
+ });
+ }
+
+ // rtTopologySvg 把「通道 + 归属 + 路由」画成**一张图**:
+ // 左侧按归属框出输入通道 → 汇集母线 → 内核 → 输出母线 → 右侧输出通道。
+ // 连线即路由;归属是容器与配色;容量是节点里的细条;输出能力是彩色圆点。
+ // 为什么合进拓扑而不是单开一项:通道属于谁是**拓扑的一部分**
+ // (左边这些输入口分别被谁接管),拆两张表反而看不出关系。
+ function rtTopologySvg(groups, channels) {
+ var ROW = 24, HEAD = 18, GPAD = 8, GAP = 10;
+ var W = 660;
+ var LX = 6, LW = 226;
+ var KX = 292, KW = 76;
+ var RX = 408, RW = 246;
+ var BUS_IN = 262, BUS_OUT = 396;
+ var top = 6;
+ var out = [];
+ function esc(s) {
+ return String(s == null ? "" : s).replace(/[<>&]/g, function (m) {
+ return m === "<" ? "<" : m === ">" ? ">" : "&";
+ });
+ }
+
+ // ---- 左:归属容器 + 通道行 ----
+ var y = top;
+ var inRows = [];
+ groups.forEach(function (g) {
+ var boxH = HEAD + g.list.length * ROW + GPAD;
+ out.push(
+ '"
+ );
+ out.push(
+ '' +
+ esc((g.child ? "▸ " : "◆ ") + g.label) +
+ (g.res ? " " + __("轮次", "rounds") + " " + (g.res.rounds || 0) : "") +
+ (g.res && g.res.context_full ? " " + __("上下文已满", "ctx full") : "") +
+ ""
+ );
+ g.list.forEach(function (c, i) {
+ var ry = y + HEAD + i * ROW;
+ inRows.push({ y: ry + ROW / 2, color: g.color });
+ out.push('' + esc(c.name) + "");
+ var cap = c.capacity || 0;
+ var tx = LX + LW - 76;
+ out.push('');
+ if (cap > 0) {
+ var wpx = Math.max(3, Math.min(46, (46 * Math.min(cap, 64)) / 64));
+ out.push('');
+ }
+ out.push('' + esc(cap || __("默认", "def")) + "");
+ });
+ y += boxH + GAP;
+ });
+ var leftBottom = Math.max(y - GAP, top + 40);
+
+ // ---- 右:输出通道 ----
+ var outs = (channels || []).filter(function (c) {
+ return c.direction === "out" || c.direction === "io";
+ });
+ var outRows = [];
+ var oy = top;
+ var CAPS = [[1, "#88c0d0"], [2, "#a3be8c"], [4, "#ebcb8b"], [8, "#d08770"], [16, "#b48ead"]];
+ outs.forEach(function (c) {
+ outRows.push({ y: oy + ROW / 2 });
+ out.push('');
+ out.push('' + esc(c.name) + "");
+ var cx = RX + RW - 76;
+ CAPS.forEach(function (b) {
+ if ((c.output_caps || 0) & b[0]) {
+ out.push('');
+ }
+ cx += 14;
+ });
+ oy += ROW;
+ });
+ var rightBottom = Math.max(oy, top + 40);
+
+ var H = Math.max(leftBottom, rightBottom) + 10;
+ var ky = Math.round((Math.min(leftBottom, rightBottom) + Math.max(leftBottom, rightBottom)) / 2) - 14;
+
+ // ---- 输入母线 ----
+ if (inRows.length) {
+ var iy0 = inRows[0].y;
+ var iy1 = inRows[inRows.length - 1].y;
+ out.push('');
+ inRows.forEach(function (r) {
+ out.push('');
+ });
+ out.push('');
+ }
+ // ---- 内核 ----
+ out.push('');
+ out.push('' + __("内核", "Kernel") + "");
+ // ---- 输出母线 ----
+ if (outRows.length) {
+ var oy0 = outRows[0].y;
+ var oy1 = outRows[outRows.length - 1].y;
+ out.push('');
+ outRows.forEach(function (r) {
+ out.push('');
+ });
+ out.push('');
+ }
+
+ return (
+ '
' + __("通道拓扑(连线即路由;左框 = 归属)", "Channel topology (edges = routing; boxes = owner)") + "
" +
+ '"
+ );
+ }
+
function renderRuntime() {
var el = document.getElementById("rt-panel");
if (!el) return;
@@ -672,7 +832,6 @@
'' +
'' +
'' +
- '' +
'' +
"";
}
@@ -694,11 +853,11 @@
// ---- 段 1:四个数字块 ----
var h = '';
- h += rtTile(ready, __("排队任务", "Ready queue"), __("等待执行的输入", "inputs waiting"), ready ? Math.min(100, ready * 20) : 0, ready > 0);
- h += rtTile(pending, __("待处理中断", "Pending interrupts"), __("四级队列 + 立即抢占", "queued + immediate"), pending ? Math.min(100, pending * 25) : 0, pending > 0, pending > 0);
- h += rtTile(stack + "/" + maxStack, __("中断栈", "Interrupt stack"), __("嵌套抢占的现场", "nested frames"), (stack / (maxStack || 4)) * 100, stack > 0);
+ h += rtTile(ready, __("排队任务", "Ready queue"), "", ready ? Math.min(100, ready * 20) : 0, ready > 0);
+ h += rtTile(pending, __("待处理中断", "Pending interrupts"), "", pending ? Math.min(100, pending * 25) : 0, pending > 0, pending > 0);
+ h += rtTile(stack + "/" + maxStack, __("中断栈", "Interrupt stack"), "", (stack / (maxStack || 4)) * 100, stack > 0);
var rFull = residents.filter(function (r) { return r.context_full; }).length;
- h += 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);
+ h += rtTile(residents.length, __("驻留子 Agent", "Resident agents"), rFull ? rFull + __(" 满", " full") : "", residents.length ? Math.min(100, residents.length * 20) : 0, residents.length > 0, rFull > 0);
h += "
";
// 抢占/背压计数(设计 §11.6 E4 的按级别口径)
h += '' + __("累计", "totals") + ": " +
@@ -744,8 +903,7 @@
__("排队(无级别)", "queued (no level)") + "" +
'' +
- '' + ready + " · " +
- __("FIFO,可被任何中断打断", "FIFO, preempted by any interrupt") + "
";
+ '' + ready + " · FIFO";
h += "";
if (sc.immediate) {
h += '⚡ ' + __("立即运行", "immediate") + ":" +
@@ -774,111 +932,15 @@
}
put("stack", h);
- // ---- 段 4:通道分配(按归属)----
+ // ---- 段 4:拓扑(通道 + 归属 + 路由 画在同一张图上)----
//
- // inputch 是输入路由单位,登记表由根 agent 与驻留子**共用同一份**——
- // 「这条输入归谁」必须画出来。只画设备能力(段 5)等于把「路由发生在
- // 进内核之前」这条设计事实藏起来,驻留子的通道分配就完全不可见。
- h = '
' + __("通道分配(按归属)", "Input channels by owner") + "
";
- var maxCap = 1;
- inputs.forEach(function (c) {
- if ((c.capacity || 0) > maxCap) maxCap = c.capacity;
- });
- var owners = [];
- var byOwner = {};
- // 根 agent 的 id:登记表里根自己的 owner 就是它(如 "main")。
- // 不把它跟驻留子分开,根自己的通道会被标成「驻留子 main」——实测踩过。
+ // 归属不再单开一项:通道属于谁是**拓扑的一部分**(左边这些输入口
+ // 分别被谁接管),拆成两张表反而看不出关系。整张图用 SVG 画,
+ // 文字只保留通道名与分组名,其余信息全部用图形编码:
+ // 归属=容器/配色、容量=节点内细条、输出能力=彩色圆点、路由=连线。
var rootID = rt.agent_id || "";
- var isChild = function (o) { return o !== "" && o !== rootID; };
- inputs.forEach(function (c) {
- // owner == 根 agent id 与 owner == "" 是同一件事(都是归根 agent / 内核默认),
- // 归一成一组,否则同一个根会画出两个容器。
- var o = c.owner || "";
- if (o === rootID) o = "";
- if (!byOwner[o]) {
- byOwner[o] = [];
- owners.push(o);
- }
- byOwner[o].push(c);
- });
- // 驻留子即使一条 inputch 都没划到也要出现在图里——否则「子存在但看不见」
- // 与「子不存在」无法区分。
- residents.forEach(function (r) {
- var o = r.id || "";
- if (o && o !== rootID && !byOwner[o]) {
- byOwner[o] = [];
- owners.push(o);
- }
- });
- owners.sort(function (a, b) {
- if (a === "" || a === rootID) return -1;
- if (b === "" || b === rootID) return 1;
- return a < b ? -1 : 1;
- });
- if (!owners.length) {
- h += '
' + __("暂无通道登记", "no channel registered") + "
";
- }
- owners.forEach(function (o) {
- var list = byOwner[o] || [];
- var res = null;
- if (o) {
- residents.forEach(function (r) {
- if (r.id === o) res = r;
- });
- }
- var child = isChild(o);
- h += '
';
- h += '
' +
- (child ? "▸ " : "◆ ") +
- (child
- ? __("驻留子 ", "resident ") + escHtml(o)
- : __("根 agent", "root agent") + (o ? " " + escHtml(o) : "") + __(" / 内核默认", " / kernel default")) +
- '' + list.length + " " + __("条通道", "channels") +
- (res ? " · " + __("轮次", "rounds") + " " + (res.rounds || 0) : "") +
- (res && res.context_full ? ' ' + __("上下文已满", "ctx full") + "" : "") +
- "
";
- if (list.length) {
- list.forEach(function (c) {
- var cap = c.capacity || 0;
- h += '
' +
- '' + escHtml(c.name) + "" +
- (c.plugin ? '' + escHtml(c.plugin) + "" : "") +
- rtSlider(cap, maxCap, __("容量", "cap"), cap ? String(cap) : __("默认", "default")) +
- '' +
- (c.output ? __("回程 ", "out ") + escHtml(c.output) : __("回程由来源决定", "out by source")) +
- "
";
- });
- } else {
- var allowed = (res && res.allowed_outputs) || [];
- h += '
' +
- (allowed.length
- ? __("可发往输出通道:", "allowed outputs: ") +
- allowed.map(function (x) { return '' + escHtml(x) + ""; }).join("")
- : __("未划入任何 inputch", "no input channel assigned")) +
- "
";
- }
- h += "
";
- });
- put("owners", h);
-
- // ---- 段 5:通道拓扑(设备能力面)----
- h = '
' + __("通道拓扑", "Channel topology") + "
";
- 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 '
' +
- '
' + escHtml(c.name) + "" +
- rtCapsHtml(c.output_caps || 0) +
- '
' +
- escHtml((c.tools || []).length ? (c.tools || []).length + " " + __("个工具", "tools") : (c.description || "").slice(0, 26)) +
- "
";
- }
- h += '
';
- h += '
' + (ins.length ? ins.map(chanHtml).join("") : '
' + __("无输入通道", "no input channel") + "
") + "
";
- h += '
' + __("内核", "Kernel") + "
";
- h += '
' + (outs.length ? outs.map(chanHtml).join("") : '
' + __("无输出通道", "no output channel") + "
") + "
";
- h += "
";
- put("topo", h);
+ var ownerGroups = rtOwnerGroups(inputs, residents, rootID);
+ put("topo", rtTopologySvg(ownerGroups, channels));
}
// loadRuntime 拉运行态小快照并就地重绘面板(约 2KB,可秒级轮询)。