DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

xinstart-1 /

xinstart-1/dsh-log-triage

Verified

Intranet container-log triage for DeepSeek Harness: a model-callable log_triage tool that pulls docker logs over SSH, clusters errors into fingerprints, extracts stack root causes, and reads only the evidence it needs.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@44661984

dsh-log-triage

内网容器日志巡检与根因定位插件(DSH 0.2.1 线)。只读:只跑 docker ps/inspect/logs/stats、dmesg、df、free 与 systemctl 状态查询,不重启、不改配置、不写服务器。

安装

需要 DSH >=0.2.1-rc.1 <0.3.0-0 与 Node >=20。本仓库不发布到 npm("private": true),所以用 Git 地址或本地路径装,不要用裸包名:

dsh plugin --profile web add github:xinstart-1/dsh-log-triage

装完必须重启 DSH 才生效(host 半侧每进程只加载一次),然后到 设置 → 容器日志巡检 填 SSH 主机。

内网 / 离线机器:本包零依赖、无 build 步骤,npm pack 出一个 223KB 的 tarball 拷进去即可 (dsh plugin --profile web add file:C:/path/to/dsh-log-triage-0.2.0.tgz,实测 --offline 零下载)。 注意 GitHub 的 Download ZIP 不能用 —— 安装器只认 git 地址、file: 路径和 .tgz,没有 zip 分支。

图形界面路径、离线装法、验证步骤、只读边界与卸载,见 docs/INSTALL.md。

它解决什么

内网 Linux 服务器上日志通常没有落文件,docker logs(容器 stdout/stderr)是唯一来源。而它有两个硬伤:受 log driver 轮转限制、容器重建后彻底丢失。

这个插件让 harness 自己去分析服务器上的报错并定位错误,而不是等你把日志粘进对话:

模型调用 log_triage 工具
  → SSH 到目标机执行只读探针(base64 传输,无需远端落文件)
  → 返回「帧化」的结构化快照(容器状态 / 退出码 / OOM / 磁盘 / 负载 / 日志)
  → 本地聚类成错误指纹(计数 + 首末时间)
  → 从 Java / Python / Node 栈里抽**最深的 Caused by**作根因
  → 脱敏(内网 IP、账号、token)
  → 给模型几百 token 的结论,而不是几万行日志

关键点:整份日志从不进上下文。一个 docker logs 动辄几万行,工具只回摘要、指纹表和有行号引用的证据。

界面与工具

入口 作用
模型工具 log_triage 让模型自己在对话里定位报错(本插件的主线)
模型工具 log_source_read 报错定位到「文件:行号」后,模型自己决定读多少源码
AI 分析(模型结论) 规则负责定位,模型负责解释「为什么 / 怎么改」
侧栏「日志巡检」按钮 立即分析 / 测连通 / 只看变化 / 历史报告
设置 → 容器日志巡检 配置编辑(主机、只检查哪些容器、无人值守告警、AI 模型)
定时巡检 默认关闭;intervalMinutes > 0 才启用,且不会重叠执行
告警通知 默认关闭;开启后异常记进面板告警列表(带未读徽标),同一告警冷却期内只提醒一次
报告归档 每轮自动存 reports/<时间>.md,保留最近 200 份,面板「历史报告」可回看

模型与按钮走同一条代码路径,所以界面看到的报告和模型读到的摘要是同一份,不会互相矛盾。

「只看变化」是干什么的

巡检每隔几分钟跑一次时,一台正常机器的指纹表每轮都一样。每次都重读那十条老指纹,人会很快学会无视这个面板——这是巡检功能最常见的死法。所以有「只看变化」:

立即分析  →  本轮的全部指纹(完整证据)
只看变化  →  过滤最新报告,只留「刚出现的」「次数突增的」「容器刚坏掉的」

它是过滤器,不是第二次巡检。 早期版本把它做成了第二个按钮:点一下就完整跑一轮 SSH 探针,只为看上一轮已经拿到的数据。这有两个代价:每次点击都消耗一次完整巡检(实测 5 容器一轮约 2.7s / 933KB 传输);更糟的是每次点击都会重置指纹基线,同一个老错误在下一轮又被当成「新指纹」重新报警。现在它只是把最新报告按 diff 过滤一遍——瞬时、零服务器开销,判定口径与告警用的是同一份 diff。

至于「持续监听」:插件已经有这个能力,就是定时巡检(intervalMinutes > 0),它就是程序在后台持续盯着容器,且默认关闭、不重叠执行。要不要常驻监听取决于你对服务器影响的容忍度,实测数据(5 容器 / openEuler 虚拟机):

项 实测
一轮完整巡检(本机视角) ~2.7s,传输 933KB(5 容器)/2.5s,133KB(2 容器白名单)
远端 docker logs --tail 4000(单容器) ~21ms
docker ps ~20ms
巡检期间容器 CPU <1.2%(被测容器自身负载)
每 5 分钟一轮的代价 每天 288 次 SSH 会话,服务器侧每次只读约 3MB 的 json log 文件头部

