What to build: 建立统一新鲜度模型。连续两个配置周期未成功更新时,页面显示数据不可用或已过期,并保留最后一次成功数据但不伪装成实时数据。
Blocked by: 15
Status: resolved Labels: ready-for-agent
2026-09-24 真机 DOM 走查完成(http://127.0.0.1:8001/#/cockpit):A–E 五组逐项通过,
逻辑闸门 43/43、20 个逻辑闸门全量回归绿。走查过程中抓到一个验收名存实亡的缺口并补上
(「部分失败」原本是没人消费的死代码),详见下文。
data-freshness=current、data-freshness-at=16:59:33、
文案「更新 16:59:33 · 数据新鲜」。current,第 2 个转 stale,
retained=true、仍显示 10:00:20(20 秒前),原因是「状态推送未连接」。
「连续两个」是连续不是累计:中间成功一次就清零(逻辑闸门 C4 守住)。unavailable、retained=false、
时刻显示占位符 「—」(不编一个 00:00:00 冒充真实时刻)——与「过期」明确分开。failed → 顶栏 data-freshness-overall=partial,
界面真的画出「· 部分数据过期或不可用」。这条是跨分区性质:单块面板只会说自己那一份。/cockpit 前 freshnessPolling()=true、离开后 false 且外壳已卸载
—— 定时器不跟着页面跑下去(票 15 刚在「漏取消定时器」上栽过)。pageerror/console.error 全程为空)。环境备注:本机
@puppeteer/browsers是空目录,t16-freshness-walkthrough.mjs起不来 (与 t28 同一环境问题),故上述 A–E 用内建浏览器驱动同一批断言完成;脚本已保留成可复跑形式。
决策地图票 07 的 Q3 把「WebSocket 失败降级轮询与新鲜度语义」判给了本票,但票面没定义 「一个周期」是什么。定下来的三条:
runtime.refreshSeconds(默认 30 秒),
每个周期做一次快照刷新;取数成功且 WS 可用才算这一周期成功 —— 只取数成功但推送断着,
说明这条链路的"实时性"已经没了,那正是要如实告诉值班人员的事。STALE/UNAVAILABLE
与 sectionView 文案(不造第二套词汇),另在顶栏加一处「更新 HH:mm:ss · 四态」胶囊。__COCKPIT_FRESHNESS_FAIL__ 演示持续失败。domain/freshnessModel.js(新):统一新鲜度模型的纯函数层 —— 四态判据
(CURRENT/STALE/EMPTY/UNAVAILABLE)、连续两个失败周期即过期、
时间与年龄文案(缺失一律占位符)、跨分区的 overallFreshness()(partial 是独立状态)、
以及与分区状态/后端 freshness 的显式翻译(各只有一处)。services/freshnessService.js(新):刷新周期与轮询。时钟、定时器全注入;
startPolling 启动即跑一轮(否则首屏要空等一个周期);stopPolling 必须清干净;
recordExternal 供推送到达等事件驱动路径记账。stores/runtimeStore.js:ADR 0004 明确把「最后成功刷新时间、过期状态」归它 ——
新增 freshnessState/freshness/retained/freshnessSectionState 与
startFreshness/stopFreshness/freshnessPolling/recordFreshness;
推送到达即记一次成功(否则一个安静的推送周期会被误判成过期);
WS 可用性取自本 store 的连接状态(方向不反:service 不认识 store)。services/cockpitConfig.js:新增 runtime.refreshSeconds(默认 30,正数校验 ——
0/负数会让"两个周期"永远不成立)与 runtime.polling。CockpitShell.vue:顶栏新鲜度胶囊(data-freshness / data-freshness-at /
data-freshness-overall / data-freshness-partial 是走查断言面)+ 启动/停止周期。verify/t16-freshness.mjs(新,43 项逻辑闸门)、verify/t16-freshness-walkthrough.mjs(新,真机走查)。HH:mm:ss + 「多久之前」,两者任一缺失都显示占位符
(不是 00:00:00,那看起来像一个真实时刻)。partial(部分失败)是独立状态,一对一全坏才算整体不可用。EXPIRED_MISSED_CYCLES 一致);
第 1 个周期失败仍是新鲜、第 2 个才过期;中间成功一次即清零(连续不是累计);
从未成功过 → 不可用(没有旧值可留);WS 断线与取数失败两条路都进同一本账。lastSuccessAt 不变、
retained=true,界面带 is-stale(琥珀色)与「显示最后一次成功数据」。node .scratch/cockpit-shell-panel-layout/verify/t16-freshness.mjs → 43/43(七段:
最近成功时间 / 四态可区分 / 连续两个周期 / 过期保留旧值 / 不显示虚假零值 / 刷新周期与清理 / 静态守卫)。lastSuccessAt(丢失旧值)→ D1/D2 红;
③ 缺失年龄回「0 秒前」→ A2/E5 红(这条第一版没红 —— 暴露了一个假的通过,见下);
④ WS 断线不算失败周期 → F4/F9 红。yarn build(AGENTS.md)。node .scratch/cockpit-shell-panel-layout/verify/t16-freshness-walkthrough.mjs
A 顶栏画出「更新 HH:mm:ss · 四态」;B 连续两个失败周期 → stale + is-stale;
C 从未取到 → unavailable + 时刻「—」;D 坏一块分区 → data-freshness-overall=partial;
E 离开 /cockpit 后 freshnessPolling() 变 false。
(本机 @puppeteer/browsers 为空目录时脚本起不来,改用内建浏览器驱动同一批断言。)
overallFreshness() 写在
domain 里、逻辑闸门也测了它,但它没有任何界面消费 —— 于是验收第 2 条点名的
「部分失败状态可区分」实际上名存实亡(纯函数绿着,值班人员看不到)。
真机走查时才发现:界面上根本找不到"部分失败"这几个字。已修:外壳把各分区状态收上来交给
overallFreshness(),顶栏胶囊在 partial 时补一句「· 部分数据过期或不可用」;
并补 G9/G10 两条守卫(断言它真的被界面消费,而不只是存在于 domain)。
—— 又一次「纯函数绿 ≠ 用户看得见」。domain/freshness.js 的 dataAgeText,
而 freshnessModel.js 的 sinceSuccessText 走的是另一条路、当时没有断言;
把它的占位符改成「0 秒前」时闸门仍 40/40 全绿。已补 E5 覆盖两个年龄入口,
复测确认变异会红(A2+E5)。tick 当前重取一份快照(mapStore.refresh())。四块面板的
周期性重取留给后续需要时接上同一本账(runtimeStore.startFreshness({ tick }) 就是那个接口)——
本票重点是统一新鲜度模型与两个周期的判据,不是把每个端点都挂上轮询。freshness 与前端统一模型是两件事:后端给了 missedCycles/freshness 时
仍以它为准(票 05 的 isExpired()),本层负责"前端自己观察到的那一份"
(WS 断了、轮询失败了,后端可能根本推不过来)。两者用 viewFromBackendFreshness() 桥接。ping/pong 未接。统一新鲜度模型落地:连续两个配置周期未成功更新即判过期(有旧值)或不可用(从未取到), 过期保留最后已知值且明确标注、绝不伪装成实时;最近成功更新时间可见;缺失值一律占位符。
domain/freshnessModel.js 纯函数 —— 阈值 2(与后端 missedCycles >= 2 同口径)、
连续而非累计(任何一次成功清零)、从未成功过则落「不可用」而不是「过期」
(因为没有旧值可留,这两件事必须分开说)。services/freshnessService.js 的轮询,runtime.refreshSeconds 给长度(默认 30 秒);
一个周期取数成功且 WS 可用才算成功(维护者口径:WS 断线也算失败周期);
时钟与定时器全注入,所以"两个周期"能同步断言、也能现场演示。is-stale;
跨分区的「部分失败」也在这枚胶囊里补一句(判据来自 domain,界面不数数)。null 就显示「—」,不编 00:00:00;年龄缺失显示「—」,
不回「0 秒前」;后端真给 0 照实显示。验证:逻辑闸门 43/43 + 四个变异测试有牙 + 真机 A–E 五组逐项通过 + 20 个逻辑闸门全量回归绿。 走查抓出「部分失败」是死代码(验收名存实亡)并补上消费与守卫。