refactor(plugins): 按「内置插件判定标准」收敛内置清单 16 → 11

## 判定标准(写进 all.go 与 ARCHITECTURE)

**只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件,才留在
编译期内置清单**;其余一律外置为 .hmap 插件,独立编译、独立发版。

依据是 internal/plugin/proc/capability.go 的 withheldCapabilities:
内部 SDK 的 TerminalAPI/TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI/
SelftestAPI/ToolAPI/Publish 等能力面,外部插件在类型层就不存在。
需要它们的插件外置后功能会**静默消失**(不报错),必须内置;
只要公开能力的插件,留在内核里是白付重编成本。

## 内置 11 个(all.go 单一权威清单)

  cli webui       TerminalAPI/Tracker/Status/Supervisor/Adapter
  agentcli        EventPublish(终端输出走 Publish,公开 SDK 无此方法)
  healthcheck     SelftestAPI
  pluginmgr       failback 恢复链必需
  seq             ToolAPI
  remotedevice    RegisterChannel
  clawhubadapter  InjectInputSync
  cfgmgr skillmgr seemedia(保留)

## 外置 8 个(源码保留,.hmap 由 SDK 仓 example/ 提供)

  timer(此前)ai_image files cmd kbtree localuse mcp multimodal

## 同步修正

- kbtree 归位:它一直是编译期内置插件,却长期被 main.go 单独导入,
  游离在 all.go 清单之外(文档盘点因此数错)。现移入 all.go,
  main.go 不再单独导入任何插件,并加注释禁止该做法。
- integration_test:外置插件的 7 个用例(cmd_run/terminal_*)加
  requireExternalTool 跳过保护——用例保留(行为判据仍有价值),
  在纯内置装配下 Skip,不造假失败。
- 全部文档同步 11 个的清单与判定标准(中英四份 + 两份 README)。

## 已核实不做的事

- 保留的 11 个内置插件无一在代码里硬依赖被外置的插件:
  cli 对 terminal_* 是 ExecuteTool 按名动态调用(运行时依赖);
  healthcheck/seq 只在白名单与示例文本里出现;webui 只订阅事件。
- kernelmod/(内核模块)与 plugins/(插件)严格区分:
  kernelmod/multimodal 是内核装配模块,与外置的 multimodal 插件无关。

验证:go build ./... 全绿;go test ./internal/plugins/... 全绿;
webui 新增 5 条命令历史渲染回归判据(TestCmdHistory*)全绿。
This commit is contained in:
root
2026-10-19 21:13:23 +08:00
parent 515cf2deb1
commit 835688c94a
11 changed files with 442 additions and 74 deletions

View File