结论:轮询式监听对这台虚拟机没有可感知影响(CPU 峰值 <2%、每 5 分钟 2.7 秒的一次性读 IO)。真正要防的是三件事:高频轮询叠加(已防:上一轮没跑完就跳过本轮)、无上限的 docker logs(已防:tail 上限 + 行数预算)、以及把全量日志拉进上下文(已防:只回摘要)。如果你要的是秒级感知(真·docker events 流式监听),那是一个常驻连接 + 常驻进程的架构,与「服务器上不装任何东西」的设计原则冲突,目前刻意没做。

判定口径写死在 diffHost() 里,只有四种算“变化”:

变化 判定
新指纹 上一轮基线里没有这个 key(按容器隔离——同一个消息在两个服务里是两件事)
突增 本轮次数 >= max(3, 上轮 × 2),避免 1→2 就报
容器刚坏 上轮还正常,这轮 broken
错误持续不消失 连续 N 轮都在(默认 3),只在跨过阈值那一轮提醒一次
两轮之间重启过 重启计数上涨且现已恢复——这是轮询唯一的真实盲区,见下

轮询会不会错过 5 分钟间隙里的问题

日志类报错不会错过。 这是 --since 3h --tail 4000 的语义决定的:不是“只看这 5 分钟”,而是“回看 3 小时内的日志”。一条错误只要在 3 小时窗口内出现过,无论发生在哪个 5 分钟间隙,下一轮巡检都能看到,且 firstSeen/lastSeen 带着真实时间戳。按错误的时间性分三类:

错误类型 会不会错过
持续性错误(DNS 挂、连接拒绝、每次请求都报) 不会。间隙里看不到,下一轮一定还在(WeKnora-app 的 dial tcp 就是这种)
瞬时错误(启动失败、一次 panic 后恢复) 基本不会。日志留在文件里,下一轮 --since 仍能看到。只有 3 小时窗口也滚过去才会丢——把 since 配大即可
容器状态变化(挂了又自动恢复) 曾会漏。下一轮读到的是“healthy 的当前状态”,像什么都没发生过——已修,见下

真正的盲区是第三种:容器在两轮之间崩溃又被 Docker 自动拉起。下一轮巡检时它 Up 3 minutes、状态健康、指纹全是老面孔——所有规则都沉默,一次真实的死亡就 invisibly 消失了。

修复靠一个持久化基线:Docker 的 RestartCount 是累积计数,把它存进 state.json,下一轮对比。计数上涨 = “它至少死过一次”,哪怕现在已经恢复。触发 critical 级告警「在两轮巡检之间重启过」,详情给出 3 -> 4 的跳变,并提示“原因要看那一轮的日志”。「只看变化」也会保留这类容器(它的日志证据正是要看的)。

两条边界:仍然 broken 的容器走「刚坏」规则(一次死亡只报一次,不重复);告警 key 带上计数值,所以死两次会报两次,不会被冷却期吞掉第二次。

如果需要秒级感知(docker events 流式订阅),那是常驻连接 + 常驻进程的架构,与「服务器上不装任何东西」冲突,目前刻意不做。5 分钟轮询 + 3 小时窗口 + 重启基线,是不装任何东西的前提下最接近实时的方式。

两条容易踩的坑(都已修,有回归测试):

  1. 「没有变化」不等于「没有问题」。 之前 delta 视图的标题抄了全量巡检的措辞 “No container is in a failed state”,等于告诉运维“全集群健康”——可实际上一个早就挂了的容器依然是挂的,只是这一轮“没有变化”。现在分三种措辞:有异常 / 全健康 / 无变化。
  2. 容器刚坏时,它的指纹往往是老面孔。 一个服务用了几周的 Connection refused 死掉,不产生任何“新指纹”或“突增”;只按这两种过滤会把根因一并删掉,报告于是显示「400 行日志,没有错误模式」。现在“刚坏”本身就是一个变化,根因和指纹都保留,并标注「本轮刚进入异常状态」。

定时巡检与告警走同一套 diff,所以「只看变化」看到的和告警判定的是同一件事,不会出现“按钮说没变、告警却响了”。## AI 分析:规则定位,模型解释

聚类、栈解析、源码定位都是确定性的——它们能回答「哪里、什么」,回答不了「为什么、该改什么」。那部分交给模型。

指纹表 + 根因 + 源码地址   →   [设置里选的模型]   →   根因判断 + 依据引用 + 处置建议
决策 为什么
默认关 每轮巡检花一次模型调用,不该由插件替用户决定。设置页「AI 分析」里显式开启
prompt 只喂摘要,绝不喂日志 否则前面所有「日志不进上下文」的努力白做,且成本反超
禁止编造 system prompt 明确要求引用输入里真实出现的异常类名 / 指纹 / 文件:行号,信息不足就说「不确定」。自信的错误答案比没有答案更糟,因为运维会照做
失败降级为一行 供应商超时、key 过期、空回答,一律变成「分析未完成:…」。巡检本身不会因为模型不可用而失败
模型清单同源 设置页下拉框读 /models(ctx.llm.listProviders + listModels),与对话框右下角的模型选择器同一份清单,不会让你选到一个宿主解析不了的模型
「跟随当前对话」 provider/model 都留空 = 用发起这次分析时的对话模型。指定了模型但调用失败,自动回落重试一次(attempts:2)

配置形态(~/.dsh/log-triage/config.json):

"analysis": { "enabled": false, "provider": "", "model": "" }

