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)") + "
" + + '
' + + out.join("") + + "
" + ); + } + 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,可秒级轮询)。