# @hyzyn/dsh-loglens — 路线图 > **状态:P0 / P1 / P2(含真机验收)/ P3 / P4(含出站围栏)/ P5 / P6 全部落地(2026-10-11 ~ 10-12)——设置面、查询地基、 > Loki 与 ES / OpenSearch 适配器、出站围栏、agent 工具(**名单以 `src/tools.ts` 注册处为准**,`loglens_facets` > 是当天后补的第五个)、Docker 的选项 0 两格(agent 指路 + 面板容器日志区)、浏览器侧的结果视图都可用; > **Loki 分页已在真 Loki 3.7.8 上验收并翻成 `cursor`**(真机抓到 D02/D03 两个缺陷,见「P2 真机验收」一节); > **但 Loki 本版不从界面提供**(2026-10-18,维护者决定:适配器与上面那次验收都在仓库里,缺的是 > **能力闸**那一段真机验收 ⇒ 类型下拉不列它、新行默认 ES;恢复是一行,见下面 > §「Loki 本版不从界面提供」); > Loki 的 `tail`(WebSocket 跟随)**已决定暂缓**(2026-10-12,理由见「P2 真机验收 → 还没做的」那一节; > `capabilities().follow` 因此诚实地声明为 `false`);**时间分布(直方图)**当天也已落地;**面板的 > 「按字段分组」榜(terms)**(2026-10-13 补上,见文末那一节);「待拍板」的 > 5 / 6 两项都已拍板(6 取选项 1「受控重复」,见 [design.md §5](./docs/design.md))。** > `packages/loglens/` 现在是 workspace 包 > (`@hyzyn/dsh-loglens@0.1.2`,已进 [publish-targets.mjs](../../scripts/publish-targets.mjs) 与两份聚合清单, > `pnpm-lock.yaml` 已同步)——所以 [docs-index 判据 5](../../scripts/docs-index.mjs) 现在**看得见它**, > 而不再是「本目录不在解析面里」。设计与契约见 [docs/design.md](./docs/design.md); > `README.md` 已按 [conventions § 文档分档](../../docs/conventions.md#文档分档) 补到当前状态(配置面 / 解剖 / 已知限制)。 > > **id 已定:`loglens`**(包 `@hyzyn/dsh-loglens`)。`logs` 这个 loader id 已被第三方插件 > `dsh-logbook` 占用,npm 上的 `dsh-logs` 也已被他人占位——理由与证据见 [design.md §8](./docs/design.md#8-已实测前提)。 > > **2026-10-13 ~ 10-18 这一段(上方状态段没覆盖到的)**:面板交互连做了三十余刀——字段面板收成 > 「只选列」、筛选收成两格输入框、直方图重做(读数气泡 / 峰值记号 / 基线裁剪)、查询串方言澄清 + > 字段名补全 + 正文高亮、截断提示分两档、Docker 那一档重排、来源行的保存 / 索引标签 / 删除权重、 > 加载中可点、输入框可清空,最后补了 `README.en.md`。**逐条见文末 `## 已完成` 各节**(每节都有 > 落点、反例与门槛),这里不复述。仍未做的两项与它们的位置见 [§ 分期](#分期) 的 P8 注与 > [design.md §9](./docs/design.md) 的头部标注(右侧栏工作台**一期都没落地**)。 本文只放**还没做的事**;落地记录排在文末 `## 已完成(落点 + 门槛)` 之后 (判据:[docs-index](../../scripts/docs-index.mjs) 的 `checkPackageRoadmapLanded`)。 --- ## P0-甲:模板 kit 钉子(独立改动,与本包无关) **为什么先做**:[templates/hello/package.json](../../templates/hello/package.json) 钉 `@hyzyn/dsh-kit: "0.3.0"`,而 kit 是 `0.5.2`、其余 **9 个消费者全部 `0.5.2`** (2026-10-11 复核修正:这里原先写的「10 个」是错的 —— `kit-settings` 与 `all` 都不声明 kit,闸门实测就是 9 处)。 [check-kit-pins.mjs](../../scripts/check-kit-pins.mjs) 的 `collectKitPins()` 只 `readdirSync(packages)`, `templates/` **不在扫描面** → 该漂移**现在无人会红**(实测 `node scripts/check-kit-pins.mjs --quiet` 退出码 `0`)。 不修的后果:`pnpm create-plugin loglens` 会带着 `0.3.0` 进 `packages/`,立刻触发「全仓出现 2 个不同钉子」; 且根 `.npmrc` 的 `link-workspace-packages=true` 只对**范围**生效,**精确钉子不匹配就不 link**, 会真去 registry 拉旧 kit——正是 `kit D11`(模块级单例失效)要防的那一侧。 **四步(一次改动;做到可提交状态,`不 commit`——`git commit` 在 [conventions § AI 协作边界](../../docs/conventions.md#ai-协作边界什么改动要先问) 的先问清单里)** 1. [templates/hello/package.json](../../templates/hello/package.json) 的 kit 钉子:`0.3.0` → `0.5.2`。 2. [create-plugin.mjs](../../scripts/create-plugin.mjs):生成时从 `packages/kit/package.json#version` **现算**钉字, 不再信任模板字面值(模板不是运行期事实:`pnpm-workspace.yaml` 只有 `packages/*`, `pnpm-lock.yaml` 对 `templates/hello` 零命中)。 3. 纯函数反例:把纯函数抽到 **`scripts/` 根**(落地为 [scripts/create-plugin-manifest.mjs](../../scripts/create-plugin-manifest.mjs)) 并 `export`,`create-plugin.mjs` 与新测试都 import 它;测试喂 `kitVersion = 9.9.9`、断言生成的 pin 是 `9.9.9`。 **不要 import `create-plugin.mjs`**——它带 shebang,而 [check-kit-pins.mjs](../../scripts/check-kit-pins.mjs) 已登记「被测试 import 的 `.mjs` 带 shebang + CRLF → 整份套件加载失败」。 **落点由 `scripts/lib/` 改为 `scripts/` 根**(落地时的改判):[vitest.config.ts](../../vitest.config.ts) 把「被用例直接 import 的纯模块」定在 `scripts/` 根、与脚本同目录(先例 `client-host-url.mjs`), 而它给 `scripts/lib/` 的口径是**真机脚本共享的隔离引导**。`scripts/lib/dsh-runtime-store.mjs` 事实上 也被测试 import、与那段口径不符 —— 按纪律 3 **只加了标注**,没改口径(见文末落地记录)。 4. [RELEASING.md](../../RELEASING.md) 发布门槛补一句:模板钉子的真相来源已改为生成时现算。 **刻意不做的**:把 `templates/hello` 加进 `collectKitPins()` 的扫描面。按判据 3,钉子必须**等于 kit 当前版本** ⇒ 每次 bump kit 都要改模板,等于给 [RELEASING.md](../../RELEASING.md) 那条「跟齐每一个消费者」的门槛 **新增一个人工同步点**(漏了在发版中途红)。若日后仍要守卫,判据应写成 「模板钉子是**合法的精确版本**」,而**不是**「等于当前 kit 版本」。 --- ## 独立待办:loader id 撞名的运行时行为(隔离 profile 实测) **为什么单列**:这不只服务本包。2026-10-11 实测确认生态里**真的会撞 id**——`dsh-logbook` 的 `cordis.patch.yml` 就是 `- id: logs`,与任何取 `logs` 的插件在同一 profile 里争同一个 loader entry id。 [architecture.md §4](../../docs/architecture.md#4-谁写哪个文件跨包冲突的唯一来源) 登记的 `duplicate loader entry id` 说的是**手工往同一个 patch 文件追加**那一侧;**两个独立 bundle 各 insert 同一个 id** 会怎样 (报错 / 后者覆盖 / 静默跳过 / 谁赢)本仓**没实测过**,也没有任何守卫看得见。 **做法**:起一个隔离 profile(独立 `DSH_HOME`、临时资源可识别前缀、跑完自证真实配置未变), 同时挂两个各自 `insert` 同一个 id 的 bundle,观察启动日志与两个插件的挂载结果;结论写进 [troubleshooting.md](../../docs/troubleshooting.md)(「跨包通用故障诊断路径」的归宿)。 **门槛**:说清「症状 / 谁赢 / 有没有日志 / 能不能自愈」四格,并给出用户侧的排查顺序。 > **✅ 2026-10-12 收口:做完了**(隔离 profile 实测,四格 + 用户侧排查顺序都落进 > [troubleshooting.md](../../docs/troubleshooting.md) 的「报 duplicate loader entry id?」那一块; > 原文按纪律 3 保留,本节只加这一句指路)。一句话结论: > > - **谁赢**:组合顺序里**靠后的那个 bundle**(`dsh.profile.bundles` 越靠后越赢),两向都实测过; > - **有没有日志**:**一条都没有**——输的那个插件的 `apply()` 从未被调用,路由 404、卡片不见, > 宿主照常启动照常服务。而且**不是「打了没看见」**:机制所在的 > `cordis-plugin-loader@1.0.6-alpha.1` 的 `lib/index.js` 里 `Object.fromEntries` 按 id 建 map > (同 id 后写覆盖先写),全文 `grep duplicate` **零命中**——**这条日志路径根本不存在**。 > (本文档复核过 loader 源码:`newMap = Object.fromEntries(config.map((o) => [o.id ?? Symbol('anonymous'), o]))` > 与「无 duplicate 告警」两点都亲自确认。) > - **能不能自愈**:不能。重启不变、换顺序只是**赢家换人**、用户层 patch 写 `disabled: true` 反而 > **两个都不挂**;**唯一根治是改 id**。 > > **纪律 3 的例外说明**:本节的待办文字**保留**(它记的是「当时为什么单列」),完成标记是上面这段。 > 顺带产出的一个**建议**(未实施,留给维护者拍板):加一道**纯静态守卫**——现算各包 > `cordis.patch.yml` 的 insert `id`,断言仓内两两不同(挡「本仓两个包抢同一个通用词 id」)。 --- ## P0-乙:原子建包(必须一次提交,中间态必红) | # | 动作 | 守卫 | |---|---|---| | 1 | `pnpm create-plugin loglens`(自带 `pnpm aggregate`) | — | | 2 | [publish-targets.mjs](../../scripts/publish-targets.mjs) 补一行 | [check-publishable.mjs](../../scripts/check-publishable.mjs) 的覆盖度判据 | | 3 | [architecture.md §2 包清单](../../docs/architecture.md#2-包清单) 加一行 | [docs-index 判据 5](../../scripts/docs-index.mjs) | | 4 | 计数同步(**分三类,见下**) | **无守卫会红** | | 5 | [search 的 PANEL_DIRECTORY](../search/src/index.ts) 加条目(`registryName` = cordis id) | **无守卫** → 补一条 | | 6 | `files` 字段:`lib` / `client.js` / `cordis.patch.yml` / `README.md` | [check-package-contents.mjs](../../scripts/check-package-contents.mjs) | | 7 | peer **逐 cohort 写全 5 档**;`engines.dsh` = `>=0.1.7-rc.2` | [check-dsh-peers.mjs](../../scripts/check-dsh-peers.mjs) | | 8 | 构建型声明(design.md §7 那三件) | [check-i18n.mjs](../../scripts/check-i18n.mjs) / [client-lint.mjs](../../scripts/client-lint.mjs) | | 9 | 台账登记:`LEDGER_SPECS` 加一项 + [编号规范表](../../docs/conventions.md#编号规范) 补一行 + `DEFECTS.md` 首行写「本包序列」 | [defects-table.test.ts](../../scripts/test/defects-table.test.ts)(**硬编码数组,不登记就静默不查**) | | 10 | 先问 4 件事落地(见文末) | — | ### 计数的三类处理(**不许一律改成新数**) [conventions § 文档分档 三条纪律第 3 条](../../docs/conventions.md#文档分档) 原文: 「不写死计数……阈值、命令、基线数字属**技术事实**,只增不改;发现过期或自相矛盾**只加标注,不擅自"修正"**。」 ⇒ 加包引出的计数与名单**远不止 23 处**(2026-10-11 全仓勘察:A 类 14 + C 类 13 + B 类 20), 要按下面三类分别处理;**全量清单见本节末尾的「P0-乙 计数清单」**。 | 类 | 处理 | 落点 | |---|---|---| | **A 结构断言**(当前真相) | **改**(或按纪律 3 的推荐:去数字化、改为指向 §2 那张**有守卫**的表)。**两条出路选哪条本身要维护者拍板** | `architecture.md` 标题 / §1 图 / `§7.1` 表行;`README.md` 包索引;`glossary.md` 的「聚合包」行 | | **B 带日期的实测快照** | **不改数**;要新数据就**另起一行带日期** | `conventions.md` 的两处「10 个客户端半体」(139 / 53)、`client-style-spec.mjs`、`client-design-tokens.mjs`、`docs-index.mjs` 的「12/12」注记 | | **C 覆盖面枚举** | **核对成员,不只是数字** | `i18n.md` 的「3 个有构建步骤」(正确,loglens 进构建型后 3 → 4)、`client-lint.mjs` 的名单(**已过期**,见 design.md §8) | **什么不会红、什么会红**(2026-10-11 全仓逐条实测修正:本节原写「全仓只有 4 处计数断言」, 少算了**会红的两处绝对名单断言**) | 形态 | 站点 | 加包后 | |---|---|---| | 绝对地板 | `docs-index.test.ts:95` `>= 12`、`package-contents.test.ts:177` `>= 13` | **永不红**(13 / 14 照样过;忘了补 `publish-targets.mjs` 那一行也照样绿——`inputs.targets` 就是那个数组) | | 相对站点(两边现算) | `docs-index.test.ts:98` / `:99` / `:100` / `:371` | 漏登记 `architecture.md §2` 就红;补了则绿 | | **绝对名单(会红)** | `plugin-settings-surface.test.ts:116` `toEqual([…8 个包名])` | loglens 一带设置卡片就是 9 ≠ 8 | | **绝对名单(会红)** | `defects-table.test.ts:63` `toEqual([…4 名])` | 登记 `LEDGER_SPECS` 就红;**不登记则静默不查** | | 潜伏名单 | `client-click-scope.test.ts:163` | loglens 不在 `:140` 的扫描名单里,**根本不被扫** | ⇒ 「地板型断言永远不会提醒人『该改文案了』」这句成立;但**名单型断言会红**——加包当天真正拦人的是那两道。 另外 `check-publishable.mjs:31-44` 的覆盖度判据也是**名单型且会红**:加包当天漏补 `publish-targets.mjs` 立刻 exit 1。 ### P0-乙 计数清单(2026-10-11 全仓勘察) **A 类:当前真相,加包要改(或去数字化)** | file:line | 原文片段 | 处理 | |---|---|---| | `AGENTS.md:12` | 系统怎么运作、**12 个包**谁依赖谁 | 12 → 13 | | `README.md:70` | **1 个共享库 + 10 个功能插件 + 1 个聚合包** | 1 + 11 + 1 | | `README.md:86` | 聚合安装包:一条 bundle patch 挂载上面 **10 个插件** | 10 → 11 | | `README.md:200` | **12 个包**怎么协作 | 12 → 13 | | `architecture.md:1` / `:3` | 架构:**12 个包**…/「**12 个包**关系与协作」的唯一归宿 | 12 → 13(或去数字化) | | `architecture.md:13` | ↑ 被下面 **9 个插件**依赖(`kit-settings` 是唯一例外) | 9 → 10 | | `architecture.md:14` | `@hyzyn/dsh-<插件> ×10` | ×11 | | `architecture.md:16` / `:36` | 一条 bundle patch 挂载上面 **10 个** | 10 → 11 | | `architecture.md:138` | 同步档 `isLoopbackRequest`(**9 个插件**的历史口径) | **勘察新发现**:与同文 `:145`「其余 **7 个**插件」**现在就自相矛盾**;跟 §7.1 档位结论一起改 | | `architecture.md:145` | 其余 **7 个插件** | 7 → 8(若 loglens 走同步档) | | `glossary.md:15` | 一条 bundle patch 挂载全部 **10 个插件** | 10 → 11 | | `packages/kit/README.md:93` | **9 个插件**的重复围栏收敛于此(同步档) | **勘察新发现**,本文原文没列 | | `docs/conventions.md:781` | **12 个可安装包**各自在 `peerDependencies` 里… | **口径需拍板**:12 与 workspace 包数同值但含义不同(可安装目标实测 13 个) | | 根 `package.json` / `packages/all` / 两个 `cordis.patch.yml` | 10 个 `@hyzyn/dsh-*` 依赖、11 条 `insert` | `pnpm aggregate` 现算,**加包后必须跑**(`aggregate:check` 会红) | **C 类:覆盖面枚举,核对成员** | file:line | 现状 | 加包后 | |---|---|---| | `i18n.md:24` / `:25` / `:95` | 「10 个包里只有 3 个有构建步骤」(`tty`/`docker`/`codegraph`)、其余 7 个裸 `client.js` | 走构建型则 3 → 4、10 → 11、裸包成员换一个 | | `client-lint.mjs:19-21` | 「只有 docker / tty 两个包有 `client-src/`;其余 7 个(codegraph / env / …)」 | **两处错且互相抵消**:漏 `codegraph` 出「两个包」、名单又把 `codegraph` 放错侧并漏 `kit-settings`;数字 7 恰好对上。按纪律 3 只加标注 | | `client-lint.mjs:126` | 「7 个裸 `client.js` 的包走这条」 | 同上 | | `client-host-url.test.ts:128`、`client-click-scope.test.ts:140` | 硬编码 10 包名单 | 加 `loglens`,否则仓库级规则测试对它**零覆盖** | | `plugin-settings-surface.test.ts:119` | 硬编码 8 名循环 | 加 `loglens`(否则它的设置面契约无人验) | | `defects-table.test.ts:63` | 4 名硬编码 | 登记台账时同步加 | | `search/src/index.ts:628-746` `PANEL_DIRECTORY` | 15 条(5 section + 10 card),其中 7 条带 `registryName` | 加 `loglens` 条目(`registryName` 也取 `loglens`);**顺带**:`search/README.md:55` 与 `panel-catalog.test.ts:98` 写「六个插件卡片」,实际已是 **7**(与加包无关的既有漂移) | | `scripts/windows/*.mjs`、`verify-client-ui.mjs` | 9 / 10 / 八个 的硬编码数组与 Set | 真机脚本**不进 CI**,漏了无人红,且这些脚本不会覆盖 loglens | **B 类:带日期的实测快照,按纪律 3 不改数**(`conventions.md:377` / `:418` / `:855`、 `client-style-spec.mjs:17` / `:66` / `:891`、`client-design-tokens.mjs:42` / `:57`、 `check-package-contents.mjs:45`、`package-contents.test.ts:6` / `:163`、`docs-index.mjs:32` / `:414`、 根 `ROADMAP.md:223` / `:543`、`i18n.md:8`、`codegraph/README.md:123` / `:132-135`、`tty/README.md:134`、 `tty/DEFECTS.md:246`、`pr-body-dsh-market.md` 四处、`windows/README.md:216`、 `verify-client-ui.mjs:26` / `:174` / `:183` / `:370`、`agent-real-test.md:135` / `:161`)。 --- ## 分期 | 期 | 内容 | 可独立交付 | |---|---|---| | **P0-甲** | 模板 kit 钉子(上节) | ✅ | | **P0-乙** | 原子建包 + 计数三类 + 台账登记 | ✅ | | **P1** | 契约 + 纯模块 + vitest 反例(含「同 ts 三行 / limit=2」与「同 ts 同内容两行 / limit=1」) | ✅ | | **P2** | Loki 打通(本地容器真机验;并**单独测同 ts 多行的顺序稳定性**) | ✅ | | **P3** | `loglens_search` / `loglens_fields` / `loglens_sources` / `loglens_context` + 分类表与守卫 | ✅ | | **P4** | ES / OpenSearch 一个适配器(复用 P1 游标;`flavor` 分流) | ✅ | | **P4 尾** | 出站围栏 `src/net.ts`(设计 §4.1 那四条) | ✅ | | **P5** | Docker 作为来源(**选项 0**,零新机制) | ✅ | | **P6** | Loki `tail`(`ws`)**已决定暂缓**(2026-10-12);直方图与浏览器视图(**口径已拍板 = 选项 1**)**已落地**(2026-10-12);面板的**按字段分组榜**(terms)**已落地**(2026-10-13,见文末);design.md §5 | — | | **P7** | 需要**跨后端统一查询**时才申请宿主半体插件间服务 | — | | **P8-甲** | **工作台承载**:新增右侧栏标签挂载点(`sidebarRightTabs.register` + `sidebar.right.pane.tab`,key 用实现 id)+ guide 入口 + 卡片里一个「打开查询台」按钮;拿不到服务**安静退回卡片**。**零契约改动**,四条既有路由原样 | ✅ | | **P8-乙** | **左栏 + 结果表**:字段侧栏(「这一页命中」/「其余可用字段」/「有类型冲突」三分区,复用 `/fields`)+ 结果表列集与行展开(`labels` 表 + `raw`)。**零契约改动**(`raw` 与 `labels` 本就随响应回来)。**已落地**(2026-10-17 第十刀):字段侧栏三分区 + 子串过滤;列集(4 列上限、缺值画 `—`、正文 198px 下限 + 横向滚动);行展开(全部标签 + 原文截断报原长)。P6 第六刀的「1 个身份标签 + +N」保留 | ✅ | | **P8-丙** | **查询栏收口**:绝对时间输入(复用宿主 `asTime` 的既有口径)+ 自动刷新(相对档随当前时刻重算、绝对档不跟)+ 筛选芯片两格(从行上取值加筛选 / 界面侧 mute)+ 直方图放大补「再点一次取消」+ localStorage 记忆(列集 / 间隔 / 最近查询)。**零契约改动**。**已提前落地一部分**(2026-10-16):从行上取值加筛选(与点榜下钻同一个 `drillDown`)+ 级别下拉(复用宿主既有 `level`,页内筛已如实标注)+ 直方图放大补「再点一次取消」+ 回车即查;**自动刷新 / mute / 绝对时间输入 / localStorage 还没做** | ✅ | | **P8-丁** | **要先问的一期**:`NormalizedQuery` 加 `sort`(服务端排序)、`FacetsSpec` 加 `min`/`max`/`cardinality`(字段统计的另一半)、`capabilities().queryLanguage` 分档到 ES\|QL(要 ES 8.11+ 真机)。**每处都要同步 `capabilities()` ↔ 实现的对账守卫** | ✅(**但做之前单独一轮确认 + 真机验收**) | **P8 的设计交底在 [design.md §9](./docs/design.md#9-查询工作台借-kibana-的核心不做-11-复刻)**—— 「借什么 / 不借什么 / 承载 / 契约影响 / 边界与失败模式 / 验收口径」都在那一节,**本表只放判据与落点** (不复制设计正文:复制即第二真相源)。P8-甲…丙 一条共同的前提:**零宿主改动**, 全走既有 `/config` `/query` `/fields` `/facets` 与既有形状,新增的只有浏览器侧状态与 `pure.js` 纯函数。 > **⚠️ 本表第三列的 `✅` 不是状态列**(表头写的是「**可独立交付**」)。它回答的是「这一期能不能单独发」, > **不是「做完没有」**——2026-10-18 复核时发现,`P8-甲` 就是被读成「已完成」的那一格: > 代码里**没有** `sidebarRightTabs` / `sidebar.right.pane.tab` / 「打开查询台」任何一处 > (`packages/loglens/client-src/index.js` 零命中),右侧栏工作台**一期都没落地**; > 真正落地的是 `P8-乙` / `P8-丙` 的**面板内**部分(第十刀起那十几刀,记录在 > `## 已完成` 那节的各刀里)。要判断哪一期真做了,**读那一节的落地记录**,别读这张表。 **P1 的前置**(2026-10-11 已定稿,见 [design.md §3.2 / §3.3](./docs/design.md) 的「P1 定稿」两段): §3.3 的 `cursor_expired` 与 `hit_limit` 分家、`track_total_hits` 归适配器、Loki 顺序稳定性的保守退路 (这项的真机验证仍是 P2 的活;P1 只保证「声明成什么就按什么用」,不在本层假设顺序)。 **验证与验收** - 全仓门禁 `pnpm typecheck && pnpm build && pnpm test && pnpm aggregate` 全绿;`lib/` 与 `client.js` 入库、`git diff` 干净(产物闸门)。 - 每个后端的解析 / 夹取 / 截断归并都有**宿主侧纯模块 + vitest 反例**(真机脚本进不了 CI,见 [conventions § 真机脚本与 CI 接线](../../docs/conventions.md#真机脚本与-ci-接线))。 - **P0-乙 建包当天跑一次 `pnpm coverage:check` 留基线证据**:`coverage-ratchet.mjs` 的范围是 `packages/*/src/**`(glob),loglens 的 `src` 会自动进面;它**只在 nightly 跑、不在 ci.yml** (文件头自述「不挡任何人」),但降了就要么补用例、要么显式 `pnpm coverage:write`。 模板自带 `src` + 一个会被 vitest 收集的测试,所以建包当天大致中性;真正的暴露是**之后**加 `src` 不加测试。 - 真机脚本:Loki 用本地容器;`DSH_HOME` 隔离 + 自证真实配置未变;新增 smoke 脚本照 [tty 的 `windows-smoke`](../tty/package.json) 登记成 npm script。 --- ## 待拍板(先问清单) **2026-10-11 已拍板 4 项**(维护者逐条确认,本轮 P0-乙 就按这些落地): 1. ✅ **档位**:进**加固档** → `isLoopbackRequestStrict` + `hasSameOriginProof`;`/config` 按**动作**豁免 (`enabled` 免、`targets` 增删改要同源证明),并记下不对称(3 个包整体豁免 + 1 个包按动作部分豁免)。 2. ✅ **编号**:mint `loglens Dxx`(**从 D01 起**)+ 登记 `LEDGER_SPECS` + conventions 编号规范表补一行 + `DEFECTS.md` 首行写「本包序列」。 3. ✅ **构建型**:新包**可以**选 `client-src/` + 构建步骤(i18n 内联在 `client-src/index.js`、构建脚本遇残留 import `exit(1)`)。 4. ✅ **建包方式**:`packages/loglens/` 里已有当天新建的两份文档 → 先挪到 `/tmp`、跑 `create-plugin`、再挪回 (两份文档内容一字未改;`pnpm aggregate` 与 `pnpm install --lockfile-only` 随后跑)。 **仍未拍板 2 项**(第 7 项落在 P7;第 8 项是 2026-10-15 新加的查询工作台前置。5 与 6 都已拍板, 保留原编号与结论): 5. **私网语义** ✅ **已拍板(2026-10-11,常规做法)**:**连接面 = 你配置的来源**——回环 / 私网 / 公网 一律放行,只有**链路本地 / 云元数据**被无条件拒绝(外加:只认 http(s)、不跟随重定向、解析后的 IP 判定、 体积与超时上限)。当天先落地的「公网默认拒 + 按来源开 `allowPublic`」**已推翻并整条拆掉**, 理由与证据见文末「公网档改判」一节。 6. **浏览器视图** ✅ **已拍板(2026-10-12,取选项 1「受控重复」)**:日志视图建在**各自插件自己的** 卡片里,不做 `docker` 暴露视图挂载服务、也不新增共享包。判据在 [design.md §5](./docs/design.md): 两份视图**不是同一件事**(loglens 的是每个来源的结果视图,docker 的是容器日志工作台), 而选项 2 要抽的正是那件工作台、改的是 ~9200 行的客户端 bundle——**不是做不到,是不值得**; 将来真要时按 docker 消费 `ttyTerminal` 那个现成形状反过来做即可(不需要新机制)。 7. **宿主服务**:只在 P7 需要时再批(本仓第一条宿主半体插件间服务)。 8. **查询工作台(P8)的三件事**(定于 2026-10-15,设计见 [design.md §9](./docs/design.md#9-查询工作台借-kibana-的核心不做-11-复刻)): ① **新增右侧栏标签挂载点**(`sidebarRightTabs.register` + `sidebar.right.pane.tab`)——跨包观感, **老宿主 / 拿不到服务的回退路径要一起定**(我们的口径:卡片保留今天的查询区,行为一点不减); ② **改 [search/src/index.ts](../search/src/index.ts) 的 loglens 面板描述**(跨包一行;描述现在写着 「面板侧只有来源清单与开关」,工作台落地后过期,而 `panel-registry-names.test.ts` 只查 `registryName`、 **抓不到描述过期**);③ **P8-丁 的契约改动**(`NormalizedQuery.sort` / 新聚合 / `queryLanguage` 分档到 ES\|QL)——单独一轮 + 真机验收,且每处都要同步 `capabilities()` ↔ 实现的对账守卫。 前两件在 P8-甲 / 乙 之前定;第三件是 P8-丁 的前置。 --- ## Loki 本版不从界面提供(2026-10-18,维护者决定) **变的是什么、没变的是什么**:变的是「提不提供」——`client-src/pure.js` 的 `KIND_OPTIONS` 不再列 `loki`、`DEFAULT_KIND` 改成 `elasticsearch`,设置卡片的描述也不再拿 Loki 当招牌。**没变**的是能力: 适配器、游标、`D02`/`D03` 的修复与 P2 那次真机验收全部保留,宿主的 `SOURCE_KINDS` 一个字没动 (仍收 `kind: loki`,面板也仍能查一条已存在的 Loki 来源)。 **为什么**:缺的是**能力闸那一段真机验收**——见本文 §「没验到的(明写,不混进 PASS)」第一条 (临时 profile 里没有 Loki 来源,那段只有用例与冒烟覆盖)。既然没在真机上验过,就先不摆到用户面前。 **恢复**:把 `'loki'` 加回 `KIND_OPTIONS`(一行)。⚠️ **别顺手把 `KNOWN_KINDS` 一起删**:它是 `draftFromTarget` / `draftProblem` 的**有效性**判据,删了会让一条已存在的 Loki 行被静默改写成默认 类型(状态还是 loki、下拉却画成第一项,而卡片其余部分按 loki 走)——那正是本包 `D04` 的形状。 下拉靠 `kindOptionsFor(kind)` 把「已知但已不提供」的类型附在当前行末尾,避免 ``,而 `query.target` 在刚进卡片时是空串—— **没有一个 value 为空串的选项时,浏览器会回落显示第一项**。于是下拉「说」选了第一项(Docker), 面板其余部分却全按「没选来源」走(`isDocker` 为假、`selectedDraft === undefined`、分组按钮灰、 查询走 `needTarget`)。这正是本仓那条老判据的违例:**说的和画的不一样**。 **修法**: - 下拉的 value 改成**回落之后的那一项**(`chosenTarget`,与 `selectedDraft` / `queryProblem` / `queryPayload` 同一份判据),并加一个 value 为空串的**占位项**(「(先选一个来源)」,不可选); - 顺带兜住同源的另一个边:**选中的来源被删掉之后**,`query.target` 还留着那个不存在的 id—— 这时下拉与请求**一起回落**(`runQuery` 里改用回落后的 `effective` 草稿),否则会出现「下拉写着没选、 请求却打一个已经不存在的 id」(真打得出去,宿主会回「找不到来源」)。 **守卫**(三条 smoke,都做过反向验证): | 用例 | 拆掉什么会咬 | |---|---| | 刚进卡片:下拉 value 为空、第一项是占位且不可选、分组按钮是灰的、点查询**不发请求**且回「先选一个来源」 | 拆掉占位项 → 红 1 条 | | 只有一个来源:下拉就显示它,且请求里的 `target` 与它逐字相同 | 下拉 value 退回 `query.target` → 红 2 条(带上一条) | | 选中的来源被删掉:下拉回占位、查询不发、回「先选一个来源」 | 同上;`runQuery` 不认回落后的来源 → 红 1 条 | 同一类判据本包早有先例:`dockerTargetChoices` 遇到「当前值不在清单里」也是**显式给出来** (标成「(不在 Docker 面板的目标里)」),而不是让那一格静默变成别的东西。这一格的修法不同 (这里该显示的恰恰是「还没选」),出发点一样:**显示的那一项必须与真正用的那一项是同一个判断**。 #### 顺带改的一处:字段候选换成自画的浮层 + 箭头换成官方那颗 chevron(2026-10-13) **怎么发现的**:维护者贴了候选浮层的图,两句话——「这个样式优化一下」「图标要和官方的一致」。 **为什么必须自画**:`` + `` 那个浮层是**浏览器画的**,CSS 一点都改不动 (字体、行高、圆角、投影、滚动条全是平台的)。一张卡片里和宿主自己的下拉并排,一眼看出是两个东西。 **官方配方从哪来(可复核)**:读**宿主自己的构建产物**,不靠猜—— `…/@deepseek-ai/dsh-web-frontend/dist/assets/index-*.css` 里的菜单模块(`._list_4ub78_7` / `._item_4ub78_90`): | 件 | 官方取值(逐条抄进本卡片) | |---|---| | 浮层 | `padding:4px` · `border:0` · `box-shadow:var(--dsw-elevation-prominent)` · `--dsw-elevation-stroke-color:var(--dsw-alias-border-l1)`(那层描边在投影里,不是 `border`) · `top:calc(100% + 4px)` · `z-index:100` · `border-radius:var(--dsw-radius-lg)` | | 行 | `min-height:34px` · `padding:6px 8px` · `border:none` · `border-radius:var(--dsw-radius-md)` · `background:transparent` · `font-size:13px/line-height:20px` · hover 与聚焦都用 `var(--dsw-alias-interactive-bg-hover)` | | 箭头 | `IconChevronDownOutlineRegular`:`viewBox 0 0 16 16`、14×14、`stroke-width:1`、`fill="none"` + `stroke="currentColor"`、路径 `M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6`(取自同目录的 `index-*.js`) | > 官方那条 `list` 规则自己**没写背景**(它的祖先给)。这里按本卡片既有层级用 `--dsw-alias-bg-layer-2` > ——这一处是**我们的选择**,不是抄来的,写在这里免得下一个人以为是逐字照抄。 > > 本卡片原来那颗箭头是**另一套图标的填色 chevron**(`viewBox 0 0 14 14` + `fill="currentColor"`)。 > 现在**卡头折叠箭头与字段框箭头共用官方这一颗**(`CHEVRON_VIEWBOX` / `CHEVRON_STROKE` / `CHEVRON_PATH`)。 **换成自画的,就得把原生浮层自带的能力接回来**(不然是**悄悄降级**:鼠标能用、键盘只能一路 Tab): | 原来的(原生) | 现在的 | |---|---| | 输入时按字过滤候选 | `fieldChoices`(子串、大小写不敏感、**保持宿主顺序**、命中 0 个回空 → 界面**不画**空浮层) | | 方向键在候选里走 | `pickMove`(**到头就停,不绕回**)+ 当前行高亮(`ll_pickItemActive`,与 hover 同一个底) | | 回车选中 | 回车取**当前那一行**(没挪过就是第一条) | | Esc 关掉 | Esc 收起 | | 点一行即填 | 点一行即填(行自己 `preventDefault` 保住输入框焦点;失焦用 `relatedTarget` 判「还在不在这一格」) | **顺带清掉一个隐患**:原生 `datalist` 要靠一个**全局唯一 id** 关联,同一页挂两张卡时得给每张卡 生成一个(原来的 `fieldsListSeq`)。自画的浮层没有 id,那一套连同注释一起去掉了。 **守卫**(`client-smoke` 这几条都会咬人,逐条做过反向验证): | 拆掉什么 | 谁红 | |---|---| | `fieldChoices` 不按输入值过滤 | pure「子串过滤」红 + smoke「筛候选」红 2 条 | | 命中 0 个也画浮层(只看 `pickOpen`) | smoke「命中 0 个不该画一个空浮层」红 | | 换回旧那颗填色 chevron | smoke「官方那颗 chevron」红 | | 失焦就收(不看 `relatedTarget`) | smoke「焦点还在格子里不许收」红 | | `pickMove` 不夹取(越界照给) | pure「到头就停」红 + smoke 方向键那条红 | | 回车死取第一条(不看当前行) | smoke 红 | | 当前行不高亮 | smoke 红 | **门槛**:`client-smoke` **23/23**(上一格 20 条 → 把原 datalist 那条换成 3 条候选浮层的 + 1 条箭头, 现 23);本包单测 **72 条**(+5:`fieldChoices` 4 条 + `pickMove` 1 条);`client-lint` 通过 (样式变量 106 处、自定义属性 107 处、圆角 16 处、排版 23 处)。 **真机验收**(真宿主 + 真 Chrome;隔离方式同上,临时 profile `loglens-pick-20261013` 跑完即删): | 步骤 | 实测 | 判定 | |---|---|---| | 刚进卡片 | DOM 里「来源」下拉是 `option "(先选一个来源)" [disabled] [selected]`(**上一格 D04 的修法在真机上也对**) | PASS | | 点「有哪些字段」 | 浮层直接摆出来,`document.querySelectorAll('.ll_pickItem').length === 80`(宿主的默认上限) | PASS | | 浮层外观 | 截图:4px 内边距 + `radius-lg` + 发丝描边投影 + 34px 行高 + 首行 hover/当前行高亮,与宿主自己的下拉一致 | PASS | | 输入框右侧箭头 | DOM 读出 `viewBox="0 0 16 16"`、`stroke-width="1"`、`d="M4 6L7.29289…"`、`stroke="currentColor"`、**无 `fill`** | PASS | | 方向键 | 按两下 `ArrowDown` → `.ll_pickItemActive` = `log.file.path`(第 3 行),且 `document.activeElement` 仍是那个输入框(光标没跑、浮层没关) | PASS | | 回车 | 输入框的 value 变成 `log.file.path`、浮层收起 | PASS | 产物(三张截图 / DOM 快照)含内网标识,**移出仓库**到 `/tmp/loglens-pick-20261013/`。 #### 再改一处:字段候选改成**点开即拉**,「有哪些字段」那个按钮去掉了(2026-10-13) **怎么决定的**:上一格的截图贴出去之后,维护者问「字段做成点击下拉选自动 loading 出来是不是更好」。 是——原生 `` 本来就是点开就有,而上一格留下的「先按按钮 → 再点输入框」是**两步做一件事**, 而且「按了按钮之后候选藏在输入框里」这一步还要用户自己猜。 **改成什么**: | 之前 | 现在 | |---|---| | 一个「有哪些字段」按钮负责拉,结果只有一句「字段清单 80 个」 | **点开那一格就拉**(`openPick`):浮层先画出来、里面是「加载中…」,拿到候选原地换上去——点了绝不会「像没反应」 | | 每次点开都打一枪后端(如果每次都拉)或永远不刷新(如果拉一次就一直用) | 缓存**按来源**(`fieldFetchNeeded`:没拉过 / 换了来源才拉),页脚留一个「**刷新**」——字段会随新数据长出来,必须留口子 | | 拉失败走卡片顶部那条错误行(和查询失败混在一起) | 失败**落在浮层里**(「字段清单取不到:宿主原话」),不动卡片顶部、不动结果列表——字段候选是附加信息 | | 空清单只有一句旁注 | 浮层里四种状态各有一句话:在途 / 失败 / 这个来源没有字段名 / 有候选;**只有「输入的字筛不出候选」才不画**(那是用户自己在收窄) | | 那一行是「字段 + 两个按钮 + 旁注」四列 | 变成「字段 + 一个按钮 + 说明」三列 | **守卫**(5 条反向验证,逐条实跑): | 拆掉什么 | 谁红 | |---|---| | `fieldFetchNeeded` 永远 true(不缓存) | pure 红 + smoke「同一个来源不重拉」红 | | `openPick` 不真的拉(只开浮层) | smoke 5 条红(点开就拉 / 缓存 / 失败 / 筛候选 / 点一行) | | 失败也写缓存(`setFieldsSource` 也进 catch) | smoke「失败不留缓存,再点开要重试」红 | | 空清单那一支永远不走 | smoke「空清单也要开浮层说清楚」红 | | 在途时不画「加载中…」 | smoke「点开就拉」红(它先断言这一格) | | 去掉在途闩(`fieldsFlightRef`) | smoke「一次点击只打一枪」红(真机抓到的那一处) | **门槛**:`client-smoke` **27/27**(上一格 23 条 → 字段候选那几条改写 + 新增缓存 / 刷新 / 失败 / 空清单 / 一次点击只一枪); 本包单测 **73 条**(+1:`fieldFetchNeeded`);`client-lint` 通过(样式变量 113 处、自定义属性 114 处、 中性边框 10 处仍全是 0.5px);`check-i18n` **103 条键**(去掉 `btn.fields` / `btn.fieldsLoading` / `msg.fieldsLoaded`,加上 `btn.refreshFields` / `error.fields`)。 > 上面那一节(顺带改的一处)里「点「有哪些字段」」的写法**保留原文**——它记的是那一天的流程, > 这一节是它的后一轮。要照现在的界面操作,看这一节。 **真机验收**(真宿主 + 真 Chrome;临时 profile `loglens-open-20261013` 跑完即删,源 profile 的 sha256 与 mtime(18:21:39)跑前跑后逐字一致): | 步骤 | 实测 | 判定 | |---|---|---| | 选来源后**点一下**那一格 | 面板当场打开、里面是「加载中…」(同一个 evaluate 里读到的:`{open:true, text:"加载中…", rows:0}`) | PASS | | 1.2 秒后 | `rows: 80`;页脚 = 宿主的截断警告原话 + 「刷新」(`{foot:"只列了前 80 个(这个来源共有 102 个字段):…刷新", refresh:true}`) | PASS | | 关掉再点开(同一来源) | `/fields` 的 resource 计数**没变**(还是那一枪) | PASS(缓存真的生效) | | 点页脚「刷新」 | 计数 +1 | PASS | | **第一版抓到的一处**:第一次点开 | `/fields` 的计数 **+2**——一次鼠标点击**先 focus 再 click**,两条路都走到 `openPick`,而 `fieldsSource` / `fieldsBusy` 都是这一轮渲染的旧值,于是白打了两枪 | **FAIL → 已修** | | 修完再点一次 | 同一个读数变成 **+1**(用 ref 做在途闩:`fieldsFlightRef`,同一个来源在途直接 return;失败也放开,好让下次重试) | PASS | **这一处(点一下打两枪)是这一格唯一的新缺陷,也是 smoke 用例看不见的那类**:桩里 `onFocus` 与 `onClick` 是被分开调的,只有**真浏览器**才会一口气把两条都发出来。修法与用例都在: `client-smoke` 新增「一次点击(focus 与 click 两条路)只打一枪 /fields」(拆掉在途闩 → 红)。 产物(截图 / DOM 快照)含内网标识,**移出仓库**到 `/tmp/loglens-open-20261013/`。 > 上面那一节记的是 2026-10-13 那一刻的界面(不截断之前:候选 80 行、页脚是宿主那句截断警告)。 > 要照现在的界面写用例,看下面这一节。 ### 字段清单不再截断(对齐 Kibana 的长度处理)(2026-10-14) 维护者问「字段不放全是出于什么考虑的」,把 Kibana / Elasticsearch 的做法摆出来对比之后,指令是 **「对齐 长度处理」**。查 Kibana 那一侧(`data_views` 的 `_field_caps` 通路)读到的口径是: | Kibana 侧 | 事实(读了它的源码与文档) | |---|---| | 请求 | `fieldCaps({ fields: ['*'](默认), include_empty_fields: true, ignore_unavailable: true, … })`——要全量,还主动要「空字段」 | | 响应 | `readFieldCapsResponse` 里**没有 slice**:一条不漏地读成 `FieldDescriptor`;同名不同类型标 `type: 'conflict'` + `conflictDescriptions`;收尾剔掉 `object` / `nested` 容器行 | | 规模兜底 | 不是 Kibana 兜的,是 ES 的 `index.mapping.total_fields.limit`(默认 **1000** 个 mapper/索引,含 object 中间节点、`.keyword`、别名、runtime field) | | 界面 | 字段清单分 Selected / Available / Popular / Empty / Unmapped 区 + 字段名搜索 + 虚拟滚动;「空不空」是**数据侧**信号(来自命中样本),与映射清单分开 | | 它真截的地方 | 文档摘要(`discover:maxDocFieldsDisplayed`)超了会打 badge 告知(PR #119744,v8.1.0)——与我们的 `total` / `truncated` + warning 同一条判据 | **结论与落地**:`_field_caps` 从来不 slice,长度交给界面消化。我们这边对应的是 「默认不截断 + 本地子串筛 + 滚动 + 把总数钉在页脚」——**只有调用方显式给 `limit` 才截**: | 动作 | 落点 | |---|---| | 不传 `limit` = 不截断(来源有多少列多少);`limit` 退化成**可选的显式收窄**,正整数校验仍在、**硬上限取消** | `execute.ts` 的 `runFields`(删掉 `FIELDS_DEFAULT_LIMIT` / `FIELDS_MAX_LIMIT`) | | ES 适配器同样不 slice(`ES_DEFAULT_FIELD_LIMIT` / `ES_MAX_FIELD_LIMIT` 一并删掉);顺序、去内部字段、去 `.keyword` 子行三条不变 | `elastic.ts` 的 `fields()` | | Loki 侧显式 `limit` 才截(形状与 ES 同);`FieldsOptions` 的注释改为「不传 = 不截断(Kibana 同口径)」 | `loki.ts` / `types.ts` | | 工具面:描述里写明**默认全列**,并给出成本提示(通配全集群可能上千个字段、响应几十 KB,要窄自己传 `limit`) | `tools.ts` 的 `loglens_fields` | | 面板:依旧是**不传 limit**(拿全量);页脚报**宿主的 `total`**「共 N 个字段」+「刷新」 | `client-src/index.js`(新键 `hint.fieldsCount`) | | 面板浮层改成**两段式**:候选那层自己滚(`.ll_pickScroll`),页脚在它外面钉住 | 同上;`.ll_pickFoot{flex:none}` | **为什么把页脚钉出来**:不截断之后候选可能有上百行(真机 102 行、滚动区 3468px)。页脚要是跟着滚, 就得先滚到底才看得见「共 102 个字段」——那等于没报,正是这一格最怕的那类「看着像全的」。页脚钉住 + 候选自滚,长度信息就不被长度本身埋掉(Kibana 把计数放在分区头上,是同一个判据)。 **用例**:`elastic.test.ts` **+1**(120 个字段、不传 limit 全列、truncated 恒 false——谁把默认上限加回来就红); `tools.test.ts` 那两条重写(不传 = 120 个全给且**无警告**;显式 `limit: 50` 才截、`total`/`truncated`/警告都在, `limit: 0` 与 `1.5` 仍拒绝);`client-smoke` **+2**(页脚报宿主的 `total` 而不是本地行数、不截断时没有任何截断警告; 宿主真报截断时照抄那句警告、且不再重复条数)+ 页脚两段式守卫(页脚/「刷新」不许落在 `.ll_pickScroll` 里)。 **反例(拆掉判据必须变红,逐条实跑过)**: | 拆法 | 变红的用例 | |---|---| | ES 适配器装回「默认 80」 | `elastic.test.ts > 不传 limit 就是不截断:120 个字段全列` | | 执行层恢复「默认 80」 | `tools.test.ts > 不传 limit 就是不截断:120 个字段全给` | | 页脚改成本地清单长度(丢掉宿主的 `total`) | `client-smoke > 候选浮层页脚:报宿主给的字段总数` | | 页脚永远显示条数(吞掉宿主警告) | `client-smoke > 宿主真报截断时,照抄那句警告` | | 警告里去掉「去掉 limit 就是全部」 | `tools.test.ts > 传了 limit 才截` | **门槛**:本包单测 **319 条**(原 317);`client-smoke` **29/29**(原 27);`client-lint` 通过 (样式变量 113 / 自定义属性 114 / 中性边框 10 处仍全是 0.5px);`check-i18n` 本包 **104 条键**(+`hint.fieldsCount`); 全仓 `typecheck && build && test && aggregate` 绿:**170 文件 / 2879 用例**(原 2877)。 **真机验收**(真宿主 + 真 Chrome,端口 3199;临时 profile `loglens-len-20261014` 跑完即删; 源 profile `test-logs/cordis.patch.yml` 的 sha256 `d79097fc…` 与 mtime(2026-10-13 18:21:39)跑前跑后逐字一致): | 步骤 | 实测 | 判定 | |---|---|---| | 选来源后点一下那一格(冷启动第一次) | `.ll_pickItem` **102 个**(此前是 80);`/fields` 资源计数 **1**(在途闩仍然只让打一枪) | PASS | | 响应本体(页面内直接 fetch) | `{fields: 102, total: 102, truncated: false, warnings: [], bytes: 6029}`——**不再有截断警告** | PASS | | 页脚 | `{text:"共 102 个字段\n刷新"}`;`footRect` 887→916 在 `.ll_pickScroll`(708→883,scrollHeight 3468)**外面**,不滚就能看见 | PASS | | 输入框里打 `name` | 候选 102 → **11**(本地子串筛),**没有新请求**(计数仍 1);页脚仍「共 102 个字段」 | PASS | | 关掉再点开(同来源) | `/fields` 计数**不变**(缓存生效) | PASS | | 点页脚「刷新」 | 计数 **+1** | PASS | | 显式收窄(页面内 fetch `{limit: 5}`) | `{fields: 5, total: 102, truncated: true}` + 警告「只列了前 5 个(这个来源共有 102 个字段):…去掉 limit 就是全部…」——截断信号只在**真截**时出现 | PASS | **产品代价(写下来,别当没有)**:这台来源的字段表是 6.0KB / 102 个字段,全量拉没有感知; 真机历史上那台「全集群通配」的来源是 **45KB**(`elastic.ts` 注释里那份读数),那种来源现在每次点开会 实打实拉 45KB——与 Kibana 付的是同一笔账,换来的是「候选里不会少字段」。真要收,可以给面板加一个 「只拉前 200 个」的开关(`limit` 通路已经在了),但那得先有人提。 ### 追加:冲突字段与容器行(对齐 Kibana 的第二刀)(2026-10-14) 上一节末尾列了「没对齐的两处」(类型冲突 / `object`·`nested` 容器行),维护者回「1、2 也对齐」。 先把**事实**量出来再动手(直接打那台来源的 ES,`*,-.*`): | 探测 | 实测 | |---|---| | `_field_caps?fields=*` 回来的名字 | **200 个** | | 其中 `object` 容器行 | **10 个**:`input` · `container` · `container.labels` · `agent` · `host.os` · `log.file` · `container` · `container.image` · `ecs` · `host`——**它们没有自己的值**,配对时列出来只会让人选一个「点了没结果」的字段 | | 多类型字段 | **1 个**:`container.labels.org_label-schema_build-date` = `date` + `text`,且两种类型都带 `indices`(`_field_caps` 的 `indices` 只在多类型时出现——Kibana 的注释也是这么写的) | 落点: | 动作 | 落点 | |---|---| | ES 原生类型 → **宽类型族**(Kibana `castEsToKbnFieldTypeName` 的判据,认不出的归 `unknown`):**同族的多类型不算冲突**(`text` + `keyword` 都是字符串,Kibana 原话 "ignore the conflict and carry on");**异族才算**,标 `conflict: true` + `esTypes` + `conflictDescriptions`(每个类型各在哪些索引) | `elastic.ts` 的 `ES_TYPE_FAMILY` / `esTypeFamily()` + `fields()` | | `object` / `nested` **容器行不列**(Kibana 收尾也剔):判据是「**所有**类型都是容器」——既当过容器又当过叶子的名字不剔(那是字段本身的问题,不能顺手藏掉);`total` 只数可列的 | `elastic.ts` 的 `isContainerRow()` + `fields()` | | 契约加三个**可选**字段(只有一个类型时不带 `esTypes`/`conflict`:那时候 `kind` 已经说完了,带上只是把载荷撑大) | `types.ts` 的 `FieldInfo`;`execute.ts` 的 `runFields` **原样搬运**(在这一层丢过就不是原样) | | 工具面:output schema + 渲染 + 描述——冲突字段逐行标「**类型冲突**:date / text」,末尾再给一句合计 | `tools.ts`(渲染抽成 `renderFields`,与 `renderLines` 并排,可直接测) | | 面板:候选行尾标「类型冲突」(宿主 warn 色),`title` 列两种类型;`fieldChoices` 把冲突信息一路带到行上(普通候选仍只有 `{ name }`) | `pure.js` / `client-src/index.js`(新键 `badge.conflict`,用的是**已有的** `badge.` 域——`tag.` 域不在白名单里,闸门当场拦下了) | **真机验收**(同一台来源、同一临时 profile,改动前后各跑一次;源 profile 指纹同上一节): | 读数 | 改动前 | 改动后 | 判定 | |---|---|---|---| | 候选行数 | **102**(含 10 个容器行) | **92**;`containerRowsLeft` 为空数组 | PASS | | 页脚 | 共 102 个字段 | **共 92 个字段** + 刷新(`total` 与列举一致) | PASS | | 冲突字段 | 长得跟别的候选一样 | `container.labels.org_label-schema_build-date` 行尾标 **类型冲突**,`title` = `…(类型冲突:date / text)` | PASS | | 输入框打 `build-date` | — | 两行并排:`container.labels.build-date`(不标)与上面那个(标)——同族/异族的差别一眼可见 | PASS | | `/fields` 调用次数 | 1 | 1(缓存与在途闩都没被这次改动碰坏) | PASS | **用例**:`elastic.test.ts` **+3**(异族标冲突 + 同族不标 + 容器行不列且 `total` 一致);`tools.test.ts` **+4**(执行层不许丢 esTypes/conflict/conflictDescriptions;`renderFields` 三条:标记 + 无冲突不多说 + 截断与警告照旧); `client-pure.test.ts` **+1**(`fieldChoices` 带冲突信息、普通候选只有 name);`client-smoke` **+1**(冲突行有标记、普通行没有、`title` 说全两种类型)。 **反例(拆掉判据必须变红,逐条实跑过)**: | 拆法 | 变红的用例 | |---|---| | 同族多类型也算冲突(判据过宽) | `elastic.test.ts > 同族的多类型**不算**冲突` | | 从不标冲突(判据整条失效) | `elastic.test.ts > 类型不一致时标 conflict…` | | 容器行不剔 | `elastic.test.ts > object / nested 容器行不列…` | | 执行层丢掉冲突信息 | `tools.test.ts > …在**执行层不许丢**,一路到渲染` | | 渲染不标冲突 | `tools.test.ts > renderFields > 冲突字段逐个标出来…` | | 面板行不标 | `client-smoke > 类型冲突的字段**在行上**标出来…` | | `fieldChoices` 不带冲突 | `client-pure.test.ts > 冲突字段把冲突信息带到行上…` | **门槛**:本包单测 **327 条**(原 319);`client-smoke` **30/30**(原 29);`client-lint` 通过 (样式变量 114 / 自定义属性 115 / 排版 26 处均已配对);`check-i18n` 本包 **105 条键**(+`badge.conflict`); 全仓 `typecheck && build && test && aggregate` 绿:**170 文件 / 2887 用例**(原 2879)。 **仍然没对齐的**(写下来,别当成已经齐了):Kibana 还会把 `searchable` / `aggregatable` / `readFromDocValues` / TSDB 维度等一起带下来,我们只带了「类型」这一轴;它的字段清单还分 Selected / Popular / Empty / Unmapped 区 (那是**数据侧**信号,来自命中样本),我们的「空不空」只有 `loglens_facets` 那一条路可看。 ### 容器下拉也自动拉(面板第三刀)(2026-10-14) 维护者:「容器的下拉也做成自动加载。」——与字段候选「点开即拉」同一条判据:**别把「做一件事」拆成两步** (原来要先点「拉取容器」,容器下拉里才有东西)。 | 判据 | 落点 | |---|---| | 缓存键 = 来源 id + Docker 目标名(空目标也算一个确定的键;**没选来源 = 空键,永远不算已缓存**) | `pure.js` 的 `containersKey` | | 选中 Docker 来源即自动拉(`React.useEffect` + `fieldFetchNeeded`);依赖里放**键的三个来源**(id / kind / dockerTarget)而不是整个对象——对象每次渲染都是新的,用它当依赖会变成「渲染一次拉一次」 | `client-src/index.js`;`selectedDraft` / `isDocker` 两行**挪到 `view === 'summary'` 那个提前返回之前**(挂 hook 就不能在提前返回之后,否则是条件调用 hook) | | 去掉「拉取容器」按钮,加「刷新」(容器会变:起停一个清单就旧了);在途那条 option **排在最后**且 disabled(`value` 与占位项同为空的,排在最前面就等于在加载期间谎报「已经选中了它」——与 D04 同一个坑) | 同上 | | 失败**落在那一格**(新键 `error.containers`,不动卡片顶部那条错误行)、**不写缓存**(下次触发仍会重试)、并清掉清单与选中项(留着一个下拉里没有的 id 就是 D04 那一类) | 同上 | | 换目标后选中项**跟着新清单走**:旧 id 还在就留着,不在就换成第一条 | 同上 | | i18n:删 `btn.containers`(「拉取容器」),加 `btn.refresh` / `error.containers` | 105 → **106** 键 | **真机验收**(真宿主 + 真 Chrome;临时 profile 同前,源 profile 指纹跑前跑后逐字一致): | 步骤 | 实测 | 判定 | |---|---|---| | 选中 Docker 来源 | **不用点任何按钮**就开始拉;下拉当场是「容器 / 加载中…」(在途那条在最末) | PASS | | 约 7 秒后 | 下拉 **35 项**(34 个容器 + 占位),**第一条自动选中**;`/api/dsh-docker/containers` **1 次**、7.1s、18.2KB——走 SSH 的目标本来就慢,界面在途有话可说比快慢重要 | PASS | | 页面上 | 没有「拉取容器」按钮;有「刷新」 | PASS | | 点「刷新」 | 点下去 200ms 读到「加载中…」;完成后请求数 **1 → 2**,仍是 35 项 | PASS | | Docker 目标下拉 | 5 个选项(占位 + 4 个目标) | PASS(换目标重拉在 smoke 里逐条钉住,真机没再花一次 7 秒 SSH) | 容器名是内网标识,**不落仓库**(真机截图移到 `/tmp/loglens-dk-20261014/`)。 **用例**:`client-pure.test.ts` **+4**(键的四种输入:同键 / 换目标 / 换来源 / 空键);`client-smoke` **+4** (自动拉 + 第一条自动选中 + 没有按钮 / 同键不重拉 + 换目标重拉 + 在途那条不顶到最前 / 失败只落那一格且不留缓存 / 「刷新」强制重拉)。 顺带把 harness 里「找来源下拉」那段抽成 `sourceSelect()`(新用例要用,别抄第二份)。 **反例(拆掉判据必须变红)**——这一轮有两条**第一版没咬住**,值得记下来: | 拆法 | 结果 | |---|---| | effect 空转(不自动拉) | 红:`自动拉容器` | | 缓存判据失效(每次都拉) | 红:`自动拉容器`(同一目标被拉第二次) | | 失败也写缓存 | **第一版没咬住** → 改测法后红 | | 失败落到卡片顶部错误行 | 红:`失败只落在那一格` | | 在途那条挪到最前 | **第一版没咬住** → 改成整段替换后红 | | 「刷新」不重拉 | 红:`「刷新」强制重拉` | | 键忽略 Docker 目标 | 红:`换目标重拉`(+ `containersKey` 的 pure 用例) | | 换目标后不换选中项 | 红:`选中项跟着新清单走` | 两条没咬住的原因(守卫的教训): ① 「失败也写缓存」第一版用**换目标**去验——换目标本来就换了键,缓存写没写都会重拉,等于没验;改成 「换来源走开、再选回这条 Docker 来源」才把两种情况分开。 ② 「在途那条排到最前面」第一版只在**末尾前多插了一条**(原来那条还在)→ 断言仍然成立;要**整段替换**(真挪位)才咬得住。 **门槛**:本包单测 **331 条**(原 327);`client-smoke` **34/34**(原 30);`client-lint` 通过; `check-i18n` 本包 **106 条键**;全仓 `typecheck && build && test && aggregate` 绿:**170 文件 / 2891 用例**(原 2887)。 ### 容器下拉:还没有选项的那几秒要有过渡(面板第四刀)(2026-10-14) 维护者两句话:「这个文案优化一下」(真机截图中「(先选一个来源)」被原生箭头切成「(先选一个来源」), 「下拉选没有拉取到容器选项的时候,要有过渡」。 | 判据 | 落点 | |---|---| | **占位项 = 过渡态本身**:原先把「加载中…」挂在**最末一条** option 上,于是「正在拉」的那一秒下拉**依然显示上次那句占位**——看起来就是一个没人碰过的空下拉。现在第一项(那个空 value 的占位项)自己说状态:`加载中…` / `加载失败` / `没有容器` / 默认 `容器` | `client-src/index.js` 的 `containerSlotText()` + `containerSlot`;判据全在 `pure.js` 的 `containerSlotState` | | 优先级 **有清单 > 失败 > 在途 > 空清单**:有清单一律回默认(换目标 / 刷新时旧清单还摆在眼前,把占位项改成「加载中…」等于替用户扔掉看得见的东西,那几秒他是有**上一条目标**的选项的);**失败压在在途前面**(失败时故意不写缓存,`pending` 一直为真——顺序放反,一次失败会让这一格永远写着「加载中…」);插件不在时回默认(请求根本发不出去,写「加载中…」就是永远不落地的谎) | `pure.js` `containerSlotState` | | `pending` **自己算**(不是等 `busy`):它在 `busy` 翻起来之前就为真(effect 还没跑、响应还没回来的那一帧),靠它这一格从**第一帧**起就是「加载中…」,而不是先闪一下「没有容器」再改口 | `index.js` `containersPending` | | 「正在拉」这句话另有一个落点:**「刷新」按钮自己在途时写「加载中…」**(与旁边「看日志」同一个写法)。占位项要留给「下拉此刻显示哪一项」 | 同上 | | 「(先选一个来源)」→ 「选择来源**: 占位项是一句话加括号(9 字 117px)放不进最窄那列(去 padding 与原生箭头只剩约 66px),被硬切成一半。改成 4 字短标签;「还要先做什么」由旁边那条错误行说(`error.needTarget` 就是原话) | i18n `option.pickTarget` | | 输入框 / 下拉加 `text-overflow:ellipsis`:装不下时**硬切**会切掉半个字,省略号至少说明「后面还有」 | `.ll_input` | | 空清单的 hint:`hint.containersEmpty`(**只列运行中的**——请求带的是 `all:false`)否则「没有容器」会被读成这个目标上什么都没有 | `index.js` + i18n | | i18n:加 `msg.containersEmpty` / `msg.containersFailed` / `hint.containersEmpty` | 106 → **109** 键 | **宽度预算的看守**(文案是下次改动里最长胖的东西,它只要读起来通顺就行,没人会去量): | 判据 | 出处 | |---|---| | 一句占位 / 过渡文案放得进最窄那一列:预算 **5 个汉字**(`.ll_queryGrid` 第一列最窄 110px − 左右 12px padding − 约 20px 原生箭头 ≈ **66px**;`.ll_input` 13px 全角字一字 13px → 5 字 65px) | `client-smoke.mjs` `assertNarrowLabel` + `labelUnits`(全角 / CJK 标点算 1,拉丁与空格算 0.5) | | 「正在拉」这句话不许**只**落在占位项上:有旧清单时占位项保持回默认 | `Docker 区:同一目标不重拉…` 那条 | **真机验收**(临时 profile `loglens-len-20261014`,源 profile 指纹跑前跑后逐字一致): | 步骤 | 实测 | |---|---| | 选中 Docker 来源的第一帧 | 占位项就是「**加载中…**」(不动一下再改口),下拉禁用、「刷新」显示「加载中…」 | | 约 7 秒后 | 占位项回到「**容器**」,第一条容器自动选中 | | 刚进卡片、未选来源 | 「来源」下拉四个字完整为「**选择来源**」,末尾不再缺半个字 | | 空清单目标 | 占位项「**没有容器**」,下面 hint:「这个 Docker 目标上没有运行中的容器(这里只列运行中的)」。真机上没有这种目标,这条**用语法夹具(`containers: []`)逐字钉住** | | 拉失败 | 占位项「**加载失败**」+ hint 里宿主原话。同样**只能用夹具**(真机上那条 SSH 目标是通的) | **用例**:`client-pure.test.ts` **+5**(`containerSlotState`:有清单 / 三态优先序 / 失败压倒在途 / 插件不在 / 垃圾入参); `client-smoke` 36 条里 4 条在本轮新写或改写(过渡态那一帧**不用 `runPanelQueryFull`**——那个助手会把每件事 settle 到稳定,正好吃掉要看的那一帧,所以手动挂载、自己控制 tick)。 **反例(拆掉判据必须变红)**: | 拆法 | 结果 | |---|---| | `containerSlotState` 永远回 `'default'` | 红:占位项不写状态(`加载中…` / `没有容器` / `加载失败` 三条一起红) | | 优先级改成「在途 > 失败」 | 红:失败态写着「加载中…」(`containerSlotState` 单测里那条 `pending: true, busy: false, error`) | | 去掉 `pending`、只信 `busy` | 红:第一帧占位项还是「容器」(`清单还没到的那几秒…` 那条) | | 占位项只在**末尾**加过渡 option | 红:第一帧显示的仍是默认那句(`assert.equal(treeText(slot), '加载中…')`) | | 占位项长回「(先选一个来源)」 | 红:`assertNarrowLabel`(宽度预算) | | `flex`/`disabled` 判断丢掉 | 红:默认态被误禁用 / 过渡态反可选 | ### 点柱放大:直方图上点一根柱 = 把窗口夹到那一档(面板第五刀)(2026-10-15 收口) 维护者在乎的那句话是「点柱放大有接线与用例」——这一刀把上一轮**只写了一半**的实现收口: 柱子已经改成 `