只有 enabled === true(严格布尔)才算开启;provider/model 只有成对出现才生效——半截的 {provider, model:""} 会被丢掉,否则每轮都会去请求一条解析不了的路由。

源码:只给地址,不给正文

早期版本会把出错位置上下 ±6 行的源码原文塞进报告。这有两个问题:费 token,而且替模型决定了"读多少代码"——真正要看的往往是整个函数,或者相邻的那个调用者。

现在拆成两步:

log_triage        →  source: `get_model_path()` -- /app/.../retrieval/utils.py:425 (1200 lines total)
                     nearby definitions: def embed_texts() @ 395 · def get_model_path() @ 410
                     read it yourself: log_source_read(container: "open-webui", file: "...", start_line: 410, end_line: 454)
log_source_read   →  只返回你要的那一段,带绝对行号

source-locate-py.sh 只回传 @@@FILE@@@(行数)、@@@DEFS@@@(只有行号+名字,无签名无代码)、@@@ANCHOR@@@(出错那一行,单行)。窗口由 log_source_read 按 start_line/end_line(或 around_line + context_lines)取,max_lines 硬上限 400。报告里的窗口建议取外围 def 的跨度,因为那通常是最小可推理单元;模型想放宽就自己改参数。

反例 → 现状 效果
早先:每个容器内联 40 行源码 一轮巡检多花数千 token,且经常贴错区段
现在:地址 + 建议窗口 + 可复制的调用 只有真的要看代码时才付费,读多少由模型定

超长报错日志(链式 traceback)怎么处理

Python 的 raise X from Y 会让一条日志里出现 3~4 段 Traceback,用 The above exception was the direct cause of the following exception: 串起来。open-webui 这类栈里,长度动辄 60 行、最深根因埋在 40 行处。

两个地方都必须按"链"而不是按"块"处理:

层 之前的错法 现在
根因抽取 整块当一个 traceback,rootCause = 最外层包装(LocalEntryNotFoundError) 按 Traceback 切块、按桥接行建链,取最深一环(socket.gaierror);exceptionChain[].causeRelation 区分 cause 与 context
指纹聚类 Traceback (most recent call last): 自己成了指纹(×4),真根因 severity 1 排最后 结构行折叠为上下文不再占位;DNS/网络类根因(gaierror/EAI_AGAIN/ENOTFOUND/no such host…)severity 3 且排第一
own 帧判定 socket.gaierror 这类小写结尾的异常类被整行丢弃,这才导致根因认成包装层 异常头正则接受 [Ee]rror 等小写形式,仍只吃 0 列行

实测 test/fixtures/chained-python-openwebui.log(按截图逐字重建):根因=socket.gaierror: [Errno -3] Temporary failure in name resolution,链 L0→L3 完整,/app/... 自有帧不再混进根因链。

errno 是有意保留的:[Errno -3](DNS 挂了)和 [Errno 111](端口没人听)是两种故障、两种修复动作,归一化成 [Errno -<n>] 会把它们误合并;而 attempt 41 这类每次变的数字照旧归一化。

只检查关心的容器(重要)

一台机器上跑几十个容器时,「全部检查」既慢又吵。实测单容器日志读取约 0.37s,docker ps/inspect 很便宜,但早期版本每个容器还要跑一次 docker stats(约 2s/个)——7 个容器就是 14 秒,而这批数据在任何地方都没被使用。现在:

优化 效果
移除无消费方的 docker stats(改为按需 INCLUDE_STATS=1) 7 容器一轮 19s → 2.5s
容器白名单:设置页从主机拉取实际容器列表并勾选 报告只含被检查的容器,不再把没读的容器算作「已检查」
PS 帧随白名单收窄 面板不会出现「7 个容器」但只有 2 个有数据
勾选而非手打容器名 手打拼错会让巡检「什么都不匹配」,看起来像一切正常

留空 = 全部容器。设置页点「从主机读取容器列表」(约 400ms,一次 SSH 往返)即可按实际容器勾选,不用手打名字。

无人值守告警(只在插件界面提醒)

开启后,每轮巡检结束会把值得惊动人的变化记进告警列表,面板右上角的「告警」按钮显示未读数,点开查看。不发邮件、短信或任何外部 webhook——提醒就在插件里,紧挨着证据。

事件 级别 说明
容器刚进入异常状态 严重 上一轮还正常,这一轮坏了
出现新错误指纹 警告 受 minCount 门槛控制
错误量突增 警告 计数至少翻倍
错误一直不消失 警告 连续 N 轮巡检都在(默认 3 轮),只在跨过阈值那一轮提醒一次
主机读不到 严重 连不上本身就是事故

为什么需要「一直不消失」这条:前三条规则结构上抓不到一种最常见的真实故障——服务一直报错但容器活着。第一次巡检时它是「新指纹」(报一次),此后再也不新、计数也不涨、容器也没挂,于是永远沉默。实测 WeKnora-app 就是这种:每轮稳定 36 条 dial tcp: lookup langfuse-web: no such host,而告警列表里一片安静。

实现上按「连续轮次」计数(state.json 里存 streaks),只在跨过阈值那一轮报一次。如果写成 streak >= 阈值 就报,一个没人会修的后台错误会每轮响一次——那正是训练用户无视面板的做法。中间断过一轮就重新计数(错误消失又出现 = 新的一集,不是「第 9 轮」)。