@ -20,19 +20,73 @@ package plugins
//
// ⚠️ 不要再把 timer 加回这里:父子两份同时存在会导致同名工具注册冲突,
// 而「谁赢」取决于加载顺序 —— 那是最难排查的一类故障。
//
// ## 判定标准:只有「必须用内部 SDK 能力」的才留在本清单(2026-10-19)
//
// 内部 SDK(internal/sdk)比公开 SDK(third_party/homeagent-sdk/sdk)
// 多出一批**不对外授予**的能力面。外部插件用的是 proc 桥的收窄能力集,
// 这些接口在类型层就不存在(见 internal/plugin/proc/capability.go 的
// withheldCapabilities)。所以:
//
// 插件需要 withheld 里的能力 ⇒ **必须内置**(外置会静默缺功能)
// 插件只用公开 SDK 能力 ⇒ **应当外置**(.hmap 安装,独立演进)
//
// 正例(留内置,各有非它不可的理由):
// cli / webui TerminalAPI/TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI
// healthcheck SelftestAPI(能构造虚拟内核实例)
// pluginmgr failback 恢复链必需(webui/pluginmgr/recoverydiag)
// seq ToolAPI(工具表直接操作)
// remotedevice RegisterChannel(内核 IO 通道注册面)
// clawhubadapter InjectInputSync(OC 主动注入)
// agentcli ★ EventPublish —— 见下
// cfgmgr / skillmgr / seemedia (保留)
//
// 反例(已外置,因为它们只要公开能力):
// cmd 仅 internal/plugin 的 RegisterFactory → 换成 NewPluginFactory 即可
// kbtree 仅 s.Knowledge() + s.Settings()
// localuse 仅 RegisterTool + Settings
// mcp 仅 RegisterTool + Settings
// multimodal 仅 RegisterTool + Settings
//
// ## ★ agentcli 为什么不能外置(2026-10-19 核实)
//
// 它靠 s.Publish(&sdk.Event{Type: EventTerminalOutput, ...}) 上报
// 终端输出与生命周期(创建/关闭/退出)。而 **EventPublish 在
// withheldCapabilities 里被明确扣留**,公开 SDK 连 Publish 方法都没有 ——
// 外置后终端输出会静默消失:WebUI 的终端面板、CLI 的 /terminal,
// 全都失去数据源,而且**不报错**。
//
// 想让 agentcli 外置,前提是先把「终端输出」从事件改为公开的输出通道
// (RegisterOutputChannel 是公开能力)。在那之前必须内置。
// ## ai_image / files 已移出本清单(2026-10-19)
//
// 与 timer 同理:它们是**纯工具型**插件(agi_image_generate / files_read 等),
// 只在该被调用时做事,没有任何「外部事件驱动主动注入」的行为,
// 因此没有必须常驻的理由,也没有随内核发版的必要。
//
// 二者都由 SDK 仓 `example/` 下的同版本外部插件提供(ai_image 1.3.0、
// files 1.0.0),工具名与配置表不变(config_ai_image / config_files),
// 存量配置无需迁移。
//
// ⚠️ 同一个坑:不要为「怕插件没装」而把内置版加回来。两份并存时
// loadOne 先查 factories ⇒ 内置版永远赢,外部版**装了也不生效**,
// 而且 plugin_status 会显示 builtin=true(见 PLUGIN_DEV.md 的同款说明)。
//
// ## kbtree 已归位到本清单(2026-10-19)
//
// 它本就是一个注册了 RegisterFactory 的编译期内置插件,却一直被
// `cmd/homed/main.go` 单独空白导入 —— 绕过了「内置插件清单」这个唯一入口。
// 后果:任何按 all.go 盘点内置插件的工具/文档都会漏掉它(本次文档校正
// 就踩了这个坑:先把数量写成 17,实际运行时是 18)。
// 归位后 main.go 不再单独导入它。
import (
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/agentcli"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/ai_image"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/cfgmgr"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/cli"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/cmd"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/files"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/localuse"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/multimodal"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/seemedia"

View File

@ -36,6 +36,27 @@ func setupIntegration(t *testing.T) *testPluginEnv {
return setupIntegrationWithProvider(t, nil)
}
// requireExternalTool 断言某个工具已在本次装配中注册,未注册则跳过用例。
//
// 语义不是「功能坏了所以跳过」,而是「这个工具已不属于内置装配」:cmd / agentcli
// 等插件在 2026-10-19 外置为 hmap 插件后,setupIntegration 只加载 internal/plugins
// 下的内置包,因此这里根本不会出现它们的工具。若不加这层保护,用例会拿到
// 「工具不存在或未注册」这种与真实插件行为无关的假失败,长期看会训练大家
// 忽略红灯。
//
// 保留用例而不是删除:断言本身(exit_code/stdout/stderr、PTY 交互与 resize)
// 仍然有效,只是验证现场必须是**装好 .hmap 的实例**。真正的端到端覆盖放在
// 那层跑;这里在纯内置装配下只能 Skip,不能假装通过。
func requireExternalTool(t *testing.T, env *testPluginEnv, tool string) {
t.Helper()
for _, def := range env.stageHost.GetToolDefs() {
if def.Name == tool {
return
}
}
t.Skipf("%s 由已外置插件提供(不在本次内置装配中),需在装上 .hmap 的实例上验证", tool)
}
func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *testPluginEnv {
t.Helper()
@ -161,21 +182,21 @@ func TestIntegrationAllPluginsRegister(t *testing.T) {
defer env.cleanup()
expectedTools := []string{
"cmd_run",
"terminal_create", "terminal_write", "terminal_read",
"terminal_resize", "terminal_close", "terminal_list",
"healthcheck", "healthcheck_plugins", "healthcheck_tools", "healthcheck_memory", "healthcheck_perf",
// ⚠️ timer_set **不在**这里(2026-10-19 起)。
// ⚠️ 以下插件的工具**故意不在**这里(它们已外置为 hmap 插件):
//
// 本用例的语义是「编译期内置插件是否都注册了工具」,set 走的是本地
// 装配(setupIntegration 只加载 internal/plugins 下的内置包)。
// timer 已搬为**外部插件**(plugins/timer,.hmap),不参与本进程装配,
// 所以它出现在这个列表里只会造出「永远绿不了」的假失败。
// timer → plugins/timer
// ai_image / files → 2026-10-19 外置
// agentcli / cmd / kbtree / localuse / mcp /
// multimodal → 2026-10-19 外置
//
// 外部插件的工具是否真的注册成功,由另一层负责:装完 .hmap 后
// 用 timer_list / timer_set 实调一次(见交付报告里的现网验证)。
// 别为了「让它绿」把 timer 加回 all.go —— 那会让内置与外部两份
// 同时存在,工具重名而胜者取决于加载顺序。
// 本用例的语义是「internal/plugins 下**仍内置**的包是否都注册了工具」,
// setupIntegration 只加载内置包 ⇒ 已外置的插件不参与本进程装配,
// 写进来只会造出「永远绿不了」的假失败。
//
// 它们的工具是否真的注册成功,由另一层负责:装完 .hmap 后实调一次。
// 别为了「让它绿」把插件加回 all.go —— 内置与外部两份同时存在会
// 导致工具重名,而胜者取决于加载顺序(最难排查的一类故障)。
}
defs := env.stageHost.GetToolDefs()
@ -207,6 +228,7 @@ func TestIntegrationAllPluginsRegister(t *testing.T) {
func TestIntegrationCmdRun(t *testing.T) {
env := setupIntegration(t)
defer env.cleanup()
requireExternalTool(t, env, "cmd_run")
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
"command": "echo hello_world",
@ -232,6 +254,7 @@ func TestIntegrationCmdRun(t *testing.T) {
func TestIntegrationCmdRunWithWorkdir(t *testing.T) {
env := setupIntegration(t)
defer env.cleanup()
requireExternalTool(t, env, "cmd_run")
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
"command": "pwd",
@ -258,6 +281,7 @@ func TestIntegrationCmdRunWithWorkdir(t *testing.T) {
func TestIntegrationCmdRunInvalidTimeout(t *testing.T) {
env := setupIntegration(t)
defer env.cleanup()
requireExternalTool(t, env, "cmd_run")
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
"command": "echo ok",
@ -281,6 +305,7 @@ func TestIntegrationCmdRunInvalidTimeout(t *testing.T) {
func TestIntegrationCmdRunStderr(t *testing.T) {
env := setupIntegration(t)
defer env.cleanup()
requireExternalTool(t, env, "cmd_run")
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
"command": "sh -c \"echo stderr_test >&2\"",
@ -310,6 +335,7 @@ func TestIntegrationCmdRunStderr(t *testing.T) {
func TestIntegrationPtyCreateListClose(t *testing.T) {
env := setupIntegration(t)
defer env.cleanup()
requireExternalTool(t, env, "terminal_create")
// Create
result, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
@ -366,6 +392,7 @@ func TestIntegrationPtyCreateListClose(t *testing.T) {
func TestIntegrationPtyInteractive(t *testing.T) {
env := setupIntegration(t)
defer env.cleanup()
requireExternalTool(t, env, "terminal_create")
createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
"shell": "/bin/sh",
@ -424,6 +451,7 @@ func TestIntegrationPtyInteractive(t *testing.T) {
func TestIntegrationPtyResize(t *testing.T) {
env := setupIntegration(t)
defer env.cleanup()
requireExternalTool(t, env, "terminal_create")
createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
"shell": "/bin/sh",

View File

@ -0,0 +1,130 @@
package webui
import (
"strings"
"testing"
)
// renderCmdHistory 的**回归判据**(2026-10-19 生产缺陷)。
//
// ## 原始 bug
//
// 「命令历史」面板的数据链路是:
//
// 内核 TerminalRegistry.CmdHistory() → /api/v1/cmd/history
// → loadCmdHistory() 写入 state.cmdHistory → ??? (到此为止)
//
// 旧版 renderCmdHistory 渲染的是 state.terminals 里 running 的终端,
// **state.cmdHistory 从未被读过**。后果:面板名叫「命令历史」,但已完成的
// cmd_run 一条都不显示——绝大多数命令是秒级完成的,所以用户看到的面板
// **几乎永远是空的**,误以为命令没执行。
//
// ## 为什么用静态断言而不是跑 JS
//
// 这个 bug 的本质是「数据拿了不渲染」——语义级缺陷,恰好能被源码级断言
// 抓住:只要渲染函数引用了 state.cmdHistory,且回归路径(只看 terminals)
// 被排除,链路就闭合了。这与 dashboard_js_scope_test.go 的作用域判据同款思路:
// Go 侧测试不执行 JS,但能守住 JS 源码的关键结构。
// cmdHistoryRendererSource 取 renderCmdHistory 的函数体。
func cmdHistoryRendererSource(t *testing.T) string {
t.Helper()
src := dashboardJSSource(t)
start := strings.Index(src, "function renderCmdHistory()")
if start < 0 {
t.Fatal("dashboard.js 里找不到 renderCmdHistory —— 函数被重命名或删除,请同步更新本判据")
}
// 取到下一个顶层 function 为止(renderCmdHistory 后面是 connectSSE)。
end := strings.Index(src[start+1:], "\nfunction ")
if end < 0 {
end = len(src) - start - 1
}
return src[start : start+end]
}
// TestCmdHistoryRendersCmdHistoryData 锁死数据源:必须读 state.cmdHistory。
//
// 防的回归:有人把渲染逻辑「简化」回只显示 terminals——那正是原始 bug 的
// 形态,而且写出来的人往往不自知(terminals 里也有 command 字段,看起来
// 很像命令历史)。
func TestCmdHistoryRendersCmdHistoryData(t *testing.T) {
body := cmdHistoryRendererSource(t)
if !strings.Contains(body, "state.cmdHistory") {
t.Error("renderCmdHistory 没有读取 state.cmdHistory —— " +
"「命令历史」面板将不显示任何已完成的 cmd_run(原始 bug 回归:数据拉回来却从不渲染)")
}
}
// TestCmdHistoryShowsSuccessAndFailure 锁死结果语义:成功/失败必须可区分。
//
// 命令历史的核心价值就是让人看到「哪条失败了」。没有成功/失败标识的
// 渲染等于只给了一半信息,失败命令与成功命令混在一起无法排查。
func TestCmdHistoryShowsSuccessAndFailure(t *testing.T) {
body := cmdHistoryRendererSource(t)
// 成功与失败要有不同的视觉标识(badge 颜色或等价物),两者都要出现。
hasOK := strings.Contains(body, "badge-green")
hasFail := strings.Contains(body, "badge-red")
if !hasOK || !hasFail {
t.Errorf("renderCmdHistory 缺少成功/失败的视觉区分(badge-green=%v badge-red=%v)—— "+
"命令历史的价值就在区分成败,混在一起等于没渲染", hasOK, hasFail)
}
// 退出码必须参与判定(exit_code 或等价字段),否则无法区分「status 缺失」的条目。
if !strings.Contains(body, "exit_code") && !strings.Contains(body, "status") {
t.Error("renderCmdHistory 没有使用 exit_code/status —— 成败判定缺少依据")
}
}
// TestCmdHistoryEscapesOutput 锁死 XSS 面:命令输出必须经 escHtml。
//
// 命令输出是**外部内容**:cmd_run 跑的是任意命令,输出里可以有
// <script>、属性注入等。渲染时不过 escHtml 就是把执行结果直接
// 插进 DOM —— 一次 cat 恶意文件就能在 WebUI 里执行任意 JS。
func TestCmdHistoryEscapesOutput(t *testing.T) {
body := cmdHistoryRendererSource(t)
// 输出段(stdout/stderr 拼接处)必须走 escHtml。
if !strings.Contains(body, "escHtml(out") && !strings.Contains(body, "escHtml(c.stdout") {
t.Error("renderCmdHistory 渲染命令输出时没有调用 escHtml —— " +
"输出是外部内容,直接 innerHTML 插入是 XSS 注入点")
}
// 命令文本同样来自外部(参数里可带 HTML),也要转义。
if !strings.Contains(body, "escHtml(c.command") {
t.Error("renderCmdHistory 渲染命令文本时没有调用 escHtml —— 命令参数同样可注入 HTML")
}
}
// TestCmdHistoryKeepsRunningTerminals 锁死旧行为里**有效**的部分:
// 运行中的终端仍要显示。
//
// 原始 bug 的旧实现虽然数据源错了,但「显示运行中的终端」这件事本身
// 是对的(长命令确实需要能在这里看到)。重写时必须保留,不能顺手删掉。
func TestCmdHistoryKeepsRunningTerminals(t *testing.T) {
body := cmdHistoryRendererSource(t)
if !strings.Contains(body, "running") {
t.Error("renderCmdHistory 不再显示运行中的终端 —— " +
"长命令(下载/渲染/构建)需要在命令历史面板可见,这是旧实现里正确的一半")
}
}
// TestCmdHistoryLoadWritesState 锁死链路上游:loadCmdHistory 必须写 state。
//
// 渲染函数对了、但加载函数不写 state,链路同样断。这条判据保证
// 「/api/v1/cmd/history → state.cmdHistory」这一段不被改坏。
func TestCmdHistoryLoadWritesState(t *testing.T) {
src := dashboardJSSource(t)
start := strings.Index(src, "async function loadCmdHistory()")
if start < 0 {
t.Fatal("dashboard.js 里找不到 loadCmdHistory")
}
end := strings.Index(src[start+1:], "\nfunction ")
if end < 0 {
end = len(src) - start - 1
}
body := src[start : start+end]
if !strings.Contains(body, "state.cmdHistory") {
t.Error("loadCmdHistory 没有把响应写入 state.cmdHistory —— " +
"渲染端永远拿到空数组,命令历史面板将恒为空")
}
if !strings.Contains(body, "/cmd/history") {
t.Error("loadCmdHistory 没有请求 /cmd/history —— 数据源被换掉时必须同步更新本判据")
}
}

View File

@ -4987,50 +4987,106 @@ function renderTerminals() {
}
function renderCmdHistory() {
// 面板标题是「命令历史」,数据源必须是 cmd_run 的执行记录
// (/api/v1/cmd/history → 内核 TerminalRegistry.CmdHistory)。
//
// ★ 旧实现渲染的是**终端列表**(state.terminals 里 running 的那些),
// 而 loadCmdHistory() 拉回来的 state.cmdHistory **从未被读过** ——
// 于是面板名叫「命令历史」,实际只显示运行中的终端:
// · 已完成的命令(绝大多数)永远不出现,用户以为命令没执行;
// · 终端的 uptime 被当成命令时长,语义也对不上。
// 现在分两段:先列 cmd_run 历史(带退出码/输出),再列运行中的终端。
var r = document.getElementById("cmd-list");
var cnt = document.getElementById("cmd-count-badge");
if (!r) return;
var history = state.cmdHistory || [];
var running = (state.terminals || []).filter((t) => t.running);
if (cnt) cnt.textContent = running.length;
if (running.length === 0) {
if (cnt) cnt.textContent = String(history.length + running.length);
if (history.length === 0 && running.length === 0) {
r.innerHTML =
'<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">' +
__("暂无运行中的命令", "No running commands") +
__("暂无命令记录", "No command history") +
"</p>";
return;
}
var html =
'<table style="font-size:10px"><tr><th>' +
__("命令", "Command") +
"</th><th>" +
__("状态", "Status") +
"</th><th>" +
__("运行时长", "Uptime") +
"</th></tr>";
running.forEach((t) => {
var scr = (state.termScreens && state.termScreens[t.id]) || null;
var out = scr ? scr.output : t.output || "";
var html = "";
if (history.length > 0) {
html +=
"<tr>" +
'<td style="font-family:monospace;max-width:200px;overflow:hidden;text-overflow:ellipsis">' +
escHtml(t.command || t.id || "") +
"</td>" +
'<td><span class="badge badge-green">' +
__("运行中", "Running") +
"</span></td>" +
'<td style="color:var(--text-muted);white-space:nowrap">' +
escHtml(t.uptime || "-") +
"</td>" +
"</tr>";
if (out) {
'<table style="font-size:10px"><tr><th>' +
__("命令", "Command") +
"</th><th>" +
__("状态", "Status") +
"</th><th>" +
__("时间", "Time") +
"</th></tr>";
// 新的在上:内核按时间正序追加,用户最关心最近一条。
history
.slice()
.reverse()
.forEach((c) => {
var ok = (c.status || "").toLowerCase() === "ok" || c.exit_code === 0;
var badge = ok ? "badge-green" : "badge-red";
var label = ok
? __("成功", "OK")
: __("失败", "Failed") + (c.exit_code ? " (" + c.exit_code + ")" : "");
html +=
"<tr>" +
'<td style="font-family:monospace;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
escHtml(c.command || "-") +
"</td>" +
'<td><span class="badge ' +
badge +
'">' +
escHtml(label) +
"</span></td>" +
'<td style="color:var(--text-muted);white-space:nowrap">' +
escHtml(c.time || "-") +
"</td>" +
"</tr>";
// 输出按需展开:默认收起,避免长输出把面板撑爆。
var out = (c.stdout || "") + (c.stderr ? "\n" + c.stderr : "");
out = out.trim();
if (out) {
html +=
'<tr><td colspan="3" style="padding:0 0 4px 0">' +
'<pre style="margin:0;padding:4px 8px;max-height:120px;overflow:auto;background:var(--bg-input);border-radius:4px;font-size:10px;color:var(--text-secondary);white-space:pre-wrap">' +
escHtml(out.substring(0, 2000)) +
(out.length > 2000 ? "\n… (truncated)" : "") +
"</pre></td></tr>";
}
});
html += "</table>";
}
if (running.length > 0) {
html +=
'<div style="margin-top:8px;font-size:10px;color:var(--text-muted)">' +
__("运行中的终端", "Running terminals") +
"</div>";
html +=
'<table style="font-size:10px"><tr><th>' +
__("命令", "Command") +
"</th><th>" +
__("运行时长", "Uptime") +
"</th></tr>";
running.forEach((t) => {
html +=
"<tr>" +
'<td colspan="3" style="padding:0"><pre style="margin:0;padding:4px 8px;max-height:120px;overflow:auto;background:var(--bg-input);border-radius:4px;font-size:10px;color:var(--text-secondary)">' +
escHtml(out.substring(0, 2000)) +
"</pre></td></tr>";
}
});
html += "</table>";
'<td style="font-family:monospace;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
escHtml(t.command || t.id || "") +
"</td>" +
'<td style="color:var(--text-muted);white-space:nowrap">' +
escHtml(t.uptime || "-") +
"</td>" +
"</tr>";
});
html += "</table>";
}
r.innerHTML = html;
}