为什么需要落盘:定时巡检是在面板关着的时候跑的。如果告警只写日志,你永远不会看到它。所以告警会写进 inbox.json,面板每 30 秒轮询一次,打开面板时历史告警仍在列表里。

去重是关键:同一告警在冷却期(默认 60 分钟)内只发一次。指纹里的数字(耗时、计数、ID)会先归一化,否则「took 1523ms」和「took 1891ms」会被当成两个新错误,冷却形同虚设。冷却窗口不会因为反复出现而滑动——否则一个持续报错的服务会永远不再告警。

告警记录失败不影响报告生成——报告永远先落盘。

服务器上不装任何东西(架构)

「主动记录 → 自动分析 → 自动生成报告」不需要常驻服务器,也不该常驻:

服务器                        你这台 Windows
──────                        ──────────────
容器 stdout(docker 本来就在写)      │
                              SSH 只读拉取(用完即断)
                              ↓
                          聚类 / 根因 / 脱敏
                              ↓
                          report.md + reports/*.md
环节 在哪发生 服务器侧痕迹
记录日志 服务器(docker 自身的 log driver) 无(本来就存在)
抓取 / 分析 / 生成报告 本机 无
归档历史 本机 reports/ 无

服务器上没有 agent、没有 cron、没有常驻进程。每轮只做:SSH 登录 → 跑只读命令 → 拉回日志 → 断开。临时文件落在 /tmp 且用完即删。

对服务器的影响(实测,7 个容器的机器):

项 实测
单次 SSH 连接 ~400ms
一轮完整巡检 ~12–19s,期间有可感知的磁盘读 + 一次 docker stats CPU 采样
常驻进程 0
磁盘写入 /tmp 下几 KB,用后删除
网络流量 一轮约 150–300 KB
超时保护 远端 timeout -k 5 兜底,不留孤儿进程

唯一真实影响是巡检那一刻的负载。业务高峰期建议手动触发,或把定时间隔设成业务低谷(例如 intervalMinutes: 1440 每天一次)。

铁律(写死在实现里)

  1. 只读:探针脚本只读,任何 restart/rm/pull/写文件都没有;
  2. 有界输出:单容器日志 tail 上限 + 整轮行数预算 + 渲染字符上限(12000);
  3. 结论可回溯:根因带 文件:行号 级别的 evidence;源码只给地址与范围,正文由模型按需调 log_source_read 取;
  4. 脱敏后才出站:内网 IP → <ip>,密码/token → <redacted>,手机号/身份证同样替换;
  5. 降级不中断:dmesg 被拒、systemctl 不存在、docker stats 无输出,都只让对应帧变空,不失败;
  6. 空转不打扰:intervalMinutes: 0(默认)意味着零后台活动;
  7. 不重叠:上一轮没跑完时,定时器跳过本轮,避免把负载翻倍压在已经吃力的机器上。

指纹静音(勾选同类报错不再显示)

面板里反复出现但你知道原因的错误(第三方组件噪音、已排期的问题),可以静音:报告、告警、AI 分析三处同时不再出现——静音了还会响的静音是谎言。

"muted": [ { "key": "USER_AGENT environment variable not set", "note": "langchain 噪音,已反馈上游", "at": "2026-11-03T21:40:00Z" } ]
设计点 为什么
子串匹配、大小写不敏感 面板给的是归一化后的完整 key,但设置页手填 lookup langfuse-web 也应生效——静音是运维决策,不是启发式
隐藏不删除 被静音的行移到 mutedFingerprints,报告末尾一行 _已静音 N 条_;完全静默会让人以为噪音从未存在
note 强烈建议填 半年后「这条为什么被藏了」需要答案;没有 note 的静音是列表腐烂的开始
告警侧同样过滤 selectAlerts 在 diff 上过滤,静音的指纹永远不能触发告警
上限 200 条 没有上限的列表是对「静音」这两个字的滥用

分析过程可见(写入宿主会话)

AI 分析原先是一次性调用:结论贴到面板,你看不到它读了什么、依据是什么。看不见的推理不值得信任,所以每次分析都会写进一个真实的宿主会话——侧边栏「未分组」下会多出:

未分组
  日志巡检 · AI 分析      ← 每轮巡检的提问 + 模型回复
  数据库探查 · AI 分析    ← db_inspect 相关的模型判断
  浏览器报错 · AI 分析    ← 前端报错的模型解读

点开任何一个,能看到当时喂给模型的完整摘要和它的原文回复,配合面板上的结论逐句对照。

关于「分组」的诚实说明:DSH 没有给插件的「创建分组」API——CreateSessionOptions.meta 只有 cwd / parentSession / isSeeded / origin / delegationDepth / agentPreset,没有 group 字段,侧边栏的分组是宿主自己的数据。插件能做的是创建不带分组的会话,它们会自动落进「未分组」。效果就是你要的那个,但实现路径是「创建独立会话」而不是「先建分组再往里塞」。

机制 说明
按组件分会话 三个槽位 dlt-analyze-log / -db / -web,各自的对话不会串
80% 换新会话 累计 token 超过窗口的 80% 时,下一次分析改用 dlt-analyze-log-1;旧会话原样保留,历史可查。单次分析不会被从中间切开
软失败 宿主没有会话服务(headless profile)时静默降级:分析照常,只是没有记录。绝不因为写会话失败而让巡检失败
默认开启 你要求默认开。设置页可关闭,并可看到各组件当前正在写入哪个会话、累计多少 token、换过几次

曾经的真 bug(已修):会话一直是空的。

user/message 和 assistant/message 在 dsh-session 里属于 surface-eligible 事件类型, 写入时必须带第三个参数 { surfaceOp: 'append' },否则会抛:

session event "user/message" is surface-eligible and requires a surfaceOp marker

插件当时没带这个标记 → 每次 append 都抛 → 异常被吞掉 → 返回 {ok:false, writes:0}, 而状态接口仍然显示「分析会话:dlt-analyze-log」且 lastError: null。 面板显示一切正常,实际一个字都没写进去。

现在两处都修了:append 带上 surfaceOp,并且写入失败会把 store 的原话记进 lastError, 不再显示成健康的零。测试也换成了严格 mock——旧测试用的假 session 接受任何参数, 所以功能全坏时 739 条测试依然全绿。现在 npm run verify:transcript 会直接用真实安装的 @deepseek-ai/dsh-session 跑一遍,确认事件真的进了日志、能读回、且旧形状会被拒绝。

第二个 bug,同一个功能:事件写进了内存,却永远不落盘。

修好 surfaceOp 之后 writes 涨到 2,但侧边栏依然是空的、~/.dsh/sessions 里没有文件。 原因是「能写」和「能看见」是两件事,而插件只做了前一半:

步骤 作用 谁在做
ctx.sessions.create(id) 会话进入内存,append() 可用 插件(做了)
ctx.sessionPersistence.create(header) 取得写所有权,注册写入器 只有 dsh-agent-loop(插件没做)

持久化层只监听 session/event,而 session/event 只在已接管的会话上派发。没有第二步, 事件就派发给「没有人」,JSONL 后端永远收不到,文件永远不存在。净效果: 应用自己创建的会话都在磁盘上,插件手工创建的会话是内存幽灵——只在我们的计数器里存在。

现在的修法:ctx.inject(['sessionPersistence']) 单独注入,拿到后先 persistence.create(handle.header) 认领会话,写完再 ctx.sessions.flush(session) 落盘。两个服务独立降级——只有 sessions 没有 sessionPersistence 时,分析照常、内存照写,但 lastError 会明确说「这个会话只在内存里, 不会出现在侧边栏」,而不是假装成功。

npm run verify:transcript 现在会验证三件事:真实 store 接受 append 形状、事件内容可读回、 以及持久化层确实认领了该会话(transcript-durable.mjs)。第三件事正是前两轮漏掉的那个问题。

第三个 bug,还是同一个功能:会话落盘了,但落错了目录。

修好持久化之后 ~/.dsh/sessions/_no-cwd/dlt-analyze-log 出现了——一个空目录。 JSONL 后端按 sessions/<工作区>/<id> 分目录存放,而 header 里没有 cwd 时会落进字面量 _no-cwd。侧边栏只列当前工作区,所以这个会话写了磁盘、依然看不见。

修法:创建会话时把 cwd 传进 header(调用方显式指定的优先,否则用宿主进程的工作目录, 也就是当前工作区)。scripts/transcript-cwd.mjs 会直接检查 header 里到底有没有 cwd、 是不是 _no-cwd。

同一个症状背后连着三个 bug,每个都能让「看起来修好了」但侧边栏依旧是空的:

# 症状 根因
1 writes: 0,lastError: null 缺 surfaceOp 标记,append 抛异常被吞
2 writes: 2,磁盘无文件 没调 persistence.create(),事件派发给「没人」
3 磁盘有文件,侧边栏没有 header 缺 cwd,落进 _no-cwd

浏览器端报错采集(网页端也是证据)

服务器容器日志永远看不到用户浏览器里发生的事:JS 异常、未处理的 Promise 拒绝、前端收到的 5xx。当「每个容器都是绿的、页面却是坏的」时,浏览器里的报错往往是唯一线索——后端日志一个 ERROR 都没有,故障却真实存在。

开启设置里的「浏览器端报错采集」后,插件面板在当前这个页面安装捕获钩子(error / unhandledrejection / fetch 包装),报错经本机回环存进 browser-errors.jsonl,巡检时并入报告,作为独立小节「浏览器端报错」渲染——刻意不与容器指纹混排:「服务端全绿、浏览器端全红」这个对比本身就是诊断结论。

红线 为什么
只采集当前页面 不注入任何业务站点、不做 <script> SDK——插件不做跨站采集器
默认关闭 与告警同一条纪律:没有明确同意就没有后台活动
只记 path+status,不记响应体 响应体可能巨大且含请求时密钥
采集器自身静默 上报失败被吞掉——采集钩子若抛错,就变成它自己要捕获的错误
双层上限 单条 8KB、整个存储 512KB,最旧先淘汰;同一条报错 60 秒内只记一次(渲染循环里崩溃每秒百次,是同一件事)
入盘即脱敏 URL 里的 token 在写盘前过 redact

聚类归一化与容器指纹同源:消息里的易变数字(React key id、计数)折叠成 <n>,同一个警告出现 50 次是一行 count=50,不是五十行噪音。

数据库只读探查(db_inspect)

定位「数据为什么不对」这类问题需要看库。新增 db_inspect 工具:模型可以直接查库,但结构上写不进去——防写不是提示词约定,是三层独立机制:

层 机制 失守时
1. 语句校验(本机) 白名单:仅 SELECT/SHOW/EXPLAIN/\d 类元命令(redis 仅 GET/SCAN/INFO 等读命令,任意 ; 拒绝)。INSERT/UPDATE/DELETE/DROP/GRANT/FLUSHALL/EVAL/SHUTDOWN… 一律拒绝 被拒语句根本不离开本机
2. 会话只读 postgres SET default_transaction_read_only=on;mysql SET SESSION TRANSACTION READ ONLY=1(与查询同一次 stdin) 数据库服务端拒绝
3. 事务回滚(仅 postgres) 整个会话包在 BEGIN READ ONLY; … ROLLBACK; 里 即便前两层全失守,事务结束自动回滚

redis 没有会话级只读开关,所以它的唯一防线是第 1 层——校验因此最严(EVAL/SCRIPT/CONFIG/任意 ; 全拒)。这是诚实的不对称:测试覆盖了三层的独立有效性。

实测(真实主机 postgres 17.11):BEGIN READ ONLY; INSERT … 返回 ERROR: cannot execute INSERT in a read-only transaction;DELETE FROM … 与 SELECT 1; DROP TABLE x 在本机第 1 层即被拒,从未到达服务器。

选服务器 → 下拉选库 → 填密码:对话框里选好「所在主机」后,会自动扫描那台服务器上的数据库, 把 容器 · 类型 · 库名 作为一个下拉列表给你挑。你不需要知道容器叫什么、库叫什么——那恰恰是没人记得住、 也最容易填错的部分。

扫描是只读的(docker ps + 各容器内的 psql -c 'SELECT datname …' / redis-cli INFO keyspace), 所以列表不会和现实脱节。三种情况会被区分开,而不是都显示成"空的":

下拉里的样子 含义
WeKnora-postgres · postgres · WeKnora 真的查到库名,直接选
WeKnora-redis · redis ·(需要密码才能列出库名) 这库要认证。填上密码后重新扫描就能列出 keyspace
web-mysql · mysql ·(容器已停止,启动后才能列出库名) 容器没在跑,不是"没有这个库"

选中一条会顺手把容器名、类型、用户、库名填好,并把连接方式设成 socket(扫描本来就是走 docker exec 成功的, 所以这是已知能通的连法)。填完仍然是全部可改的:扫描只告诉你"有什么",不限制你想要什么。

一个刻意的边界:如果数据库是嵌在应用镜像里的(比如 web-tlias 这种 Spring 应用,库配置在 jar 包里), 镜像名和环境变量里都没有数据库线索,扫描就不会报告它——只能靠猜,而猜错比不猜更糟。 这种库请手动填容器名和库名。

两种连接方式(设置页「数据库配置编辑」里选,模型也能用 mode 指定):

方式 怎么连 什么时候用
socket(默认) docker exec 进容器,走容器内本地 socket 没发布端口的数据库。Docker 网络内的库通常不 publish,SSH 主机又没有 psql/redis-cli,这是唯一可行的连法
tcp 连 dbHost:dbPort;container 非空时客户端在那个容器里跑 数据库不在容器里、或需要验证端口真的对外开着

tcp 模式里 container 是「客户端住在哪」,不是被连的对象——这是实测出来的:WeKnora-postgres 的 5432 没有 publish,从 SSH 主机直连被拒,而且那台主机根本没装 psql。所以 tcp + 容器名 = 容器里的客户端 连容器网络的数据库;容器名留空才表示「SSH 主机自己有客户端」。

传输卫生:query 经 base64 进 env,永不进 argv;脚本本身(可能带着 base64 密码)经 stdin 管道送进 bash -s——不是 argv、不是临时文件、不是环境变量,所以 ps 在两端都看不到它。输出过 redact;上限 32KB。 仍是服务器上不装任何东西:客户端随镜像自带(已验证 postgres/redis/mysql 镜像均内置 CLI),一次 SSH 用完即断。

曾经的真 bug(已修):密码被写进本机临时文件、把路径当 DB_PW_FILE 传给远端 cat。在 Windows 上那个路径是 C:\Users\…\Temp\…\pw,远端 Linux 上永远不存在 —— 于是密码认证从来没生效过。它没被发现,是因为 postgres 的 pg_hba 对 127.0.0.1 是 trust:错的密码也能连上,看起来一切正常。抓到它的是 redis(它强制认证)。 现在密码经过 stdin 送进脚本、在内存里 base64 解码,远端不落盘,且加密后的形态也不进 argv。

target 与 log_triage 同义:既可以是设置页里的主机名称(openeuler-vm),也可以是裸 user@host。 名称会在本机解析成 user@host 并带上端口与 askpass 密码——早期版本把名称原样丢给 ssh,于是 「日志巡检刚连上的那台机器,查库却说无法解析主机名」。

设置页的「数据库连接」是简写:每个条目是一整条连接(name/host/mode/container/dbHost/dbPort/kind/database/user/password/color/note), 模型只要把 name 当 target 传(或只传 container),缺的参数由这条记录补齐。显式参数永远优先。 「测试连接」和模型调用走同一个 resolveDbConnection——否则会出现「面板说连上了、模型连不上」, 而绿色对勾比报错更没人查。这两半曾经都没接上:表单能填能存,db_inspect 从不读那张表。

配置

落在 ~/.dsh/log-triage/config.json:

{
  "hosts": [
    { "name": "app-01", "ssh": "root@10.0.0.11", "since": "3h", "tail": 4000, "containers": [] },
    { "name": "lab-vm", "host": "10.20.30.40", "user": "root", "port": 22, "password": "", "note": "", "color": "blue" }
  ],
  "intervalMinutes": 0,
  "since": "3h",
  "tail": 4000,
  "top": 12,
  "concurrency": 3,
  "timeoutMs": 180000
}
  • containers: [] 表示该机全部容器;填名字则只看这些;
  • 主机行既可以是紧凑字符串 ssh: "user@host",也可以是结构化字段 host/user/port/password/note/color(设置页「SSH配置编辑」对话框保存的形态),两种形态双向兼容,加载时缺哪边就派生哪边;
  • 密码认证:password 非空时走 OpenSSH 的 SSH_ASKPASS 机制——密码只放在 ssh 子进程的环境变量里,不进命令行、不落到远端,临时 askpass 脚本用后即删;留空则照旧走本机密钥免密;
  • port 缺省 22;IPv6 目标写 [::1]:22 这种带方括号的形态;
  • 状态与报告同目录:state.json(上一轮指纹基线)、report.md(最新一份)、reports/<时间>.md(历史归档,保留最近 200 份)。

前置条件

  1. 本机 → 目标机可达,二选一:
    • SSH 免密(推荐):
    type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh root@10.0.0.11 "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys"
    
    • 在设置页给这台主机填密码(SSH_ASKPASS 通道,见上)。要求 OpenSSH ≥ 8.4(SSH_ASKPASS_REQUIRE=force),Windows 10/11 自带 OpenSSH 与 openEuler 24.03 均满足。
  2. 远端账号能跑 docker(在 docker 组即可,不需要 root;dmesg 需要 root 或有 CAP_SYSLOG,拿不到就自动跳过)。

路由(仅本机回环可访问)

方法 路径 说明
GET /plugins/dsh-log-triage/env 配置(密码已剥离)、路径、主机数、上次运行
POST /plugins/dsh-log-triage/run 跑一轮分析,返回 Markdown + 结构化结果 + 归档路径
POST /plugins/dsh-log-triage/check 只测 SSH 连通性(一次往返,~400ms,不跑探针)
POST /plugins/dsh-log-triage/containers 列出某台主机的容器名(供设置页勾选,~400ms)
GET/POST /plugins/dsh-log-triage/config 读 / 存配置
GET /plugins/dsh-log-triage/report 最新 Markdown 报告;?name=<归档文件名> 读历史;?structure=1 读结构化快照(面板刷新用)
GET /plugins/dsh-log-triage/reports 归档报告列表(?limit=,默认 50)
GET/POST /plugins/dsh-log-triage/alerts 告警收件箱:GET 读列表与未读数,POST 标记已读

开发

npm run check   # 遍历 lib/ 下每个 .js 做语法检查(不再手维护文件清单)
npm test        # 内核单测(node --test,742 项)
npm run e2e     # 端到端:离线 fixture + 真机探针 + 工具调用
npm run verify  # 路由冒烟 + 工具实跑(空配置,不需要真机)
npm run verify:live  # 同上,但用真实配置打真机
npm run verify:db    # 数据库配置:/db-check 与 db_inspect 走同一条解析,含错误密码必须失败
npm run verify:discover  # 下拉选库:/db-discover 真的扫出库,且扫出来的库真的能查
npm run verify:transcript  # 分析会话:真实 store 接受 append 形状、持久化层认领、cwd 正确

check 曾经是一串手写的 node --check a && node --check b …:lib/ 有 26 个文件,它只列了 15 个,而 漏掉的正好是当时新加的 tool/db-inspect.js、runtime/db.js、runtime/transcript.js 等——新增文件里 有语法错也照样通过。现在是 scripts/check-syntax.mjs 遍历目录,新模块一落地就被覆盖。

为什么需要 verify:host 半侧每个进程只加载一次,改了 lib/ 之后正在运行的 DSH 仍在跑旧代码, 于是「面板报 500」永远有两种解释——代码错了,还是进程旧了?scripts/route-smoke.mjs 在新进程里 用真实 apply() 注册路由、逐个调用 handler,并把返回值按 registry 的规则校验一遍,一次就能分清。

这一类 bug 的共同形态

本轮真机测试暴露的 6 个问题,形态是同一个:每层都"看起来实现了",但两半没有接上。

表面 实际 后果
log_triage 返回 sourceEvidence output.schema 没声明这个字段 registry 用 additionalProperties:false 校验,每次调用都失败
runSweep 读 sessionContext 变量声明在 apply() 里 POST /run 恒 500(ReferenceError),面板一个卡片都画不出来
db_inspect 收 target 没走 resolveHosts 设置页里的主机名被当主机名丢给 ssh → Could not resolve hostname
设置页「数据库连接」可编辑可保存 db_inspect 从不读 config.databases 列表是只写字段,模型仍要每次手填四个参数
设置页可改 analysisTranscript / alertOnPersistent / persistSweeps validateDraft 不 emit 这三项 保存后静默回退到默认值,界面还显示「已保存」
/run 收 analyze 参数 runSweep 从不读 overrides.analyze 面板点「AI 分析」得到的是纯规则报告

共同点是没有任何一处测试把两半放在一起跑:单测覆盖了每个函数自己的契约,没有一条断言 "真实返回值能通过声明的 schema"或"表单里改过的字段保存后还在"。所以补的不是 6 个补丁,是三层断言:

  1. test/core/tool-output-schema.test.js —— 把 registry 的 assertSupportedJsonSchema / validateJsonSchemaValue 移植成本地版本(同一套词表、同一句报错),每个工具的真实返回值都过一遍;
  2. test/integration/client-load.test.mjs —— validateDraft 的往返断言:表单编辑的每个字段都必须 emit 出来,且数字被夹到 store 认可的区间;
  3. test/integration/plugin-load.test.mjs —— 模块级函数不得引用 apply() 作用域的变量(就是 sessionContext 那类 bug),外加真调一次 POST /run。

端口不被接受这件事也有结构原因:本插件不 import @deepseek-ai/*,所以工具定义是手写普通对象直接 register()。registry 只校验 output.schema,不校验 parameters——defineTool() 才会做的入参校验 被绕过去了,于是每个 required: [] 都只是注释。现在 lib/tool/args.js 补上了这一层:参数错就报参数错, 不会等到"返回值不符合 schema"才暴露。

另有一个专项脚本,把截图里那条链式报错从原始日志一路跑到模型结论:

node scripts/chained-log-e2e.mjs   # 15 项断言,离线可跑

端到端脚本分三层,真机不可达时跳过而不是失败,所以离开内网也能跑:

  1. 离线:test/fixtures/real-lab-host/*.log(真机抓下来的日志);
  2. 真机:SSH 跑探针,校验帧完整性与无 docker inspect 模板错误;
  3. 工具:按 harness 的调用方式执行 log_triage,断言答案在预算内、定位到错误、且内网 IP 与密码没有泄漏。

开发时的本地加载(link 形态)

改 lib/ 的时候,把 profile 指向本地目录比反复打包省事。dsh plugin add file:<目录> 就是这个形态(装的是软链接,改完源码重启即生效):

dsh plugin --profile web add file:C:/path/to/dsh-log-triage
# 重启 DSH(host 半侧每个进程只加载一次)

卸载回退:

dsh plugin --profile web remove dsh-log-triage

本仓库不含 scripts/link-fork.ps1 / unlink-fork.ps1。作者本机用的是仓库外的一份 PowerShell 辅助脚本,它做的事就是上面这两条命令的手工版(改 profile 的 dependencies 写 link:、把包名塞进 dsh.profile.bundles、再跑一次 pnpm install)。 装了本插件之外的读者不需要它。完整安装说明见 docs/INSTALL.md。

实现要点(踩过的坑)

  • 不 import @deepseek-ai/*:第三方插件不保证能解析到宿主私有依赖(实测 @deepseek-ai/dsh-tools 从 profile 的 node_modules 解析失败)。所以工具定义是手写的普通对象,schema 直接写成编译后的 JSON Schema(对象级 required: [] 数组,而不是属性级的 required: true)。已用 DSH 真实的 assertSupportedJsonSchema / validateJsonSchemaValue 校验通过。详见 docs/dsh-plugin-api.md。
  • docker inspect 没有顶层 .ExitCode:openEuler + Docker 26 上 {{.ExitCode}} 会报 map has no entry for key,必须用 {{.State.ExitCode}}。
  • df 在中文 locale 下表头是中文:解析一律 LC_ALL=C 或按列位取值,绝不匹配英文表头。
  • docker logs --since 3h 对几个月前退出的容器是 0 行:探针会在窗口为空时回退到全量 tail,并强制插入 <window empty since=3h; showing older log history instead> 标记,避免把旧日志误当新日志。
  • 容器名要加 -- 终止符,否则容器名以 - 开头时会被当成 flag;名字里的 @@@ 会伪造帧边界,探针已统一净化。

已知限制

  • 未做告警推送(可接 QQ/IM 通道);
  • 未做趋势图(state.json 已保存每轮指纹,可扩展);
  • 定时巡检的「主动汇报」目前只写日志与 report.md,没有主动唤醒会话(ctx.schedule 方案见文档,属未验证路径);
  • 指纹粒度是「归一化后的错误行」,长堆栈会被切成「每层一个指纹」——这是刻意的,模型能看到异常链的深度,但同一条栈会占多个席位;
  • 改完 lib/ 必须重启 DSH 才生效:host 半侧每进程只加载一次,client 半侧也没有 dev:web watcher。 所以"面板还是 500 / 工具还是报错"要先确认进程是否已经重启过——npm run verify 就是在新进程里 跑同一份代码,用它区分"代码错"和"进程旧"。

许可证

MIT,见 LICENSE。

—/ 5

No ratings yet

Verified DSH bundle

Commit 446619841b91

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout