From 74f5a9326ca4666ad7eef1e22753c120ade5719a Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 14:11:14 -0400 Subject: [PATCH 1/2] fix(nodes): one fault order, add all and un-hide all can't double-run or stop early, dialogs show the current board (#153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fix-track chunk 4 (#133). It covers the items X6 / R8, R6, N4, R9 / R10 and C5, as decided on 2026-10-16. The new-board state and event-log items (D1 / X3 / R2, S6, X8 / C6, D8 / S5 / C4) are in a separate PR. Nothing else changes. ## 1. X6 / R8: one fault order | | Today | After | |---|---|---| | **Order** | The Log listed a board's own flags as bus-off, error-passive, degraded, overheat. The Cockpit listed them as bus-off, fault, overheat, error-passive. | The Cockpit's order everywhere: bus-off, fault, overheat, error-passive. | | **Owner** | Three hand-written sequences (`views.ts`, `panels.ts`, `adopt.ts`) and the log's walk over `STATUS_FLAGS`. | `nodes/faults.ts NODE_FLAG_ORDER` and its helpers (`raisedNodeFlags`, `nodeFlagWords`, `nodeFlagChips`, `NODE_FLAGS`). The log (`node-events.ts`), the tile and Attention (`views.ts`), the status boxes (`panels.ts`) and the Adopt evidence (`adopt.ts`) all read them. | | **Vocabulary** | `events/status-flags.ts STATUS_FLAGS` was an ordered list. | It is now a record keyed by flag, with no order. `gateway-health.ts` looks labels up by key and keeps its own documented Gateway order. | The PDU flag text in `faults.ts` was written out twice: once for the Cockpit and once for the log, in the same order. The Cockpit now reuses the log's tables, so its output is unchanged. ## 2. R6: Add all can't double-run | | Today | After | |---|---|---| | **Dialog** | Add all had no busy flag, so a double click sent it twice. `done` was set from a stale closure. | One busy flag for the whole dialog, lifted into `AdoptDialog`. A ref refuses a second click before React re-renders. While Add all or Adopt runs, Add all, Later and Adopt / Add to cockpit are all disabled. `done` updates are functional. | | **Main** | `addAllInTurn` could run again while one was running. | `mapping-sequence.ts MappingSequences` (NodeHub and the component mock) ignores a second Add all, or a second Un-hide all, while the same one is running. It answers `{ ok: true, done: [] }`. The two sequences don't block each other. | ## 3. N4: Un-hide all goes on and reports | | Today | After | |---|---|---| | **Main** | It stopped at the first board it couldn't un-hide and never tried the rest. | Every board is tried. The refused case of `NodeBatchResult` gains `failed`: every refusal, in order. `nodeId` and `error` are still the first. Add all still stops at its first refusal, with one entry. | | **Settings › Hidden boards** | It showed the one error. | The existing error line (`set-err`) lists each failure: `Couldn’t un-hide #8 Right_Radar: `. A single Un-hide still shows main's error alone. No new UI element. | The API guard header (`ipc.compat.test-d.ts`) records the change to the type. ## 4. R9 / R10: the dialogs show the current board and count | | Today | After | |---|---|---| | **Reset… review** | It fetched the preview again on every `nodes:model` push (roster polls, PDU pages), with no stale guard. | It fetches again only when the inputs change. The key is `settings-view.ts resetPreviewKey`: the mapping as the snapshot shows it (each board's name, console, PDU and source, the hidden ones, and the differing count). An answer that a newer fetch superseded is dropped. | | **Adopt count** | The header counted every listed row ("6 boards…"), while the button that opened it said "New on the bus · 3". | The header reads `attention.newCount`, the same field as the button. | | **Adopt board** | A new request to open the dialog (a cog, or Connection's "›") while it was open kept it on the board it was showing. | Each request remounts the dialog (`key`) on the board asked for, as the latest push has it. | ## 5. C5: an older snapshot never wins - **Main.** NodeHub stamps a rising `rev` on every model it pushes (`NodeModel.rev`, optional). The `nodes:get` answer carries the rev of the last push. - **Renderer.** `nodes/store.ts` drops a `nodes:get` answer or a `nodes:model` push that is older than the model it holds. It also drops a `cockpit:snapshot` whose rev is older than the one it holds. - **The mock** stamps revs the same way NodeHub does. - **The other consumers.** `useNodesPulled` uses its own pull only as a "data arrived" signal and writes nothing, so no change there. The mapping intents' `model` answers aren't read by the renderer. Everything the screens show comes from the snapshot, which is now rev-guarded. ## Tests changed on purpose | Test | Was | Now | Why | |---|---|---|---| | `events.test` `raises and clears bus-off, error-passive (with TEC), overheat` | bus-off, error-passive, overheat | bus-off, overheat, error-passive | X6 / R8 | | `mapping-sequence.test` `unhideInTurn … stops at the first refusal` | stops at #30, #41 not tried | goes on, lists #30 and #52 in `failed` | N4 | | `mapping-sequence.test` Add all refusal | no `failed` | `failed: [{ 7 }]` | N4 (the shape of the result) | | `node-hub.test` Add all / Un-hide all | no `failed`; read-only un-hide stopped at #1 | `failed` added; #2 is tried after #1 | N4 | | T10 `lists every new board…` | "6 boards this computer hasn’t added yet" | "3 boards…", the same as "New on the bus · 3" | R10 | ## New tests - **`nodes/faults.test.ts`**: a board with several flags. The log rows, the status boxes, the Adopt evidence and the tile chip all come out in the one order. - **`nodes/mapping-sequence.test.ts`**: `MappingSequences` ignores re-entry, is free again after it finishes or after a refusal, and the two sequences are independent. - **`nodes/mapping-sequences.seam.test.ts`** (via `makeServices`, which gained the add-all and unhide intents): - two Add all invokes in flight: the second is ignored and each board is adopted once; - Un-hide all with a refused id in the middle: the others are un-hidden and the refusal is listed. - **`node-hub.test`**: every model pushed carries a rising rev, and `nodes:get` answers the last push's rev. - **T11**: a double click on Add all runs it once, and the dialog is busy until it lands. - **T12**: Un-hide all with one board failing un-hides the rest and lists the failure. - **T30**: the review asks main again only when the mapping changes. - **T10**: a request to open another board while the dialog is open lands on that board. - **`ipc-adapter.test`**: a pull answered behind a newer push is dropped, and so is an older Cockpit snapshot. ## How verified - `npm run lint`, `npm run lint:deps`, `npm run typecheck` and `npm test` all pass (1409 tests). - Each new renderer and store test was run against the code from before its fix, and failed there: - T11 failed with the old `AdoptDialog`; - T30 with the old `ResetDialog` / `CockpitSection`; - both R10 tests in T10 with the old `Cockpit.tsx`; - both C5 cases with the old `store.ts`. - The fault-order test fails against the old `STATUS_FLAGS` walk. - Not run on hardware. The #133 hardware checklist items "fault order matches", "double-click Add all runs once" and "Un-hide all with one failing board" are still to do. --- src/main/events/events.test.ts | 12 +-- src/main/events/status-flags.ts | 21 +++-- src/main/gateway/gateway-health.ts | 4 +- src/main/nodes/cockpit-snapshot/adopt.ts | 9 +-- src/main/nodes/cockpit-snapshot/panels.ts | 57 ++++++------- src/main/nodes/cockpit-snapshot/views.ts | 7 +- src/main/nodes/faults.test.ts | 73 +++++++++++++++++ src/main/nodes/faults.ts | 61 ++++++++++---- src/main/nodes/mapping-sequence.test.ts | 79 ++++++++++++++++--- src/main/nodes/mapping-sequence.ts | 70 +++++++++++++--- src/main/nodes/mapping-sequences.seam.test.ts | 59 ++++++++++++++ src/main/nodes/node-events.ts | 7 +- src/main/nodes/node-hub.test.ts | 28 ++++++- src/main/nodes/node-hub.ts | 23 ++++-- src/main/test-harness/services.ts | 4 +- src/renderer/src/app/ipc-adapter.test.ts | 41 +++++++++- .../src/app/settings/CockpitSection.tsx | 13 +-- src/renderer/src/app/settings/ResetDialog.tsx | 22 ++++-- .../src/app/settings/settings-view.ts | 33 +++++++- src/renderer/src/nodes/AdoptDialog.tsx | 50 ++++++++++-- src/renderer/src/nodes/Cockpit.tsx | 9 +++ src/renderer/src/nodes/store.ts | 17 +++- src/renderer/test/skyhawk-mock.ts | 22 ++++-- .../test/t10-adopt-dialog-queue-form.test.tsx | 21 ++++- .../test/t11-adopt-dialog-add-all.test.tsx | 23 ++++++ .../test/t12-settings-hidden-boards.test.tsx | 29 +++++++ src/renderer/test/t30-settings-reset.test.tsx | 32 ++++++++ src/shared/app/ipc.compat.test-d.ts | 5 ++ src/shared/nodes/ipc.ts | 18 ++++- 29 files changed, 708 insertions(+), 141 deletions(-) create mode 100644 src/main/nodes/faults.test.ts create mode 100644 src/main/nodes/mapping-sequences.seam.test.ts diff --git a/src/main/events/events.test.ts b/src/main/events/events.test.ts index 9001200..b0f1a66 100644 --- a/src/main/events/events.test.ts +++ b/src/main/events/events.test.ts @@ -90,7 +90,9 @@ describe('NodeEventTracker — changes', () => { ]) }) - it('raises and clears bus-off, error-passive (with TEC), overheat', () => { + // X6/R8 (#133): in the Cockpit's order — bus-off, fault, overheat, error-passive (was bus-off, + // error-passive, overheat). + it('raises and clears bus-off, overheat, error-passive (with TEC)', () => { const t = live([st(2)]) expect( short( @@ -98,13 +100,13 @@ describe('NodeEventTracker — changes', () => { ) ).toEqual([ 'Right_Navigation: bus-off [error]', - 'Right_Navigation: error-passive · TEC 131 [warn]', - 'Right_Navigation: overheat [warn]' + 'Right_Navigation: overheat [warn]', + 'Right_Navigation: error-passive · TEC 131 [warn]' ]) expect(short(t.status([st(2)], NODE_MSG, '', 30))).toEqual([ 'Right_Navigation: bus-off cleared [info]', - 'Right_Navigation: error-passive cleared [info]', - 'Right_Navigation: overheat cleared [info]' + 'Right_Navigation: overheat cleared [info]', + 'Right_Navigation: error-passive cleared [info]' ]) }) diff --git a/src/main/events/status-flags.ts b/src/main/events/status-flags.ts index 51cbcab..41fc964 100644 --- a/src/main/events/status-flags.ts +++ b/src/main/events/status-flags.ts @@ -5,13 +5,20 @@ import type { NodeStatus } from '@shared/nodes' export type StatusFlag = 'boff' | 'epvf' | 'degraded' | 'overheat' -/** A node's own heartbeat / health flags, as every row and the Gateway (#94) name them. */ -export const STATUS_FLAGS: { key: StatusFlag; label: string; severity: EventSeverity }[] = [ - { key: 'boff', label: 'bus-off', severity: 'error' }, - { key: 'epvf', label: 'error-passive', severity: 'warn' }, - { key: 'degraded', label: 'degraded', severity: 'warn' }, - { key: 'overheat', label: 'overheat', severity: 'warn' } -] +/** + * A node's own heartbeat / health flags, as every row and the Gateway (#94) name them: the label and + * the severity, by flag. No order here: the order a node's flags are named in is nodes/faults.ts's + * (NODE_FLAG_ORDER), the Gateway's own is gateway-health's. + */ +export const STATUS_FLAGS: Record< + StatusFlag, + { key: StatusFlag; label: string; severity: EventSeverity } +> = { + boff: { key: 'boff', label: 'bus-off', severity: 'error' }, + epvf: { key: 'epvf', label: 'error-passive', severity: 'warn' }, + degraded: { key: 'degraded', label: 'degraded', severity: 'warn' }, + overheat: { key: 'overheat', label: 'overheat', severity: 'warn' } +} /** The fault-dictionary label, else `Fault N`. */ export function faultLabel(n: Pick): string { diff --git a/src/main/gateway/gateway-health.ts b/src/main/gateway/gateway-health.ts index 953be95..223a463 100644 --- a/src/main/gateway/gateway-health.ts +++ b/src/main/gateway/gateway-health.ts @@ -31,8 +31,8 @@ export const GATEWAY_REASK_MS = 1000 const SECS = GATEWAY_NO_ANSWER_MS / 1000 -type Flag = (typeof STATUS_FLAGS)[number] -const flag = (key: StatusFlag): Flag => STATUS_FLAGS.find((f) => f.key === key)! +type Flag = (typeof STATUS_FLAGS)[StatusFlag] +const flag = (key: StatusFlag): Flag => STATUS_FLAGS[key] /** * Client wording for the faults a Gateway reports about itself, keyed by NODE_FAULT_CODES name. diff --git a/src/main/nodes/cockpit-snapshot/adopt.ts b/src/main/nodes/cockpit-snapshot/adopt.ts index df053f5..a894178 100644 --- a/src/main/nodes/cockpit-snapshot/adopt.ts +++ b/src/main/nodes/cockpit-snapshot/adopt.ts @@ -13,7 +13,7 @@ import { type NodeView } from '@shared/nodes' import { amps, fmtUptime, volts } from '@shared/format' -import { nodeFaultTag } from '../faults' +import { nodeFlagWords } from '../faults' import { isAdoptableKind, reportedIdentity } from '../node-kind' import { displayCurrentMa } from '../node-telem' import { resolveZone } from '../power-zone' @@ -110,12 +110,7 @@ export function adoptEvidence(v: NodeView): Evidence { const reporting = e.kind === 'pdu' ? 'telemetry: board status' : 'not reporting right now' return { detected, reporting, registry } } - const faults = [ - st.boff && 'bus-off', - st.degraded && nodeFaultTag(st), - st.overheat && 'overheat', - st.epvf && 'error-passive' - ].filter((f): f is string => !!f) + const faults = nodeFlagWords(st) return { detected, reporting: `status only · up ${fmtUptime(st.uptimeSec)} · ${faults.length ? faults.join(', ') : 'healthy'}`, diff --git a/src/main/nodes/cockpit-snapshot/panels.ts b/src/main/nodes/cockpit-snapshot/panels.ts index 7e7e489..d666a9a 100644 --- a/src/main/nodes/cockpit-snapshot/panels.ts +++ b/src/main/nodes/cockpit-snapshot/panels.ts @@ -12,6 +12,7 @@ import { type FaultBox, type FedBoard, type NodeModelEntry, + type NodeStatus, type NodeView, type PduOutage, type PduRailPage, @@ -21,7 +22,8 @@ import { type ZoneChoices } from '@shared/nodes' import { PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' -import { nodeFaultTag, pduAlertFlags, pduFlagMeanings } from '../faults' +import type { StatusFlag } from '../../events' +import { nodeFaultTag, pduAlertFlags, pduFlagMeanings, raisedNodeFlags } from '../faults' import { displayCurrentMa } from '../node-telem' import { canForget, canHide, type OfflineGate } from '../offline-actions' import { fedBy, zoneLabel } from '../power-zone' @@ -100,7 +102,7 @@ function notSeenBox(e: NodeModelEntry): FaultBox { * The boxes at the top of the node panel's Status tab, stacked; empty when there is nothing to * explain. New, no-status-yet and offline each stand alone. Otherwise every flag the node raised * gets its own box — bus-off, then degraded (the fault table's label), overheat, error-passive, - * the order the tile and Attention name them in — so a second fault never hides behind the first. + * the one order (faults.ts NODE_FLAG_ORDER) — so a second fault never hides behind the first. */ export function nodeFaultBoxes(v: NodeView): FaultBox[] { const e = v.entry @@ -134,32 +136,31 @@ export function nodeFaultBoxes(v: NodeView): FaultBox[] { ] } if (!st) return [] - const out: FaultBox[] = [] - if (st.boff) { - out.push({ - tone: 'err', - title: 'Bus-off', - text: 'its CAN controller stopped transmitting after too many errors. Reported by the node.' - }) - } - if (st.degraded) { - out.push({ - tone: 'warn', - title: st.faultLabel ?? nodeFaultTag(st), - text: st.faultDesc || 'reported by the node; this client has no description for this code.' - }) - } - if (st.overheat) { - out.push({ tone: 'warn', title: 'Overheat', text: 'the node raised its own temperature trip.' }) - } - if (st.epvf) { - out.push({ - tone: 'warn', - title: 'Error-passive', - text: 'its CAN error counters are high; it still communicates but can only signal errors passively.' - }) - } - return out + return raisedNodeFlags(st).map((k) => FLAG_BOX[k](st)) +} + +/** Each flag's box, as nodeFaultBoxes stacks them (in faults.ts NODE_FLAG_ORDER). */ +const FLAG_BOX: Record FaultBox> = { + boff: () => ({ + tone: 'err', + title: 'Bus-off', + text: 'its CAN controller stopped transmitting after too many errors. Reported by the node.' + }), + degraded: (st) => ({ + tone: 'warn', + title: st.faultLabel ?? nodeFaultTag(st), + text: st.faultDesc || 'reported by the node; this client has no description for this code.' + }), + overheat: () => ({ + tone: 'warn', + title: 'Overheat', + text: 'the node raised its own temperature trip.' + }), + epvf: () => ({ + tone: 'warn', + title: 'Error-passive', + text: 'its CAN error counters are high; it still communicates but can only signal errors passively.' + }) } /** diff --git a/src/main/nodes/cockpit-snapshot/views.ts b/src/main/nodes/cockpit-snapshot/views.ts index 56a2036..e4b2409 100644 --- a/src/main/nodes/cockpit-snapshot/views.ts +++ b/src/main/nodes/cockpit-snapshot/views.ts @@ -9,7 +9,7 @@ import type { NodeView, PduReadings } from '@shared/nodes' -import { nodeFaultTag, pduAlertFlags } from '../faults' +import { nodeFlagChips, pduAlertFlags } from '../faults' /** A PDU's pages that are fresh right now. A stale page is not a reading. */ function freshReadings(t: NodeTelemSnapshot | undefined): PduReadings { @@ -28,10 +28,7 @@ export function nodeHealth(e: NodeModelEntry, r: PduReadings = {}): NodeHealth { if (e.availability === 'offline') return { state: 'off', flag: 'OFFLINE' } const st = e.status if (st?.boff) return { state: 'off', flag: 'BUS-OFF' } - const flags: string[] = [] - if (st?.degraded) flags.push(nodeFaultTag(st)) - if (st?.overheat) flags.push('OVERHEAT') - if (st?.epvf) flags.push('ERR-PASSIVE') + const flags = st ? nodeFlagChips(st) : [] if (e.kind === 'pdu') flags.push(...pduAlertFlags(r)) return flags.length ? { state: 'warn', flag: flags.join(' · ') } : { state: 'on' } } diff --git a/src/main/nodes/faults.test.ts b/src/main/nodes/faults.test.ts new file mode 100644 index 0000000..1fe13cb --- /dev/null +++ b/src/main/nodes/faults.test.ts @@ -0,0 +1,73 @@ +// X6 / R8: one order for a node's own flags. The Log and every Cockpit surface name a board's flags +// the same way round: bus-off, the fault it reports (degraded), overheat, error-passive. +import { describe, expect, it } from 'vitest' +import type { LogEventInput } from '@shared/events' +import type { NodeStatus } from '@shared/nodes' +import { modelNode, nodeModel, nodeStatus } from '../../test-fixtures/nodes' +import { adoptEvidence } from './cockpit-snapshot/adopt' +import { nodeFaultBoxes } from './cockpit-snapshot/panels' +import { nodeViews } from './cockpit-snapshot/views' +import { NODE_FLAG_ORDER, nodeFlagChips, nodeFlagWords, raisedNodeFlags } from './faults' +import { NodeEventTracker } from './node-events' +import { NODE_END_MSG, NODE_MSG } from './node-status-wire' + +const ALL: Partial = { + boff: true, + degraded: true, + faultId: 1, + faultAbbr: 'I2C', + faultLabel: 'I2C peripheral', + overheat: true, + epvf: true, + tec: 131 +} +const st = (over: Partial = {}): NodeStatus => + nodeStatus(2, { uptimeSec: 130, dieTempC: null, ...over }) + +/** The rows the Log gets when the board raises `over`, after a healthy baseline. */ +function logRows(over: Partial): string[] { + const t = new NodeEventTracker({ + name: () => 'Right_Navigation', + isNew: () => false, + unmapped: () => false + }) + t.status([st()], NODE_MSG, '', 0) + t.status([st()], NODE_END_MSG, '', 10) + return t.status([st(over)], NODE_MSG, '', 20).map((e: LogEventInput) => e.event) +} + +const view = (over: Partial, source: 'registry' | 'new' = 'registry') => + nodeViews(nodeModel([modelNode(2, { source, status: st(over), firstSeenAt: 1 })]), [])[0]! + +describe('node flag order (X6/R8)', () => { + it('is the Cockpit’s: bus-off, fault, overheat, error-passive', () => { + expect(NODE_FLAG_ORDER).toEqual(['boff', 'degraded', 'overheat', 'epvf']) + expect(raisedNodeFlags(st(ALL))).toEqual(['boff', 'degraded', 'overheat', 'epvf']) + expect(nodeFlagWords(st(ALL))).toEqual(['bus-off', 'I2C', 'overheat', 'error-passive']) + expect(nodeFlagChips(st(ALL))).toEqual(['BUS-OFF', 'I2C', 'OVERHEAT', 'ERR-PASSIVE']) + }) + + it('a board with every flag: the Log and the Cockpit name them in the same order', () => { + expect(logRows(ALL)).toEqual(['bus-off', 'degraded', 'overheat', 'error-passive']) + expect(nodeFaultBoxes(view(ALL)).map((b) => b.title)).toEqual([ + 'Bus-off', + 'I2C peripheral', + 'Overheat', + 'Error-passive' + ]) + expect(adoptEvidence(view(ALL, 'new')).reporting).toBe( + 'status only · up 00:02:10 · bus-off, I2C, overheat, error-passive' + ) + }) + + it('without bus-off: the tile’s chip, the boxes and the Log agree', () => { + const { boff: _boff, ...three } = ALL + expect(logRows(three)).toEqual(['degraded', 'overheat', 'error-passive']) + expect(view(three).health).toEqual({ state: 'warn', flag: 'I2C · OVERHEAT · ERR-PASSIVE' }) + expect(nodeFaultBoxes(view(three)).map((b) => b.title)).toEqual([ + 'I2C peripheral', + 'Overheat', + 'Error-passive' + ]) + }) +}) diff --git a/src/main/nodes/faults.ts b/src/main/nodes/faults.ts index 6e6f173..ed12651 100644 --- a/src/main/nodes/faults.ts +++ b/src/main/nodes/faults.ts @@ -1,11 +1,49 @@ // Node fault and flag text (pure, no Node/Electron deps): what a raised PDU rail or status flag is -// called in an event row and on the Cockpit, what it means, and the reading that goes with it. +// called in an event row and on the Cockpit, what it means, and the reading that goes with it — and +// the one order a node's own flags are named in, on the Cockpit and in the event log alike (X6/R8). import type { EventSeverity } from '@shared/events' import { amps, volts } from '@shared/format' import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' import type { NodeStatus, NodeView, PduRailPage, PduReadings } from '@shared/nodes' +import { STATUS_FLAGS, type StatusFlag } from '../events' import { displayCurrentMa } from './node-telem' +// ── a node's own flags ── + +/** + * The order a node's own flags are named in, everywhere (X6/R8): bus-off, then the fault it reports + * (degraded), overheat, error-passive. The Cockpit's tile, Attention, status boxes and Adopt + * evidence and the event log's rows all read it from here. + */ +export const NODE_FLAG_ORDER: readonly StatusFlag[] = ['boff', 'degraded', 'overheat', 'epvf'] + +/** The status-flag vocabulary (label, severity) in NODE_FLAG_ORDER: what the event log walks. */ +export const NODE_FLAGS = NODE_FLAG_ORDER.map((k) => STATUS_FLAGS[k]) + +/** The flags a node raised, in NODE_FLAG_ORDER. */ +export function raisedNodeFlags(n: NodeStatus): StatusFlag[] { + return NODE_FLAG_ORDER.filter((k) => n[k]) +} + +/** The flags a node raised, in words (`bus-off`, the fault tag, `overheat`, `error-passive`). */ +export function nodeFlagWords(n: NodeStatus): string[] { + return raisedNodeFlags(n).map((k) => (k === 'degraded' ? nodeFaultTag(n) : STATUS_FLAGS[k].label)) +} + +const CHIP: Record = { + boff: 'BUS-OFF', + degraded: '', + overheat: 'OVERHEAT', + epvf: 'ERR-PASSIVE' +} + +/** The flags a node raised, as the Cockpit's chip prints them (`BUS-OFF`, the fault tag, …). */ +export function nodeFlagChips(n: NodeStatus): string[] { + return raisedNodeFlags(n).map((k) => (k === 'degraded' ? nodeFaultTag(n) : CHIP[k])) +} + +// ── PDU flags ── + /** Rail flag text, as the Cockpit prints it (`5V UNDER-V`). */ export const RAIL_FLAGS: [number, string][] = [ [RAIL_FLAG.UNDER_V, 'UNDER-V'], @@ -38,31 +76,20 @@ export function nodeFaultTag(n: NodeStatus): string { return n.faultAbbr ?? `Fault ${n.faultId}` } -// ── the Cockpit's flag text (moved from the renderer, R3.2) ── +// ── the Cockpit's flag text (moved from the renderer, R3.2): the same tables as the log's ── -const RAIL_FLAG_TEXT: [number, string][] = [ - [RAIL_FLAG.UNDER_V, 'UNDER-V'], - [RAIL_FLAG.OVER_V, 'OVER-V'], - [RAIL_FLAG.OVER_CURRENT, 'OVER-I'], - [RAIL_FLAG.FUSE_OPEN, 'FUSE OPEN'], - [RAIL_FLAG.SENSOR_FAULT, 'SENSOR FAULT'] -] -/** Status flags that are alerts. UNCAL_VDDA / UNCAL_ZERO are calibration state, not alerts. */ -const STATUS_FLAG_TEXT: [number, string][] = [ - [PDU_FLAG.TEMP_CRIT, 'TEMP CRIT'], - [PDU_FLAG.TEMP_WARN, 'TEMP WARN'], - [PDU_FLAG.NTC_FAULT, 'NTC FAULT'] -] +/** Status flags that are alerts. UNCAL_VDDA / UNCAL_ZERO are calibration state (info), not alerts. */ +const STATUS_ALERTS = PDU_STATUS_FLAGS.filter(([, , severity]) => severity !== 'info') /** The alert flags a PDU raises on its fresh pages, e.g. `5V UNDER-V`. Empty = healthy. */ export function pduAlertFlags(r: PduReadings): string[] { const out: string[] = [] const rail = (label: string, p?: PduRailPage) => { - for (const [bit, text] of RAIL_FLAG_TEXT) if (p && p.flags & bit) out.push(`${label} ${text}`) + for (const [bit, text] of RAIL_FLAGS) if (p && p.flags & bit) out.push(`${label} ${text}`) } rail('12V', r.rail12V) rail('5V', r.rail5V) - for (const [bit, text] of STATUS_FLAG_TEXT) if (r.status && r.status.flags & bit) out.push(text) + for (const [bit, text] of STATUS_ALERTS) if (r.status && r.status.flags & bit) out.push(text) return out } diff --git a/src/main/nodes/mapping-sequence.test.ts b/src/main/nodes/mapping-sequence.test.ts index 5fcc686..e9ec121 100644 --- a/src/main/nodes/mapping-sequence.test.ts +++ b/src/main/nodes/mapping-sequence.test.ts @@ -1,9 +1,10 @@ // "Add all" and "Un-hide all" (R3.2b): the loops the Adopt dialog and Settings ran, one board at a -// time — in order, each awaited before the next, stopping at the first refusal. +// time — in order, each awaited before the next. Add all stops at the first refusal; Un-hide all +// goes on and lists every refusal (N4); neither re-enters (R6). import { describe, expect, it, vi } from 'vitest' import type { NodeMappingResult } from '@shared/nodes' import { nodeModel } from '../../test-fixtures/nodes' -import { addAllInTurn, unhideInTurn } from './mapping-sequence' +import { addAllInTurn, MappingSequences, unhideInTurn } from './mapping-sequence' const OK: NodeMappingResult = { ok: true, model: nodeModel([]) } const spec = { name: 'X', console: 'Left', pduId: null, kind: 'panel' } as const @@ -37,7 +38,13 @@ describe('addAllInTurn', () => { [2, 7, 9].map((nodeId) => ({ nodeId, spec })), adopt ) - expect(r).toEqual({ ok: false, done: [2], nodeId: 7, error: 'Name cannot be empty' }) + expect(r).toEqual({ + ok: false, + done: [2], + nodeId: 7, + error: 'Name cannot be empty', + failed: [{ nodeId: 7, error: 'Name cannot be empty' }] + }) expect(adopt.mock.calls.map(([id]) => id)).toEqual([2, 7]) }) @@ -50,21 +57,75 @@ describe('addAllInTurn', () => { }) describe('unhideInTurn', () => { - it('un-hides each in order and stops at the first refusal', async () => { + // N4 (#133): it used to stop at the first refusal, with no word about the rest. + it('un-hides each in order, goes on past a refusal and lists every one refused', async () => { const hide = vi.fn( (id: number): NodeMappingResult => - id === 30 ? { ok: false, error: 'Node mapping is read-only' } : OK + id === 30 || id === 52 ? { ok: false, error: `#${id} could not be saved` } : OK ) - expect(await unhideInTurn([8, 30, 41], hide)).toEqual({ + expect(await unhideInTurn([8, 30, 41, 52], hide)).toEqual({ ok: false, - done: [8], + done: [8, 41], nodeId: 30, - error: 'Node mapping is read-only' + error: '#30 could not be saved', + failed: [ + { nodeId: 30, error: '#30 could not be saved' }, + { nodeId: 52, error: '#52 could not be saved' } + ] }) expect(hide.mock.calls).toEqual([ [8, false], - [30, false] + [30, false], + [41, false], + [52, false] ]) expect(await unhideInTurn([8], hide)).toEqual({ ok: true, done: [8] }) }) }) + +describe('MappingSequences (R6: no re-entry)', () => { + /** An answer the test releases, so the first call is still running when the second comes. */ + function gate() { + let open!: () => void + const p = new Promise((r) => (open = () => r(OK))) + return { p, open } + } + + it('a second Add all while the first runs is ignored, not run twice', async () => { + const seq = new MappingSequences() + const g = gate() + const adopt = vi.fn((_id: number) => g.p) + const bulk = [2, 7].map((nodeId) => ({ nodeId, spec })) + const first = seq.addAll(bulk, adopt) + const second = await seq.addAll(bulk, adopt) + expect(second).toEqual({ ok: true, done: [] }) + g.open() + expect(await first).toEqual({ ok: true, done: [2, 7] }) + expect(adopt.mock.calls.map(([id]) => id)).toEqual([2, 7]) + // Once it has finished, it runs again. + expect(await seq.addAll(bulk, () => OK)).toEqual({ ok: true, done: [2, 7] }) + }) + + it('a second Un-hide all while the first runs is ignored; a refusal still frees it', async () => { + const seq = new MappingSequences() + const g = gate() + const hide = vi.fn(() => g.p) + const first = seq.unhide([8, 30], hide) + expect(await seq.unhide([8, 30], hide)).toEqual({ ok: true, done: [] }) + g.open() + expect(await first).toEqual({ ok: true, done: [8, 30] }) + expect(hide).toHaveBeenCalledTimes(2) + const refused = await seq.unhide([8], () => ({ ok: false, error: 'read-only' })) + expect(refused.ok).toBe(false) + expect(await seq.unhide([8], () => OK)).toEqual({ ok: true, done: [8] }) + }) + + it('Add all and Un-hide all are separate: one running doesn’t block the other', async () => { + const seq = new MappingSequences() + const g = gate() + const first = seq.addAll([{ nodeId: 2, spec }], () => g.p) + expect(await seq.unhide([8], () => OK)).toEqual({ ok: true, done: [8] }) + g.open() + expect(await first).toEqual({ ok: true, done: [2] }) + }) +}) diff --git a/src/main/nodes/mapping-sequence.ts b/src/main/nodes/mapping-sequence.ts index e232125..4764bbe 100644 --- a/src/main/nodes/mapping-sequence.ts +++ b/src/main/nodes/mapping-sequence.ts @@ -1,16 +1,19 @@ // Mapping changes one board at a time (R3.2b): the Adopt dialog's "Add all" and Settings' "Un-hide -// all". The renderer ran these loops itself, one invoke per board (AdoptDialog `addAll`, -// CockpitSection `unhide`); NodeHub runs them now, the same way: in the order given, each change -// awaited before the next, stopping at the first refusal. Each change pushes the model as it goes -// (NodeHub.update), just as each invoke did. +// all". NodeHub runs them in the order given, each change awaited before the next. Each change +// pushes the model as it goes (NodeHub.update), just as each invoke did. // -// Today's defects stay as they are (fix track): Add all adds what it can before a refusal and the -// rest are not tried; Un-hide all stops at the first board it can't un-hide. +// - Add all stops at the first refusal: the boards after it are not tried. +// - Un-hide all (N4, #133) goes on past a board it can't un-hide and reports every one that failed. +// - Neither re-enters (R6, #133): a second call while one is still running is ignored — it answers +// that it did nothing — so a double click never runs it twice. import type { AdoptRequest, AdoptSpec, NodeBatchResult, NodeMappingResult } from '@shared/nodes' /** One change, as NodeHub (or an invoke) answers it. undefined = no answer (counts as done). */ type Answer = NodeMappingResult | undefined | Promise +/** What a call made while the same sequence is still running answers: nothing was done. */ +const IGNORED: NodeBatchResult = { ok: true, done: [] } + /** "Add all": adopt each board in turn; the first refusal stops it. */ export async function addAllInTurn( bulk: AdoptRequest[], @@ -19,22 +22,67 @@ export async function addAllInTurn( const added: number[] = [] for (const b of bulk) { const r = await adopt(b.nodeId, b.spec) - if (r && !r.ok) return { ok: false, done: added, nodeId: b.nodeId, error: r.error } + if (r && !r.ok) { + const failed = [{ nodeId: b.nodeId, error: r.error }] + return { ok: false, done: added, nodeId: b.nodeId, error: r.error, failed } + } added.push(b.nodeId) } return { ok: true, done: added } } -/** "Un-hide" / "Un-hide all": show each board again in turn; the first refusal stops it. */ +/** + * "Un-hide" / "Un-hide all": show each board again in turn. A refusal doesn't stop it: every board + * is tried, and the answer lists the ones that failed (the first also as `nodeId` / `error`). + */ export async function unhideInTurn( ids: number[], hide: (nodeId: number, on: boolean) => Answer ): Promise { const done: number[] = [] + const failed: { nodeId: number; error: string }[] = [] for (const id of ids) { const r = await hide(id, false) - if (r && !r.ok) return { ok: false, done, nodeId: id, error: r.error } - done.push(id) + if (r && !r.ok) failed.push({ nodeId: id, error: r.error }) + else done.push(id) + } + const first = failed[0] + return first + ? { ok: false, done, nodeId: first.nodeId, error: first.error, failed } + : { ok: true, done } +} + +/** + * The two sequences as NodeHub (and the component tests' mock) runs them: each one at a time. A + * call made while the same sequence is running is ignored (`{ ok: true, done: [] }`), not run. + */ +export class MappingSequences { + private adding = false + private unhiding = false + + async addAll( + bulk: AdoptRequest[], + adopt: (nodeId: number, spec: AdoptSpec) => Answer + ): Promise { + if (this.adding) return IGNORED + this.adding = true + try { + return await addAllInTurn(bulk, adopt) + } finally { + this.adding = false + } + } + + async unhide( + ids: number[], + hide: (nodeId: number, on: boolean) => Answer + ): Promise { + if (this.unhiding) return IGNORED + this.unhiding = true + try { + return await unhideInTurn(ids, hide) + } finally { + this.unhiding = false + } } - return { ok: true, done } } diff --git a/src/main/nodes/mapping-sequences.seam.test.ts b/src/main/nodes/mapping-sequences.seam.test.ts new file mode 100644 index 0000000..c666838 --- /dev/null +++ b/src/main/nodes/mapping-sequences.seam.test.ts @@ -0,0 +1,59 @@ +// SESSION-seam tests for "Add all" and "Un-hide all" (#133: R6, N4), through the services harness: +// the renderer's intents in, pushes and node-mapping.json out. +import { describe, expect, it } from 'vitest' +import { CTRL } from '@shared/app' +import type { NodeMappingFile } from '@shared/nodes' +import { statusLine } from '../../test-fixtures/nodes' +import { makeServices } from '../test-harness/services' + +const spec = (name: string) => ({ name, console: 'Left', pduId: null, kind: 'panel' }) as const +const mapping = (app: ReturnType) => + app.files.json('node-mapping.json')!.nodes + +describe('Add all and Un-hide all (R6, N4)', () => { + it('a second Add all while the first is still running is ignored: each board is added once', async () => { + const app = makeServices() + app.serial.open() + // Two unlisted panel groups on the bus, waiting to be adopted. + app.serial.receive(statusLine(41) + statusLine(42) + '_NODE_STATUS_END\n') + const bulk = [ + { nodeId: 41, spec: spec('Left_A') }, + { nodeId: 42, spec: spec('Left_B') } + ] + const before = app.pushesOf('nodes:model').length + // A double click: the second invoke lands while the first is still adopting. + const first = app.invoke(CTRL.nodeMappingAddAll, bulk) + const second = app.invoke(CTRL.nodeMappingAddAll, bulk) + expect(await second).toEqual({ ok: true, done: [] }) + expect(await first).toEqual({ ok: true, done: [41, 42] }) + // One adopt (and one model push) per board, not two. + expect(app.pushesOf('nodes:model').length - before).toBe(2) + expect(mapping(app)[41]).toMatchObject({ added: true, name: 'Left_A' }) + expect(mapping(app)[42]).toMatchObject({ added: true, name: 'Left_B' }) + }) + + it('Un-hide all goes on past a board it can’t un-hide and lists it; the others are shown', async () => { + const app = makeServices({ + paths: { + files: { + 'node-mapping.json': { + version: 4, + nodes: { 1: { added: true, hidden: true }, 2: { added: true, hidden: true } } + } + } + } + }) + // 999 is no NODE_ID: main refuses it, between the two it can un-hide. + expect(await app.invoke(CTRL.nodeMappingUnhide, [1, 999, 2])).toEqual({ + ok: false, + done: [1, 2], + nodeId: 999, + error: 'Not a NODE_ID: 999', + failed: [{ nodeId: 999, error: 'Not a NODE_ID: 999' }] + }) + expect(mapping(app)).toMatchObject({ 1: { added: true }, 2: { added: true } }) + expect(mapping(app)[1]?.hidden).toBeUndefined() + expect(mapping(app)[2]?.hidden).toBeUndefined() + expect(app.lastPush('nodes:model')?.hidden).toEqual([]) + }) +}) diff --git a/src/main/nodes/node-events.ts b/src/main/nodes/node-events.ts index 20121d8..7b35857 100644 --- a/src/main/nodes/node-events.ts +++ b/src/main/nodes/node-events.ts @@ -3,8 +3,8 @@ // threshold, so every alert here is a flag a board raised itself. import type { EventSeverity, LogEventInput } from '@shared/events' import type { NodeStatus, NodeTelem } from '@shared/nodes' -import { faultLabel, STATUS_FLAGS } from '../events' -import { PDU_STATUS_FLAGS, RAIL_FLAGS, railReading, TEMP_FLAGS } from './faults' +import { faultLabel } from '../events' +import { NODE_FLAGS, PDU_STATUS_FLAGS, RAIL_FLAGS, railReading, TEMP_FLAGS } from './faults' import { BRIDGE_NODE_ID, NODE_END_MSG, ROSTER_REPLY_MS } from './node-status-wire' import { TELEM_PAGE } from './node-telem' @@ -173,7 +173,8 @@ export class NodeEventTracker { out: LogEventInput[] ): LogEventInput[] { if (!n.present) return out - for (const f of STATUS_FLAGS) { + // In the Cockpit's order (faults.ts NODE_FLAG_ORDER), so the Log names them as it does. + for (const f of NODE_FLAGS) { const before = was?.[f.key] ?? false const on = n[f.key] if (f.key === 'degraded' && on && before && was!.faultId !== n.faultId) { diff --git a/src/main/nodes/node-hub.test.ts b/src/main/nodes/node-hub.test.ts index 9bf8027..6c7fa7b 100644 --- a/src/main/nodes/node-hub.test.ts +++ b/src/main/nodes/node-hub.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { NodeMappingFile } from '@shared/nodes' +import type { NodeMappingFile, NodeModel } from '@shared/nodes' import { NODE_END_MSG, NODE_MSG, NodeRoster } from './node-status-wire' import { importMapping } from './mapping-rules' @@ -189,6 +189,21 @@ describe('NodeHub', () => { expect(disk.saves).toBe(before) }) + it('every model pushed carries a rising rev; nodes:get answers the last push’s (C5)', () => { + const { h, pushes } = hub() + const revs = () => + pushes.filter(([c]) => c === 'nodes:model').map(([, m]) => (m as NodeModel).rev) + h.setMapping(1, { name: 'Armament' }) + h.setMapping(1, { name: 'Armament_2' }) + const [a, b] = revs() + expect(b).toBe(a! + 1) + expect(h.snapshot().model.rev).toBe(b) + // A refused change pushes nothing and answers the same rev. + h.setMapping(1, { name: ' ' }) + expect(revs()).toHaveLength(2) + expect(h.snapshot().model.rev).toBe(b) + }) + it('adopting a new node takes it out of "new"; a nameless adopt is refused and changes nothing', () => { const { h, roster } = hub() roster.applyMessage(NODE_MSG, '2901000000000000008000000000', 0) // node 41: kind not reported yet @@ -269,7 +284,8 @@ describe('NodeHub', () => { ok: false, done: [41], nodeId: 43, - error: 'Its board type is not known yet' + error: 'Its board type is not known yet', + failed: [{ nodeId: 43, error: 'Its board type is not known yet' }] }) expect(Object.keys(disk.file.nodes).map(Number)).toEqual([1, 2, 3, 4, 5, 41]) // One adopt's pushes, as one node-mapping:adopt invoke made them; 42 was not tried. @@ -280,6 +296,7 @@ describe('NodeHub', () => { expect(pushes.map(([c]) => c)).toEqual(['cockpit:snapshot', 'nodes:model']) }) + // N4 (#133): a refusal no longer stops it — every board is tried, each refusal listed. it('Un-hide all (R3.2b) un-hides each board in turn, each pushed as it lands', async () => { disk.file = { version: 4, @@ -297,11 +314,14 @@ describe('NodeHub', () => { disk.file = { version: 4, nodes: { 1: { added: true, hidden: true } } } disk.readOnly = 'node-mapping.json was written by a newer client' const ro = hub().h + const notSaved = 'Not saved: node-mapping.json was written by a newer client' + // #1 can't be saved; #2 is tried after it (already shown, so nothing to save). expect(await ro.unhide([1, 2])).toEqual({ ok: false, - done: [], + done: [2], nodeId: 1, - error: 'Not saved: node-mapping.json was written by a newer client' + error: notSaved, + failed: [{ nodeId: 1, error: notSaved }] }) }) diff --git a/src/main/nodes/node-hub.ts b/src/main/nodes/node-hub.ts index f0f3808..2d305f6 100644 --- a/src/main/nodes/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -76,7 +76,7 @@ import { type MappingFileIO } from './mapping-store' import { CockpitSnapshotHost } from './cockpit-host' -import { addAllInTurn, unhideInTurn } from './mapping-sequence' +import { MappingSequences } from './mapping-sequence' /** Port: the debug log, handed in by app (debug.log lives in app). */ export type Log = (tag: string, data?: unknown) => void @@ -160,6 +160,8 @@ export class NodeHub implements NodeLineSink { private readonly defaults = registryDefaults(NODE_NAMES) private telemPushedAt = 0 private modelDirty = true + /** Rises with every `nodes:model` push; every model main hands out carries it (C5). */ + private modelRev = 0 /** Present NODE_IDs at the last roster change, to tell "came back" from "still listed". */ private wasPresent = new Set() /** Node changes for the event log (#64), named by the mapping. */ @@ -177,6 +179,8 @@ export class NodeHub implements NodeLineSink { private mappedCache = new Map() /** The node mapping and its file. */ private readonly store: MappingStore + /** Add all / Un-hide all, each one at a time (R6): a call while it runs is ignored. */ + private readonly sequences = new MappingSequences() /** The Cockpit tab's picture (R3.2), pushed on `cockpit:snapshot` with each node push. */ private readonly cockpit: CockpitSnapshotHost @@ -440,15 +444,19 @@ export class NodeHub implements NodeLineSink { /** * "Add all" (R3.2b, the Adopt dialog's loop until then): adopt each board in turn, as one adopt - * each — every one pushes the model as it lands — stopping at the first refusal. + * each — every one pushes the model as it lands — stopping at the first refusal. A second call + * while one runs is ignored (R6). */ addAll(bulk: AdoptRequest[]): Promise { - return addAllInTurn(bulk, (nodeId, spec) => this.adopt(nodeId, spec)) + return this.sequences.addAll(bulk, (nodeId, spec) => this.adopt(nodeId, spec)) } - /** "Un-hide" / "Un-hide all" (R3.2b, Settings' loop until then): the same, one un-hide each. */ + /** + * "Un-hide" / "Un-hide all" (R3.2b, Settings' loop until then): one un-hide each, every board + * tried, the ones refused listed (N4). A second call while one runs is ignored (R6). + */ unhide(ids: number[]): Promise { - return unhideInTurn(ids, (nodeId, on) => this.hide(nodeId, on)) + return this.sequences.unhide(ids, (nodeId, on) => this.hide(nodeId, on)) } /** One registry board back to its NODE_IDS.md defaults. */ @@ -559,8 +567,9 @@ export class NodeHub implements NodeLineSink { return this.update((f) => setHidden(f, this.defaults, nodeId, on)) } + /** The model as it stands, stamped with the rev of the last push (a newer push has a higher one). */ private model(now: number): NodeModel { - return buildNodeModel({ + const model = buildNodeModel({ roster: this.roster.snapshot().map(withFaultInfo), seen: this.roster.seenInfo(), rosterCompleteAt: this.roster.rosterCompleteAt(), @@ -569,9 +578,11 @@ export class NodeHub implements NodeLineSink { file: this.mapping(), now }) + return { ...model, rev: this.modelRev } } private pushModel(now = Date.now()): NodeModel { + this.modelRev++ const model = this.model(now) this.modelDirty = false this.cockpit.modelPushed(model) // just ahead of the push it mirrors diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index 03c3f5c..5da928a 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -19,7 +19,7 @@ import { onTestFinished, vi } from 'vitest' import { CTRL, type IpcListener, type PushChannel, type PushChannels } from '@shared/app' import type { AppConfig, ConfigEdit } from '@shared/config' import type { RecordState, ReplayLoad } from '@shared/dcs' -import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes' +import type { AdoptRequest, AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes' // ── the outside world, faked ───────────────────────────────────────────────── @@ -265,6 +265,8 @@ function launch(opts: ServicesOptions) { [CTRL.nodeMappingResetPreview]: () => services.nodes.previewReset(), [CTRL.nodeRegistry]: () => services.nodes.registry(), [CTRL.nodeMappingHide]: (id: number, on: boolean) => services.nodes.hide(id, on), + [CTRL.nodeMappingAddAll]: (bulk: AdoptRequest[]) => services.nodes.addAll(bulk), + [CTRL.nodeMappingUnhide]: (ids: number[]) => services.nodes.unhide(ids), [CTRL.serialMonitor]: (on: boolean) => services.serialMonitor.setSerialMonitor(on), [CTRL.eventsGet]: () => services.journal.snapshot(), [CTRL.eventsClear]: () => services.journal.clear() diff --git a/src/renderer/src/app/ipc-adapter.test.ts b/src/renderer/src/app/ipc-adapter.test.ts index 63519f0..3d25969 100644 --- a/src/renderer/src/app/ipc-adapter.test.ts +++ b/src/renderer/src/app/ipc-adapter.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { CALIBRATION_IDLE, type CalibrationState, type CalSnapshot } from '@shared/hid/calibration' -import type { NodeModel, NodeSnapshot, NodeTelemSnapshot } from '@shared/nodes' +import { + COCKPIT_IDLE, + type NodeModel, + type NodeSnapshot, + type NodeTelemSnapshot +} from '@shared/nodes' import type { PushChannel, PushChannels, RelayStatus } from '@shared/app' import type { LogEvent } from '@shared/events' import type { DeviceStatus, DeviceStatusPush } from '@shared/gateway' @@ -51,6 +56,8 @@ const EMPTY_MODEL: NodeModel = { rosterComplete: false } let nodesSnap: NodeSnapshot = { model: EMPTY_MODEL, telemetry: [] } +/** When set, `nodes:get` answers only once the test releases it (a reply racing a push). */ +let nodesAnswer: Promise | undefined let startResult: { ok: boolean; error?: string } = { ok: true } let eventsSnap: LogEvent[] = [] @@ -79,7 +86,7 @@ function fakeApi() { }) }) }, - getNodes: () => Promise.resolve(nodesSnap), + getNodes: () => nodesAnswer ?? Promise.resolve(nodesSnap), getEvents: () => Promise.resolve(eventsSnap), clearEvents: () => { eventsCleared++ @@ -351,6 +358,7 @@ describe('node state (#63)', () => { beforeEach(() => { status = { running: false, relay: { running: false }, device: { state: 'connected' } } nodesSnap = { model: EMPTY_MODEL, telemetry: [] } + nodesAnswer = undefined }) it('rehydrates the model on a renderer reload', async () => { @@ -372,6 +380,35 @@ describe('node state (#63)', () => { push('device:status', { state: 'connected' }) expect(s.getState().linkDownAt).toBeUndefined() }) + + it('an older model never wins: a pull answered behind a newer push is dropped (C5)', async () => { + let answer!: (snap: NodeSnapshot) => void + nodesAnswer = new Promise((r) => (answer = r)) + const s = await freshStore() + const newer = { ...pduModel, rev: 5 } + push('nodes:model', newer) + answer({ model: { ...EMPTY_MODEL, rev: 4 }, telemetry: [] }) + await nodesAnswer + await Promise.resolve() + expect(s.getState().nodeModel).toBe(newer) + // A pull as new as the last push (or newer) is taken, and so is every newer push. + const pushed = { ...EMPTY_MODEL, rev: 6 } + push('nodes:model', pushed) + expect(s.getState().nodeModel).toBe(pushed) + push('nodes:model', { ...pduModel, rev: 5 }) + expect(s.getState().nodeModel).toBe(pushed) + }) + + it('an older Cockpit snapshot never wins (C5)', async () => { + const s = await freshStore() + const newer = { ...COCKPIT_IDLE, rev: 8, live: true } + push('cockpit:snapshot', newer) + push('cockpit:snapshot', { ...COCKPIT_IDLE, rev: 7 }) + expect(s.getState().cockpit).toBe(newer) + const next = { ...COCKPIT_IDLE, rev: 9 } + push('cockpit:snapshot', next) + expect(s.getState().cockpit).toBe(next) + }) }) describe('event log (#64)', () => { diff --git a/src/renderer/src/app/settings/CockpitSection.tsx b/src/renderer/src/app/settings/CockpitSection.tsx index 4bfacf3..ca7b194 100644 --- a/src/renderer/src/app/settings/CockpitSection.tsx +++ b/src/renderer/src/app/settings/CockpitSection.tsx @@ -5,7 +5,7 @@ import { useConfigStore } from '../../config' import { useNodesStore } from '../../nodes' import { Row, SectionHead } from '../kit' import { ResetDialog } from './ResetDialog' -import { hiddenRow, resetRowDesc } from './settings-view' +import { hiddenRow, resetPreviewKey, resetRowDesc, unhideError } from './settings-view' import { useNodesPulled } from './useNodesPulled' type LimitKey = keyof PsuLimits @@ -100,11 +100,12 @@ function HiddenNodes() { const [error, setError] = useState() const hidden = pulled ? nodes.hidden : [] - // Main un-hides them one at a time and stops at the first it can't (R3.2b). + // Main un-hides them one at a time, goes on past one it can't and lists each it couldn't (N4). const unhide = async (ids: number[]) => { setError(undefined) + const listed = hidden const r = await useNodesStore.getState().unhideNodes(ids) - if (r && !r.ok) setError(r.error) + if (r) setError(unhideError(r, ids.length, listed)) } return ( @@ -158,8 +159,8 @@ function HiddenNodes() { export function CockpitSection() { const pulled = useNodesPulled() const nodes = useNodesStore((s) => s.cockpit.settings) - // The review asks main for a fresh preview each time the model is pushed. - const model = useNodesStore((s) => s.nodeModel) + // The review asks main for a fresh preview when the mapping it is built from changes (R9). + const previewKey = useNodesStore((s) => resetPreviewKey(s.cockpit)) const attentionBanner = useConfigStore((s) => s.attentionBanner) const setConfigField = useConfigStore((s) => s.setConfigField) const [resetOpen, setResetOpen] = useState(false) @@ -194,7 +195,7 @@ export function CockpitSection() { - {resetOpen && setResetOpen(false)} />} + {resetOpen && setResetOpen(false)} />} ) } diff --git a/src/renderer/src/app/settings/ResetDialog.tsx b/src/renderer/src/app/settings/ResetDialog.tsx index 299ce42..313ec3f 100644 --- a/src/renderer/src/app/settings/ResetDialog.tsx +++ b/src/renderer/src/app/settings/ResetDialog.tsx @@ -1,23 +1,33 @@ import { useEffect, useState } from 'react' -import type { NodeModel, ResetPreview } from '@shared/nodes' +import type { ResetPreview } from '@shared/nodes' import { useNodesStore } from '../../nodes' import { Dialog } from '../kit' import { resetButtonLabel } from './settings-view' /** * Reset node mapping — a review, never a one-click reset. Lists every field the reset changes, - * and offers to forget adopted nodes too (off by default). `model` re-fetches the preview when - * the mapping changes underneath. + * and offers to forget adopted nodes too (off by default). `inputs` (settings-view + * resetPreviewKey) re-fetches the preview when the mapping changes underneath, and only then (R9); + * an answer that a newer fetch superseded is dropped. */ -export function ResetDialog({ model, onClose }: { model?: NodeModel; onClose: () => void }) { +export function ResetDialog({ inputs, onClose }: { inputs: string; onClose: () => void }) { const [preview, setPreview] = useState() const [forget, setForget] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState() useEffect(() => { - void useNodesStore.getState().previewNodeReset()?.then(setPreview) - }, [model]) + let current = true + void useNodesStore + .getState() + .previewNodeReset() + ?.then((p) => { + if (current) setPreview(p) + }) + return () => { + current = false + } + }, [inputs]) const nodes = preview?.nodeCount ?? 0 const adopted = preview?.adopted ?? [] diff --git a/src/renderer/src/app/settings/settings-view.ts b/src/renderer/src/app/settings/settings-view.ts index 80efc63..6f2dbef 100644 --- a/src/renderer/src/app/settings/settings-view.ts +++ b/src/renderer/src/app/settings/settings-view.ts @@ -2,7 +2,7 @@ // and Reset…'s gate are main's (cockpit:snapshot settings, R3.2b). The Import rows' wording is // config/import-view.ts. import { plural } from '@shared/format' -import type { HiddenNode } from '@shared/nodes' +import type { CockpitSnapshot, HiddenNode, NodeBatchResult } from '@shared/nodes' import { consoleLabel } from '@shared/nodes' /** "Reset node mapping" row description. */ @@ -38,3 +38,34 @@ export function hiddenRow(n: HiddenNode): ListRow { : 'returns when it reports again' } } + +/** + * What Hidden boards says when main refused an un-hide (N4): one board's refusal as main worded it; + * from Un-hide all, every board it couldn't un-hide — by #id and the name it was listed under — + * with its reason. The others were un-hidden. undefined = nothing refused. + */ +export function unhideError( + r: NodeBatchResult, + asked: number, + listed: HiddenNode[] +): string | undefined { + if (r.ok) return undefined + if (asked === 1) return r.error + const named = (id: number) => { + const name = listed.find((n) => n.nodeId === id)?.name + return name ? `#${id} ${name}` : `#${id}` + } + return `Couldn’t un-hide ${r.failed.map((f) => `${named(f.nodeId)}: ${f.error}`).join('; ')}` +} + +/** + * When Reset…'s review asks main for its preview again (R9): the preview is built from the node + * mapping, so this is the mapping as the snapshot shows it — every board's name, console, PDU and + * where it came from, the hidden ones included, and how many differ. A push that changes none of + * it (a roster poll, a PDU page) gives the same key, and the review keeps the preview it has. + */ +export function resetPreviewKey(s: CockpitSnapshot): string { + const boards = s.views.map(({ entry: e }) => [e.nodeId, e.source, e.name, e.console, e.pduId]) + const hidden = s.settings.hidden.map((h) => [h.nodeId, h.source, h.name, h.console, h.pduId]) + return JSON.stringify([s.settings.differing, boards, hidden]) +} diff --git a/src/renderer/src/nodes/AdoptDialog.tsx b/src/renderer/src/nodes/AdoptDialog.tsx index 89e1353..2151a26 100644 --- a/src/renderer/src/nodes/AdoptDialog.tsx +++ b/src/renderer/src/nodes/AdoptDialog.tsx @@ -14,6 +14,9 @@ import { useModalFocus } from '../app/kit' import { useNodesStore } from './store' import { Dot } from './Dot' +/** A change run under the dialog's one busy flag: its answer, or null if another was running. */ +type RunBusy = (call: () => Promise | undefined) => Promise<{ r: T | undefined } | null> + /** * New on the bus (#77): name and place each NODE_ID the registry does not know, or leave it for * later. A board that can't be adopted is listed so you can see what it is; it stays in Attention @@ -24,6 +27,8 @@ export function AdoptDialog(props: { /** Main's queue: new NODE_IDs, oldest first seen first. */ rows: AdoptRow[] zones: ZoneChoices + /** "New on the bus · N", as the Attention button that opens this shows it (R10: one count). */ + count: number initial?: number onClose: () => void }) { @@ -35,6 +40,26 @@ export function AdoptDialog(props: { const shown = useRef(new Map()) for (const r of live) shown.current.set(r.view.entry.nodeId, r) const [done, setDone] = useState>(new Set()) + /** A board added this turn leaves the queue (functional, so one landing mid-Add all is kept). */ + const markDone = (ids: number[]) => setDone((d) => new Set([...d, ...ids])) + // One busy flag for the whole dialog (R6): while an Adopt or Add all runs, nothing else starts. + // The ref answers at once, so a double click's second click is refused before React re-renders. + const [busy, setBusyState] = useState(false) + const busyRef = useRef(false) + const setBusy = (on: boolean) => { + busyRef.current = on + setBusyState(on) + } + /** One change under the busy flag: its answer, or null when refused (another is running). */ + const run: RunBusy = async (call) => { + if (busyRef.current) return null + setBusy(true) + try { + return { r: await call() } + } finally { + setBusy(false) + } + } const queue = useMemo(() => keptQueue(live, shown.current, done), [live, done]) const ids = queue.map((r) => r.view.entry.nodeId) // The queue as the dialog showed it, so "next" keeps its place as nodes leave it. @@ -69,15 +94,18 @@ export function AdoptDialog(props: { // Main adopts them one at a time and stops at the first it refuses (R3.2b); the ones it added // leave the queue. const addAll = async () => { + if (busyRef.current) return setBulkError('') - const r = await useNodesStore.getState().addAllNodes(bulk) - setDone(new Set([...done, ...(r ? r.done : bulk.map((b) => b.nodeId))])) + const ran = await run(() => useNodesStore.getState().addAllNodes(bulk)) + if (!ran) return + const { r } = ran + markDone(r ? r.done : bulk.map((b) => b.nodeId)) if (r && !r.ok) setBulkError(`NODE_ID ${r.nodeId}: ${r.error}`) } if (!current) return null const move = (how: 'done' | 'later') => { - if (how === 'done') setDone(new Set([...done, current.view.entry.nodeId])) + if (how === 'done') markDone([current.view.entry.nodeId]) const next = nextSelection(order.current, ids, current.view.entry.nodeId, how) if (next === null && how === 'later') return props.onClose() setSel(next) @@ -101,7 +129,7 @@ export function AdoptDialog(props: {
New on the bus
- {queue.length} board{queue.length === 1 ? '' : 's'} this computer hasn’t added yet. + {props.count} board{props.count === 1 ? '' : 's'} this computer hasn’t added yet. Boards in NODE_IDS.md come pre-filled — check them and add.
@@ -146,6 +174,8 @@ export function AdoptDialog(props: { onForm={(f) => setEdits(new Map(edits).set(current.view.entry.nodeId, f))} bulk={bulk.length > 1 ? bulk.length : 0} bulkError={bulkError} + busy={busy} + run={run} onAddAll={() => void addAll()} onDone={() => move('done')} onLater={() => move('later')} @@ -168,6 +198,9 @@ function AdoptForm(props: { form?: FormValues onForm: (f: FormValues) => void bulkError: string + /** The dialog's one busy flag (R6), and how a change runs under it. */ + busy: boolean + run: RunBusy onAddAll: () => void onDone: () => void onLater: () => void @@ -181,7 +214,7 @@ function AdoptForm(props: { const { name, console: con, pduId } = form const setName = (v: string) => props.onForm({ ...form, name: v }) const setPduId = (v: number | null | '') => props.onForm({ ...form, pduId: v }) - const [busy, setBusy] = useState(false) + const { busy } = props const [error, setError] = useState('') const ev = row.evidence // #93: the PDU is explicit, and only ever one in your cockpit (D7): one on another console is @@ -191,10 +224,11 @@ function AdoptForm(props: { const ident = identState(e.availability === 'online', props.dropped) const run = async (call: () => Promise | undefined) => { - setBusy(true) + if (busy) return setError('') - const r = await call() - setBusy(false) + const ran = await props.run(call) + if (!ran) return // the dialog is busy (R6): this click starts nothing + const { r } = ran if (!r) return setError('Not connected to the client process.') if (r.ok) props.onDone() else setError(r.error) diff --git a/src/renderer/src/nodes/Cockpit.tsx b/src/renderer/src/nodes/Cockpit.tsx index 831291c..722689a 100644 --- a/src/renderer/src/nodes/Cockpit.tsx +++ b/src/renderer/src/nodes/Cockpit.tsx @@ -32,6 +32,11 @@ export function Cockpit({ psuView }: { psuView: PsuView }) { const [adopting, setAdopting] = useState(false) /** The Adopt dialog opened on this NODE_ID, else on the first in its queue. */ const [adoptAt, setAdoptAt] = useState() + /** + * Each request to open the Adopt dialog (R10): a new one starts it over on the board asked for, as + * the latest push has it, instead of keeping a dialog already open on another board. + */ + const [adoptOpen, setAdoptOpen] = useState(0) /** * Every way into a board lands here — the plan, Attention, Powered by, Feeds and other tabs' * requests — and goes where main says (D6, `opens`): its panel while it is in your cockpit, else @@ -43,11 +48,13 @@ export function Cockpit({ psuView }: { psuView: PsuView }) { if (to !== 'adopt') return setOpen(null) setAdoptAt(nodeId) + setAdoptOpen((n) => n + 1) setAdopting(true) }, []) /** The Adopt dialog on the first board in its queue ("New on the bus", the Overview's notice). */ const adoptFirst = useCallback(() => { setAdoptAt(undefined) + setAdoptOpen((n) => n + 1) setAdopting(true) }, []) /** A node another tab asked to open here (Connection's "›"). */ @@ -174,8 +181,10 @@ export function Cockpit({ psuView }: { psuView: PsuView }) { {adopting && ( setAdopting(false)} /> diff --git a/src/renderer/src/nodes/store.ts b/src/renderer/src/nodes/store.ts index 6cd4ed4..829f255 100644 --- a/src/renderer/src/nodes/store.ts +++ b/src/renderer/src/nodes/store.ts @@ -58,14 +58,25 @@ export const useNodesStore = create(() => ({ resetNodeMapping: (opts) => api()?.resetNodeMapping(opts) })) +/** + * An older model or snapshot never wins (C5): main's revs only rise, so one that arrives behind + * what the store holds (a pull's answer landing after a newer push) is dropped. A model without a + * rev (not from main) counts as 0. + */ +const olderModel = (m: NodeModel | undefined, held: NodeModel | undefined): boolean => + !!m && !!held && (m.rev ?? 0) < (held.rev ?? 0) + /** What main sends this slice; the IPC adapter calls each as its push or pull lands. */ export const nodesFromMain = { /** `nodes:get`: node pushes only fire on change, so a reloaded renderer asks for the current picture. */ - nodes: (model: NodeModel | undefined): void => useNodesStore.setState({ nodeModel: model }), + nodes: (model: NodeModel | undefined): void => + useNodesStore.setState((s) => (olderModel(model, s.nodeModel) ? s : { nodeModel: model })), /** `nodes:model`. */ - model: (m: NodeModel): void => useNodesStore.setState({ nodeModel: m }), + model: (m: NodeModel): void => + useNodesStore.setState((s) => (olderModel(m, s.nodeModel) ? s : { nodeModel: m })), /** `cockpit:snapshot`. */ - cockpit: (c: CockpitSnapshot): void => useNodesStore.setState({ cockpit: c }) + cockpit: (c: CockpitSnapshot): void => + useNodesStore.setState((s) => (c.rev < s.cockpit.rev ? s : { cockpit: c })) } diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts index 674e2aa..2d12b5d 100644 --- a/src/renderer/test/skyhawk-mock.ts +++ b/src/renderer/test/skyhawk-mock.ts @@ -30,8 +30,9 @@ // `getNodeRegistry` answers it. // // "Add all" and "Un-hide all" run main's own sequence (R3.2b): `addAllNodes` / `unhideNodes` go -// through NodeHub's loop (mapping-sequence), one `adoptNode` / `hideNode` per board — the same -// spies, so a test still sees (and can refuse) each board's call. +// through NodeHub's loop (mapping-sequence, guarded against re-entry as NodeHub's is), one +// `adoptNode` / `hideNode` per board — the same spies, so a test still sees (and can refuse) each +// board's call. // // Calibration runs main's own workflow (R3.1): the mock hosts a real CalibrationWorkflowHost — the // workflow, the dialog's start/close sequencing, the restore offer and the derived CalibrationState @@ -103,7 +104,7 @@ import { shownStatus } from '../../main/gateway/gateway-health' import { captureView, relayFacts, replayView } from '../../main/dcs/relay-rules' import { savedEdit } from '../../main/config/settings-form' import { resolvePsu } from '../../main/nodes/psu-limits' -import { addAllInTurn, unhideInTurn } from '../../main/nodes/mapping-sequence' +import { MappingSequences } from '../../main/nodes/mapping-sequence' import type { NodeModel, NodeRegistryInfo, NodeTelemSnapshot } from '@shared/nodes' import { deliverPushes, @@ -253,6 +254,10 @@ export function installSkyhawkMock(): SkyhawkMock { let state: Scenario = {} const resolve = (v: T) => Promise.resolve(v) const calFail = { ok: false, kind: 'offline', message: 'mock' } as const + /** NodeHub's Add all / Un-hide all, one at a time each (R6). */ + const sequences = new MappingSequences() + /** NodeHub's model rev: one per `nodes:model` push (C5). */ + let modelRev = 0 const fns: Fns = { on: vi.fn((ch, cb) => { @@ -357,8 +362,8 @@ export function installSkyhawkMock(): SkyhawkMock { previewNodeReset: vi.fn(() => resolve({ rows: [], nodeCount: 0, adopted: [] })), hideNode: vi.fn(() => resolve(mapped())), // ── node-mapping sequences: main's loop, over the per-board intents above ── - addAllNodes: vi.fn((bulk) => addAllInTurn(bulk, (id, spec) => fns.adoptNode(id, spec))), - unhideNodes: vi.fn((ids) => unhideInTurn(ids, (id, on) => fns.hideNode(id, on))), + addAllNodes: vi.fn((bulk) => sequences.addAll(bulk, (id, spec) => fns.adoptNode(id, spec))), + unhideNodes: vi.fn((ids) => sequences.unhide(ids, (id, on) => fns.hideNode(id, on))), // Main's SerialTrafficMonitor: the page's flag comes back on `device:status`. setSerialMonitor: vi.fn((on: boolean) => { monitorShown = on @@ -657,7 +662,12 @@ export function installSkyhawkMock(): SkyhawkMock { // and pushes it, unless the scenario says what main pushed. const device = given.calDevice ?? state.calDevice const opened = device && given.device?.state === 'connected' - const s = opened && given.cal === undefined ? { ...given, cal: snapshotOf(device) } : given + const withCal = + opened && given.cal === undefined ? { ...given, cal: snapshotOf(device) } : given + // NodeHub stamps each model it pushes with a rising rev, and `getNodes` answers the last (C5). + const s = withCal.model + ? { ...withCal, model: { ...withCal.model, rev: ++modelRev } } + : withCal if (opened) lastCal = s.cal state = { ...state, ...s } turn(() => { diff --git a/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx b/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx index 1f54db8..07a8833 100644 --- a/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx +++ b/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx @@ -1,7 +1,8 @@ // T10 — the Adopt dialog: its queue, the badges, and the form for each kind of new board. import { describe, expect, it, vi } from 'vitest' -import { screen, within } from '@testing-library/react' +import { act, screen, within } from '@testing-library/react' import { Cockpit } from '../src/app/cockpit/Cockpit' +import { useNav } from '../src/app/kit' import { renderScenario, settle } from './render' import { connected, model, newBoard, pdu } from './scenarios' @@ -57,13 +58,29 @@ describe('T10 Adopt dialog: queue and form (B74, B21, B22)', () => { 'NODE_ID 10BRIDGEfirst seen just now', 'NODE_ID 13UNKNOWN TYPEfirst seen just now' ]) + // R10 (#133): the count is the "New on the bus · N" button's (the boards that can be added), + // not every row listed — it was 6 here, against the button's 3. + expect(screen.getByText('New on the bus · 3')).toBeInTheDocument() expect( within(dlg).getByText( - /^6 boards this computer hasn’t added yet\. Boards in NODE_IDS\.md come pre-filled/ + /^3 boards this computer hasn’t added yet\. Boards in NODE_IDS\.md come pre-filled/ ) ).toBeInTheDocument() }) + it('a request to open another board while the dialog is open lands on that board (R10)', async () => { + const { pick, selected } = await openAdopt() + await pick(12) + expect(selected()).toHaveTextContent('NODE_ID 12') + // Another way into a board (Connection's "›", a cog) asks for #41: the dialog starts over on + // it, as the latest push has it, rather than staying on the board it was showing. + act(() => useNav.getState().openInCockpit(41)) + const [dlg] = screen.getAllByRole('dialog', { name: 'Adopt new devices' }) + expect(screen.getAllByRole('dialog', { name: 'Adopt new devices' })).toHaveLength(1) + const queue = within(dlg!).getByRole('listbox', { name: 'New NODE_IDs' }) + expect(within(queue).getByRole('option', { selected: true })).toHaveTextContent('NODE_ID 41') + }) + it('a registry board comes pre-filled from NODE_IDS.md and is one click to add', async () => { const { dlg, sky, user, selected, button } = await openAdopt() expect(selected()).toHaveTextContent('NODE_ID 8') diff --git a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx index 6b333dc..625cd3e 100644 --- a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx +++ b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx @@ -68,6 +68,29 @@ describe('T11 Adopt dialog: Add all, PDU choices, power-cycle (B74, F15, F24, X3 expect(options().map((o) => o.textContent)).toEqual(['NODE_ID 6PDUPDU_Center · in NODE_IDS.md']) }) + it('a double click on Add all runs it once; the dialog is busy until it lands (R6)', async () => { + const { dlg, sky, user } = await openAdopt(model([PDU_LEFT, PDU_RIGHT, NAV, TACAN, PDU_CENTER])) + // Main answers each board a turn later, so the first run is still going on the second click. + const answers: (() => void)[] = [] + sky.adoptNode.mockImplementation( + () => new Promise((r) => answers.push(() => r({ ok: true, model: sky.scenario.model! }))) + ) + const addAll = within(dlg).getByRole('button', { + name: 'Add all 2 panel groups from NODE_IDS.md' + }) + await user.dblClick(addAll) + // One run in flight: the whole dialog is busy. + expect(addAll).toBeDisabled() + expect(within(dlg).getByRole('button', { name: 'Later' })).toBeDisabled() + expect(within(dlg).getByRole('button', { name: 'Add to cockpit' })).toBeDisabled() + while (answers.length) { + answers.shift()!() + await settle() + } + expect(sky.addAllNodes).toHaveBeenCalledTimes(1) + expect(sky.adoptNode.mock.calls.map(([id]) => id)).toEqual([2, 7]) + }) + it('an untouched form keeps its default PDU only when that PDU is in your cockpit, else None', async () => { const { sky, addAll } = await openAdopt(model([PDU_LEFT, PDU_RIGHT, NAV, TACAN])) await addAll(2) diff --git a/src/renderer/test/t12-settings-hidden-boards.test.tsx b/src/renderer/test/t12-settings-hidden-boards.test.tsx index 0949929..01efcee 100644 --- a/src/renderer/test/t12-settings-hidden-boards.test.tsx +++ b/src/renderer/test/t12-settings-hidden-boards.test.tsx @@ -58,6 +58,35 @@ describe('T12 Settings › Cockpit › Hidden boards (B88)', () => { ]) }) + it('Un-hide all with one board failing un-hides the rest and lists the failure (N4)', async () => { + // Three hidden; main can't save #8. It used to stop there: #30 and #41 were never tried. + const { sky, user, unhideAll } = await openCockpitSection( + model([panel(1, 'Center_Armament')], [], { + hidden: [8, 30, 41], + hiddenNodes: [ + { nodeId: 8, name: 'Right_Radar', console: 'Right', kind: 'panel', source: 'registry' }, + { nodeId: 30, name: 'Left_Spare', console: '', kind: 'panel', source: 'adopted' }, + { nodeId: 41, name: 'Left_Lights', console: 'Left', kind: 'panel', source: 'adopted' } + ] + }) + ) + sky.hideNode.mockImplementation(async (id: number) => + id === 8 + ? { ok: false as const, error: 'permission denied in userData' } + : { ok: true as const, model: sky.scenario.model! } + ) + await user.click(unhideAll()) + await settle() + expect(sky.hideNode.mock.calls).toEqual([ + [8, false], + [30, false], + [41, false] + ]) + expect( + screen.getByText('Couldn’t un-hide #8 Right_Radar: permission denied in userData') + ).toBeInTheDocument() + }) + it('a refused un-hide shows main’s error', async () => { const { sky, user, unhide } = await openCockpitSection(twoHidden()) sky.hideNode.mockResolvedValueOnce({ ok: false, error: 'Node mapping is read-only' }) diff --git a/src/renderer/test/t30-settings-reset.test.tsx b/src/renderer/test/t30-settings-reset.test.tsx index 0aea798..29c2678 100644 --- a/src/renderer/test/t30-settings-reset.test.tsx +++ b/src/renderer/test/t30-settings-reset.test.tsx @@ -159,6 +159,38 @@ describe('T30 Settings › Cockpit › Reset… (B89, B24)', () => { expect(screen.getByRole('dialog', { name: 'Reset node mapping' })).toBeInTheDocument() }) + it('the review asks main again only when the mapping changes, not on every push (R9)', async () => { + const { sky, openReview } = await openCockpit(fileModel()) + const dlg = await openReview() + expect(sky.previewNodeReset).toHaveBeenCalledTimes(1) + // Roster polls and PDU pages push the model and the snapshot again; the mapping is the same. + sky.emitScenario({ model: fileModel() }) + sky.emitScenario({ + model: model( + [ + panel(2, 'Nav', { pduId: 3, differs: true, availability: 'offline' }), + pdu(3, 'PDU_Left'), + adoptedTrim() + ], + [], + { differingCount: 1 } + ) + }) + await settle() + expect(sky.previewNodeReset).toHaveBeenCalledTimes(1) + // The mapping changes underneath (Nav renamed elsewhere): the review asks again. + sky.emitScenario({ + model: model( + [panel(2, 'Nav_2', { pduId: 3, differs: true }), pdu(3, 'PDU_Left'), adoptedTrim()], + [], + { differingCount: 1 } + ) + }) + await settle() + expect(sky.previewNodeReset).toHaveBeenCalledTimes(2) + expect(within(dlg).getByRole('button', { name: 'Reset 1 node' })).toBeEnabled() + }) + it('Cancel closes the review without asking main', async () => { const { sky, user, openReview } = await openCockpit(fileModel()) const dlg = await openReview() diff --git a/src/shared/app/ipc.compat.test-d.ts b/src/shared/app/ipc.compat.test-d.ts index d3ef279..1cd257d 100644 --- a/src/shared/app/ipc.compat.test-d.ts +++ b/src/shared/app/ipc.compat.test-d.ts @@ -99,6 +99,11 @@ // flag as main holds them (no type change there). `CalibrationTabState` (`cal:workflow`) gained // `badgeOpens`: the badges follow the Calibrate button's link rule (a type change only). // +// Fix track #133 (N4, Un-hide all): a type change only — no channel, intent or method was added, +// removed or renamed. `NodeBatchResult`'s refused case (`addAllNodes`, `unhideNodes`) gained +// `failed`: every board refused, in order. Un-hide all now tries every board and lists each one +// it couldn't un-hide; Add all still stops at its first refusal (one entry). +// // Type-only: never imported, never run, not matched by vitest (`*.test.ts`) or the build. import type { Mutate, StoreApi } from 'zustand/vanilla' import type {} from 'zustand/middleware' // registers the subscribeWithSelector store mutator diff --git a/src/shared/nodes/ipc.ts b/src/shared/nodes/ipc.ts index 3eff230..0a3bb3f 100644 --- a/src/shared/nodes/ipc.ts +++ b/src/shared/nodes/ipc.ts @@ -317,6 +317,12 @@ export interface NodeModel { * 'unknown', since there is no roster to be missing from. */ rosterComplete: boolean + /** + * Rises with every `nodes:model` push (C5). A `nodes:get` answer carries the rev of the last push + * it is at least as new as, so the renderer never lets an older model replace a newer one. Absent + * only in a model not from main (a test's fixture). + */ + rev?: number } /** Rail limits as printed on a PSU label. Absent = unknown: the Cockpit shows plain totals. */ @@ -396,11 +402,19 @@ export interface AdoptRequest { /** * Mapping changes run one board at a time ("Add all", "Un-hide all"; R3.2): the NODE_IDs done in - * order, and — when one was refused — that one and why. The boards after it were not tried. + * order, and — when any was refused — the first one and why, and every refusal in `failed`. Add all + * stops at its first refusal (the boards after it were not tried); Un-hide all tries every board + * (N4). */ export type NodeBatchResult = | { ok: true; done: number[] } - | { ok: false; done: number[]; nodeId: number; error: string } + | { + ok: false + done: number[] + nodeId: number + error: string + failed: { nodeId: number; error: string }[] + } // ── consoles ──────────────────────────────────────────────────────────────── From 32d660d5bdcae6091d66690fe960b039ad8ce136 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 14:19:01 -0400 Subject: [PATCH 2/2] fix(nodes): new boards counted on every tab, grace from coming online, one log of the physical bus (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix-track chunk 4 (#133), the new-boards and event-log items. Behaviour changes on purpose (decided 2026-10-16); nothing else does. Rebased on `fix/chunk-4` after #153 (one fault order); its changes are kept as they are. ## 1. D1 / X3 / R2: strays on every tab | | Today | After | |---|---|---| | Strays | The 5 s "UNIDENTIFIED" (or SECOND GATEWAY / UNKNOWN BOARD TYPE) warning counted on the Cockpit only. The Overview tile read "no nodes known", with no banner; Connected nodes had no row. | One `newState(entry, now, live)` in `node-model.ts`: `news` (adoptable), `identifying` (with `strayAt`), `stray`, or none. `isReportableNew` is built on it, and `attention.ts strayNew` reads it. `buildCockpitSnapshot` hands the same `strays` to `overviewSummary` and `nodesSummary`. The Cockpit, the Overview (tile and banner) and Connection (counts plus a stray row with chip `… · can’t be adopted`, no zone) all count it after 5 s. `NodesSummary.known` is the band's total, so a lone stray isn't hidden behind "No nodes reported". | `IDENTIFY_WAIT_MS` and `strayKind` moved into `node-model.ts`. `strayFlag` is `strayKind` in capitals. ## 2. S6: grace from coming online | | Today | After | |---|---|---| | Grace | Counted from `firstSeenAt`, which an absent roster row also sets. A board listed absent and coming online later was flagged at once. | `NodeRoster` keeps `NodeSeen.onlineSince`: set on a present row after an absent one (or the first present row), cleared when the board goes absent (delta or END). The model copies it onto new entries (`NodeModelEntry.onlineSince`), and `newState` counts the grace from it. | ## 3. X8 / C6: "from NODE_IDS.md" wording | | Today | After | |---|---|---| | A registry board waiting to be added | `nodeHealth` flag `NOT IN REGISTRY`, the `nodeFaultBoxes` box "Not in registry", log detail `in NODE_IDS.md` / `not in registry`. The Overview already said "from NODE_IDS.md". | One `newReason(entry)` in `node-model.ts`: `from NODE_IDS.md` / `not in NODE_IDS.md`. The flag is `FROM NODE_IDS.md` / `NOT IN NODE_IDS.md`. The box title is `From NODE_IDS.md` (a registry board's text no longer says it isn't listed). The log's "new on the bus" detail reads the same. | A probe test (`main/nodes/phase0-probes` "X8 / C6") scans every `.ts`/`.tsx` in `src` for `not in registry` (any case) and the old log literal `'in NODE_IDS.md'`, and fails on a hit. It was shown to fail when `// NOT IN REGISTRY` was appended to `views.ts`. **Left alone:** `AdoptDialog.tsx`'s queue label ` · in NODE_IDS.md` is the renderer's own wording for a registry board in the Adopt queue. It was out of scope (that dialog was another PR's file), so it isn't in the probe. ## 4. D8 / S5 / C4: the log is the physical bus | | Today | After | |---|---|---| | Boards outside your cockpit | Their offline / bus-off / flag rows were errors and warnings like your own boards' rows. A stray never got any row. | Every board on the bus is still logged. A row about a board outside your cockpit is at **info**, with the detail suffix `not in your cockpit` (one `row()` in the tracker). A stray gets one `new on the bus · · can’t be adopted` row at info once `newState` says `stray`. If it later identifies as adoptable, it gets its `new` row then. | | "N online" | Counted every board, with nothing to tell them apart. | Still counts the whole bus, now with `· K not in your cockpit` when some aren't yours. | | "New" | Re-derived in NodeHub (`isNew`: not known and an adoptable `kindOf`). Only checked on status rows, so a board identified only by telemetry got no row until its next roster row. | `announce()` reads `newState` from the model entry, built at most once per line, so it isn't re-derived. It is checked on status **and** telemetry lines: a PDU identified by its pages gets its row at those pages. | | "Seen new" record | Survived Forget, so Forget then re-plug logged nothing new. | `NodeEventTracker.cockpitChanged()` runs after every successful mapping change. It drops the record for boards now in your cockpit, so a forgotten board that returns is new again. | | Telemetry-only boards | No last seen, and no online / offline rows. | NodeHub's `busTransitions` (was `rosterTransitions`) uses `onBusIds`: the one `availabilityOf` rule over roster ∪ telemetry. It runs on roster changes and on page takes, so last seen and un-hide follow pages too. `pagesOnline()` gives a board online by its pages alone (`onlineByPages`) `online` / `offline · no telemetry` rows. If the roster later lists it, no second `online`. | | Degraded | A new fault id while still degraded logged another `degraded` row. | Once per episode: raised, then cleared. | ## Tests changed on purpose | Test | Was | Now | Why | |---|---|---|---| | T1 `the Overview does not count the same stray (X1 today)` | "no nodes known", no banner | Banner `1 node needs attention — 1 warning (NODE_ID 9)` | D1 | | T1 `the 5 s grace counts from firstSeenAt … (F26 today)` | Stray at once | No stray at first, a stray 5 s after `onlineSince` | S6 / F26 | | T3 `the Overview doesn’t count a stray (X1 today)` | `—` / "no nodes known" | Tile `1 / 1`, banner names NODE_ID 9 | D1 | | T4 `a board still identifying, or one that can’t be adopted, is not mentioned` | No status role at all | Notice still absent; the banner names the stray (fake clock at T0 so #9 is still identifying) | D1: the stray is a warning, not news | | T34 the agreement cases (header + 2 tests) | Overview / Connection without the stray | All three count it (`4 / 7`, Connection 2/2/2 with the stray's row; next push `6 / 7`, 5/1/0) | D1 | | `on-the-bus.test` link up | Connected nodes `known 2`, rows `[2]` | `known 3, warn 1`, rows `[2, 9]` | D1 (Connection strays-absent case) | | `overview-summary.test` `… without strays` | Band and items without strays | Same equality, `strays` passed; plus a new stray case | D1 | | events `lookup()` stub (and the #153 `faults.test` stub, call-site only) | `isNew` / `unmapped` / `inRegistry` | `outside` / `entry` (the model entry) | "New" from `newState` | | events: registry / not-in-registry details | `in NODE_IDS.md` / `not in registry` | `from NODE_IDS.md` / `not in NODE_IDS.md` | X8 | | events `reports a new node once per app run…` | `roster: 1 node online` | `… · 1 not in your cockpit` | D8 label | | events `a new node coming back in a later port session…` | `online [info]`, `offline · no heartbeat [error]` | `… · not in your cockpit [info]` | D8 | | events `degraded … a new fault id is a new row` | Second row for fault 9 | No row; after a clear, a new episode logs | Degraded once per episode | | `node-hub.seam` B33 | No row at the pages; one at the next status row | One row at the pages, none after | C4 | | main `phase0-probes` C4 | Same as B33 | Fixed; plus Forget → re-plug logs "new" again; plus the X8 source probe | C4 / X8 | | renderer `phase0-probes` D1, S6, C6 | Today's behaviour | The fixed behaviour | D1 / S6 / X8 | | `node-hub.test` S1 kind stored | `{ added, kind }` after PDU pages | Also `lastSeen` | Telemetry-only boards update last seen | | `node-status-wire.test` seen | `{ firstSeenAt, reportedAt }` | Includes `onlineSince` (reset on coming back) | S6 | | T22 log fixture | `detail: 'in NODE_IDS.md'` | `from NODE_IDS.md` | Fixture mirrors main's wording; the X8 probe covers it | **Inputs only, assertions unchanged:** `scenarios.newBoard` also sets `onlineSince` (its doc already said "online since firstSeenAt"), and the `adopt.test` / `cockpit-host.test` / `on-the-bus.test` stray fixtures set `onlineSince`. `nodesSummary` / `overviewSummary` gained the `strays` argument (`[]` in the old cases). The **NODE_ID 0 overview cases** (`takes / treats NODE_ID 0 like any other node`) are unchanged and still pass. Nothing here changes how the summaries treat a NODE_ID 0 entry, and the model has excluded node 0 since #344. ## New tests - `node-model.test`: `newState` (news / stray / identifying with `strayAt`, none off the bus or unplugged), `onlineSince` on the model, `newReason`, `strayKind`, `onlineByPages`. - `node-status-wire.test`: `onlineSince` cleared by absent row or END, reset on coming back. - `adopt.test`: grace from `onlineSince`. - `overview-summary.test` and `connection-summary.test`: a stray counted with its row. - events: outside rows at info and labelled; baseline count; stray announcements; stray → news; telemetry announce; `cockpitChanged`; `pagesOnline`. - New `main/nodes/new-boards.seam.test.ts`, through `makeServices()` over the fake serial port: - a stray on all three tabs after 5 s; - the grace from coming online; - the `FROM NODE_IDS.md` flag and log row; - outside rows at info and the roster label; - Forget → re-plug logs new again; - a pages-only PDU's last seen and its online / offline rows. ## How verified - `npm run lint`, `npm run lint:deps`, `npm run typecheck`, `npm test`: all green (1434 tests). - Not run on hardware. The chunk's hardware checklist is in #133. API guard: header note only. `NodeModelEntry` and `NodeSeen` gained optional `onlineSince`; there is no channel or intent change. --- src/main/events/events.test.ts | 155 +++++++++++-- src/main/nodes/cockpit-host.test.ts | 11 +- src/main/nodes/cockpit-snapshot/adopt.test.ts | 17 +- src/main/nodes/cockpit-snapshot/attention.ts | 38 ++-- .../connection-summary.test.ts | 28 ++- src/main/nodes/cockpit-snapshot/index.ts | 34 ++- .../nodes/cockpit-snapshot/on-the-bus.test.ts | 16 +- .../cockpit-snapshot/overview-summary.test.ts | 22 +- src/main/nodes/cockpit-snapshot/panels.ts | 9 +- src/main/nodes/cockpit-snapshot/summaries.ts | 28 ++- src/main/nodes/cockpit-snapshot/views.ts | 9 +- src/main/nodes/faults.test.ts | 4 +- src/main/nodes/new-boards.seam.test.ts | 159 +++++++++++++ src/main/nodes/node-events.ts | 208 +++++++++++++----- src/main/nodes/node-hub.seam.test.ts | 11 +- src/main/nodes/node-hub.test.ts | 8 +- src/main/nodes/node-hub.ts | 91 +++++--- src/main/nodes/node-model.test.ts | 66 +++++- src/main/nodes/node-model.ts | 64 +++++- src/main/nodes/node-status-wire.test.ts | 21 +- src/main/nodes/node-status-wire.ts | 19 +- src/main/nodes/phase0-probes.test.ts | 43 +++- src/renderer/src/nodes/AdoptDialog.tsx | 2 +- src/renderer/src/nodes/node-view.test.ts | 1 + src/renderer/src/nodes/phase0-probes.test.ts | 43 ++-- src/renderer/test/scenarios.ts | 1 + .../test/t1-cockpit-attention-strays.test.tsx | 23 +- .../test/t10-adopt-dialog-queue-form.test.tsx | 2 +- .../test/t11-adopt-dialog-add-all.test.tsx | 4 +- .../test/t18-node-panel-status.test.tsx | 2 +- src/renderer/test/t22-log-events.test.tsx | 6 +- .../test/t3-overview-tile-banners.test.tsx | 13 +- .../t34-overview-cockpit-agreement.test.tsx | 34 +-- .../test/t4-overview-new-devices.test.tsx | 9 +- src/shared/app/ipc.compat.test-d.ts | 5 + src/shared/nodes/ipc.ts | 15 +- 36 files changed, 961 insertions(+), 260 deletions(-) create mode 100644 src/main/nodes/new-boards.seam.test.ts diff --git a/src/main/events/events.test.ts b/src/main/events/events.test.ts index b0f1a66..7d846f7 100644 --- a/src/main/events/events.test.ts +++ b/src/main/events/events.test.ts @@ -7,18 +7,22 @@ import { NodeEventTracker, type NodeLookup } from '../nodes/node-events' import type { LogEventInput } from '@shared/events' import { eventText } from '@shared/format' import { NODE_END_MSG, NODE_MSG, ROSTER_REPLY_MS } from '../nodes/node-status-wire' -import type { NodeStatus, NodeTelem } from '@shared/nodes' +import type { NodeModelEntry, NodeStatus, NodeTelem } from '@shared/nodes' import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' -import { nodeStatus } from '../../test-fixtures/nodes' +import { modelNode, nodeStatus } from '../../test-fixtures/nodes' const st = (nodeId: number, over: Partial = {}): NodeStatus => nodeStatus(nodeId, { uptimeSec: 0, rxCount: 0, dieTempC: null, ...over }) const NAMES: Record = { 2: 'Right_Navigation', 4: 'PDU_Right', 7: 'Left_Fuel' } -const lookup = (): NodeLookup => ({ +/** Boards in your cockpit are NAMES; any other id is outside it, on the bus as a panel group. */ +const lookup = (entries: Record> = {}): NodeLookup => ({ name: (id) => NAMES[id] ?? `Node ${id}`, - isNew: (id) => !(id in NAMES), - unmapped: (id) => !(id in NAMES) + outside: (id) => !(id in NAMES), + entry: (id) => + id in NAMES + ? undefined + : modelNode(id, { source: 'new', identified: 'panel', onlineSince: 0, ...entries[id] }) }) /** Short form for assertions: `name: event · detail [severity]`. */ @@ -110,14 +114,19 @@ describe('NodeEventTracker — changes', () => { ]) }) - it('degraded carries the fault label (or Fault N), and a new fault id is a new row', () => { + it('degraded carries the fault label (or Fault N), once per episode (D8, #133)', () => { const t = live([st(2)]) const deg = (faultId: number, faultLabel?: string) => st(2, { degraded: true, faultId, faultLabel }) expect(short(t.status([deg(2, 'I2C peripheral')], NODE_MSG, '', 20))).toEqual([ 'Right_Navigation: degraded · I2C peripheral [warn]' ]) - expect(short(t.status([deg(9)], NODE_MSG, '', 30))).toEqual([ + // Was: a new fault id while degraded was a new row. Same episode: none. + expect(short(t.status([deg(9)], NODE_MSG, '', 30))).toEqual([]) + expect(short(t.status([st(2)], NODE_MSG, '', 35))).toEqual([ + 'Right_Navigation: degraded cleared [info]' + ]) + expect(short(t.status([deg(9)], NODE_MSG, '', 37))).toEqual([ 'Right_Navigation: degraded · Fault 9 [warn]' ]) expect(short(t.status([st(2)], NODE_MSG, '', 40))).toEqual([ @@ -136,24 +145,25 @@ describe('NodeEventTracker — changes', () => { ]) }) - it('a registry board not added yet is new too, and says it is in NODE_IDS.md (#93)', () => { - const t = new NodeEventTracker({ ...lookup(), inRegistry: (id) => id === 41 }) + it('a registry board not added yet is new too, and says it is from NODE_IDS.md (#93, X8)', () => { + const registry = { name: 'Left_Oxygen', console: 'Left', pduId: null } + const t = new NodeEventTracker(lookup({ 41: { registry } })) t.status([st(2)], NODE_MSG, '', 0) t.status([st(2)], NODE_END_MSG, '_NODE_STATUS_END', 10) expect(short(t.status([st(2), st(41)], NODE_MSG, '', 20))).toEqual([ - 'Node 41: new on the bus · in NODE_IDS.md [new]' + 'Node 41: new on the bus · from NODE_IDS.md [new]' ]) }) it('reports a new node once per app run, even across a reopen', () => { const t = live([st(2)]) expect(short(t.status([st(2), st(41)], NODE_MSG, '', 20))).toEqual([ - 'Node 41: new on the bus · not in registry [new]' + 'Node 41: new on the bus · not in NODE_IDS.md [new]' ]) t.reset() t.status([st(41)], NODE_MSG, '', 30) expect(short(t.status([st(41)], NODE_END_MSG, '', 31))).toEqual([ - 'roster: 1 node online [info]' + 'roster: 1 node online · 1 not in your cockpit [info]' ]) }) @@ -163,18 +173,133 @@ describe('NodeEventTracker — changes', () => { t.reset() // port reopens with 41 unplugged t.status([st(2)], NODE_MSG, '', 30) expect(short(t.status([st(2)], NODE_END_MSG, '', 31))).toEqual(['roster: 1 node online [info]']) - expect(short(t.status([st(2), st(41)], NODE_MSG, '', 40))).toEqual(['Node 41: online [info]']) + expect(short(t.status([st(2), st(41)], NODE_MSG, '', 40))).toEqual([ + 'Node 41: online · not in your cockpit [info]' + ]) expect(short(t.status([st(2), st(41, { present: false })], NODE_MSG, '', 50))).toEqual([ - 'Node 41: offline · no heartbeat [error]' + 'Node 41: offline · no heartbeat · not in your cockpit [info]' ]) }) it('a new node seen at baseline still gets its row', () => { const t = new NodeEventTracker(lookup()) expect(short(t.status([st(41)], NODE_MSG, '', 0))).toEqual([ - 'Node 41: new on the bus · not in registry [new]' + 'Node 41: new on the bus · not in NODE_IDS.md [new]' + ]) + }) +}) + +describe('NodeEventTracker — the physical bus (D8 / S5 / C4, #133)', () => { + it('a board outside your cockpit: every row at info, labelled; yours keep their severity', () => { + const t = live([st(2)]) + t.status([st(2), st(41)], NODE_MSG, '', 20) // new on the bus + expect( + short( + t.status( + [st(2, { boff: true }), st(41, { boff: true, epvf: true, tec: 140 })], + NODE_MSG, + '', + 30 + ) + ) + ).toEqual([ + 'Right_Navigation: bus-off [error]', + 'Node 41: bus-off · not in your cockpit [info]', + 'Node 41: error-passive · TEC 140 · not in your cockpit [info]' + ]) + }) + + it('the baseline counts every board on the bus, and says how many are not yours', () => { + const t = new NodeEventTracker(lookup()) + t.status([st(2), st(41), st(42)], NODE_MSG, '', 0) + const out = t.status([st(2), st(41), st(42)], NODE_END_MSG, 'end', 1) + expect(short(out)).toEqual(['roster: 3 nodes online · 2 not in your cockpit [info]']) + }) + + it('a stray is logged once newState says so: at once when it can’t be adopted, else after 5 s', () => { + const t = new NodeEventTracker( + lookup({ + 9: { identified: 'bridge' }, + 10: { identified: undefined, onlineSince: 0 } + }) + ) + t.status([st(2)], NODE_END_MSG, '', 0) + // #9 says it is a Gateway: a stray at once. #10 says nothing: still identifying at 1 s. + expect(short(t.status([st(2), st(9), st(10)], NODE_MSG, '', 1000))).toEqual([ + 'Node 9: new on the bus · second Gateway · can’t be adopted [info]' + ]) + expect(short(t.status([st(2), st(9), st(10)], NODE_MSG, '', 5000))).toEqual([ + 'Node 10: new on the bus · unidentified · can’t be adopted [info]' + ]) + expect(t.status([st(2), st(9), st(10)], NODE_MSG, '', 10_000)).toEqual([]) // once + }) + + it('a stray that later identifies as something adoptable is news then', () => { + const entries: Record> = { 10: { identified: undefined } } + const t = new NodeEventTracker(lookup(entries)) + t.status([st(2)], NODE_END_MSG, '', 0) + expect(short(t.status([st(2), st(10)], NODE_MSG, '', 6000))).toEqual([ + 'Node 10: new on the bus · unidentified · can’t be adopted [info]' + ]) + entries[10] = { identified: 'panel' } + expect(short(t.status([st(2), st(10, { nodeKind: 1 })], NODE_MSG, '', 7000))).toEqual([ + 'Node 10: new on the bus · not in NODE_IDS.md [new]' ]) }) + + it('PDU pages that identify a board outside your cockpit make its "new on the bus" row', () => { + const t = new NodeEventTracker(lookup({ 42: { kind: 'pdu', identified: 'pdu' } })) + t.status([st(2)], NODE_END_MSG, '', 0) + const page: NodeTelem = { + nodeId: 42, + kind: 'rail', + page: 1, + flags: 0, + vAvgMv: 12000, + vMinMv: 11900, + iAvgMa: 500 + } + expect(short(t.telemetry(page, 'p', 1000))).toEqual([ + 'Node 42: new on the bus · not in NODE_IDS.md [new]' + ]) + expect(t.telemetry(page, 'p', 2000)).toEqual([]) + }) + + it('the "seen new" record goes once the board is in your cockpit: forgotten, it is new again', () => { + const outside = new Set([41]) + const t = new NodeEventTracker({ + ...lookup(), + outside: (id) => !(id in NAMES) && outside.has(id) + }) + t.status([st(2)], NODE_END_MSG, '', 0) + expect(short(t.status([st(2), st(41)], NODE_MSG, '', 10))).toEqual([ + 'Node 41: new on the bus · not in NODE_IDS.md [new]' + ]) + outside.delete(41) // adopted + t.cockpitChanged() + t.status([st(2), st(41, { present: false })], NODE_MSG, '', 20) // unplugged + outside.add(41) // forgotten + t.cockpitChanged() + expect(short(t.status([st(2), st(41)], NODE_MSG, '', 30))).toEqual([ + 'Node 41: new on the bus · not in NODE_IDS.md [new]', + 'Node 41: online · not in your cockpit [info]' + ]) + }) + + it('a board online by its pages alone gets online / offline rows; the roster taking over adds none', () => { + const t = live([st(2)]) + const pdu = (over: Partial = {}) => + modelNode(4, { kind: 'pdu', status: undefined, ...over }) + expect(short(t.pagesOnline([pdu()], 20))).toEqual(['PDU_Right: online [info]']) + expect(t.pagesOnline([pdu()], 30)).toEqual([]) // still online + expect(short(t.pagesOnline([pdu({ availability: 'offline' })], 40))).toEqual([ + 'PDU_Right: offline · no telemetry [error]' + ]) + expect(short(t.pagesOnline([pdu()], 50))).toEqual(['PDU_Right: online [info]']) + // The roster lists it now: no second "online", and its pages no longer decide. + expect(t.status([st(2), st(4)], NODE_MSG, '', 60)).toEqual([]) + expect(t.pagesOnline([pdu({ status: st(4) })], 70)).toEqual([]) + }) }) describe('NodeEventTracker — PDU telemetry', () => { diff --git a/src/main/nodes/cockpit-host.test.ts b/src/main/nodes/cockpit-host.test.ts index 7e7efe3..99401dc 100644 --- a/src/main/nodes/cockpit-host.test.ts +++ b/src/main/nodes/cockpit-host.test.ts @@ -166,7 +166,12 @@ describe('CockpitSnapshotHost', () => { it('a board still identifying: rebuilt nextAt + 50 ms later, when it becomes a stray', () => { vi.useFakeTimers({ now: 10_000 }) const { host, pushed } = rig() - const fresh = modelNode(9, { source: 'new', console: null, firstSeenAt: 10_000 }) + const fresh = modelNode(9, { + source: 'new', + console: null, + firstSeenAt: 10_000, + onlineSince: 10_000 + }) host.modelPushed(nodeModel([fresh])) expect(pushed.at(-1)!.attention.items).toEqual([]) const due = 10_000 + IDENTIFY_WAIT_MS + STRAY_RECHECK_MS @@ -186,7 +191,7 @@ describe('CockpitSnapshotHost', () => { it('keeps one timer per nextAt, as the renderer effect did, and drops it once nothing waits', () => { vi.useFakeTimers({ now: 0 }) const { host, pushed } = rig() - const a = modelNode(9, { source: 'new', console: null, firstSeenAt: 0 }) + const a = modelNode(9, { source: 'new', console: null, firstSeenAt: 0, onlineSince: 0 }) host.modelPushed(nodeModel([a])) host.telemetryPushed([]) // same nextAt: the timer stays vi.advanceTimersByTime(2000) @@ -201,7 +206,7 @@ describe('CockpitSnapshotHost — the strays’ clock', () => { it('a push inside the 50 ms after a board is due is judged at the old time, as the tab was', () => { vi.useFakeTimers({ now: 0 }) const { host, pushed } = rig() - const a = modelNode(9, { source: 'new', console: null, firstSeenAt: 0 }) + const a = modelNode(9, { source: 'new', console: null, firstSeenAt: 0, onlineSince: 0 }) host.modelPushed(nodeModel([a])) vi.advanceTimersByTime(IDENTIFY_WAIT_MS + 20) host.telemetryPushed([]) diff --git a/src/main/nodes/cockpit-snapshot/adopt.test.ts b/src/main/nodes/cockpit-snapshot/adopt.test.ts index f43f62f..3a76942 100644 --- a/src/main/nodes/cockpit-snapshot/adopt.test.ts +++ b/src/main/nodes/cockpit-snapshot/adopt.test.ts @@ -258,7 +258,7 @@ describe('strayNew', () => { const ids = (r: ReturnType) => r.strays.map((v) => v.entry.nodeId) it('waits IDENTIFY_WAIT_MS for an unknown board, and says when the next one is due', () => { - const views = at([fresh(41, { firstSeenAt: 0 }), fresh(43, { firstSeenAt: 3000 })]) + const views = at([fresh(41, { onlineSince: 0 }), fresh(43, { onlineSince: 3000 })]) expect(strayNew(views, 4000, true)).toEqual({ strays: [], nextAt: 5000 }) const r = strayNew(views, 5000, true) expect(ids(r)).toEqual([41]) @@ -269,13 +269,20 @@ describe('strayNew', () => { it('takes a bridge or an unknown type at once; never an adoptable or offline node', () => { const views = at([ - fresh(44, { identified: 'bridge', firstSeenAt: 1000 }), - fresh(45, { identified: 'other', firstSeenAt: 1000 }), - fresh(41, { identified: 'panel', firstSeenAt: 0 }), - fresh(46, { availability: 'offline', firstSeenAt: 0 }) + fresh(44, { identified: 'bridge', onlineSince: 1000 }), + fresh(45, { identified: 'other', onlineSince: 1000 }), + fresh(41, { identified: 'panel', onlineSince: 0 }), + fresh(46, { availability: 'offline', onlineSince: 0 }) ]) expect(ids(strayNew(views, 1000, true))).toEqual([44, 45]) }) + + it('counts the grace from when it came online, not from when it was first seen (S6)', () => { + // First seen (absent) at 0, online at 60 s: identifying until 65 s. + const views = at([fresh(9, { firstSeenAt: 0, onlineSince: 60_000 })]) + expect(strayNew(views, 60_001, true)).toEqual({ strays: [], nextAt: 65_000 }) + expect(ids(strayNew(views, 65_000, true))).toEqual([9]) + }) }) describe('strayFlag', () => { diff --git a/src/main/nodes/cockpit-snapshot/attention.ts b/src/main/nodes/cockpit-snapshot/attention.ts index 6cb2cb4..9f18621 100644 --- a/src/main/nodes/cockpit-snapshot/attention.ts +++ b/src/main/nodes/cockpit-snapshot/attention.ts @@ -13,8 +13,7 @@ import type { } from '@shared/nodes' import { lastSeenLabel } from '@shared/format' import { shownHealth } from '../../gateway' -import { isReportableNew, onBusNow } from '../node-model' -import { adoptability } from './adopt' +import { isReportableNew, newState, strayKind } from '../node-model' /** "1 node affected" / "3 nodes affected" — a PDU outage, wherever it is shown. */ export function affected(n: number): string { @@ -80,18 +79,15 @@ export function newCount(model: NodeModel, live: boolean): number { return model.nodes.filter((e) => isReportableNew(e, live)).length } -/** - * A normal board reports its kind ~1 s after it appears. Until this has passed, an unidentified - * new board is simply still identifying — not an issue — so it never flashes a warning. - */ -export const IDENTIFY_WAIT_MS = 5000 +/** The identify grace (node-model.ts newState), re-exported where the strays are built. */ +export { IDENTIFY_WAIT_MS } from '../node-model' /** - * New boards on the bus now (onBusNow) that can't be adopted (#93): a kind that can't be adopted (a - * second SimGateway, a type this client doesn't know), or still unidentified after - * IDENTIFY_WAIT_MS. A board on the bus is active, so it can't be hidden — it shows in Attention as - * a warning instead. None while the link is down. `nextAt` = when one still identifying will cross - * the line. + * The strays (#93, X3): the boards newState calls `stray` — new, on the bus now, and can't be + * adopted (a kind that can't be, or still unidentified IDENTIFY_WAIT_MS after it came online). One + * list for every tab: the Cockpit's band and Attention, the Overview's tile and banner, Connection › + * Connected nodes. A board on the bus is active, so it can't be hidden — it is a warning instead. + * None while the link is down. `nextAt` = when one still identifying will cross the line. */ export function strayNew( views: NodeView[], @@ -101,24 +97,16 @@ export function strayNew( const strays: NodeView[] = [] let nextAt: number | undefined for (const v of views) { - const e = v.entry - if (e.source !== 'new' || !onBusNow(e, live) || adoptability(e).kind) continue - const due = (e.firstSeenAt ?? 0) + IDENTIFY_WAIT_MS - if (e.identified || due <= now) strays.push(v) - else nextAt = Math.min(nextAt ?? due, due) + const s = newState(v.entry, now, live) + if (s?.state === 'stray') strays.push(v) + else if (s?.state === 'identifying') nextAt = Math.min(nextAt ?? s.strayAt, s.strayAt) } return nextAt === undefined ? { strays } : { strays, nextAt } } -/** A stray's Attention line: what it is, and that it can't be adopted. */ +/** A stray's Attention line: what it is (node-model.ts strayKind), and that it can't be adopted. */ export function strayFlag(e: NodeModelEntry): string { - const what = - e.identified === 'bridge' - ? 'SECOND GATEWAY' - : e.identified - ? 'UNKNOWN BOARD TYPE' - : 'UNIDENTIFIED' - return `${what} · can’t be adopted` + return `${strayKind(e).toUpperCase()} · can’t be adopted` } /** diff --git a/src/main/nodes/cockpit-snapshot/connection-summary.test.ts b/src/main/nodes/cockpit-snapshot/connection-summary.test.ts index fc09fd0..a6545f1 100644 --- a/src/main/nodes/cockpit-snapshot/connection-summary.test.ts +++ b/src/main/nodes/cockpit-snapshot/connection-summary.test.ts @@ -47,6 +47,7 @@ const summary = ( return nodesSummary( nodeViews(m, telemetry), outages, + [], boardZones(m.nodes, cockpitMembers(m).get), true ) @@ -95,6 +96,29 @@ describe('nodesSummary counts', () => { }) }) +describe('nodesSummary strays (D1 / X3)', () => { + it('counts a stray as the Cockpit band does — one up, one warning — with its Attention row', () => { + const m = model([node(1), node(41, { name: 'Node 41', source: 'new', identified: 'bridge' })]) + const views = nodeViews(m, []) + const zones = boardZones(m.nodes, cockpitMembers(m).get) + const strays = [{ nodeId: 41, flag: 'SECOND GATEWAY · can’t be adopted' }] + const before = nodesSummary(views, [], [], zones, true) + expect([before.known, before.healthy, before.warn, before.rows.length]).toEqual([1, 1, 0, 0]) + const s = nodesSummary(views, [], strays, zones, true) + expect([s.known, s.healthy, s.warn, s.off]).toEqual([2, 1, 1, 0]) + expect(s.rows).toEqual([ + { + nodeId: 41, + name: 'NODE_ID 41', + state: 'warn', + chip: 'SECOND GATEWAY · can’t be adopted', + tone: 'warn', + meta: '#41' + } + ]) + }) +}) + describe('nodesSummary rows', () => { it('a board not seen this session: no false "offline since", but when it was last seen (#93)', () => { const t = Date.UTC(2026, 9, 14, 21, 13) @@ -136,7 +160,9 @@ describe('nodesSummary rows', () => { ] } const meta = (mem: ReturnType) => - nodesSummary(nodeViews(m, []), [], boardZones(m.nodes, mem.get), true).rows.map((r) => r.meta) + nodesSummary(nodeViews(m, []), [], [], boardZones(m.nodes, mem.get), true).rows.map( + (r) => r.meta + ) expect(meta(cockpitMembers(m))).toEqual([ `#13 · PDU_Left (hidden) · offline since ${fmtTime(T).slice(0, 8)}` ]) diff --git a/src/main/nodes/cockpit-snapshot/index.ts b/src/main/nodes/cockpit-snapshot/index.ts index 24f7485..58d160b 100644 --- a/src/main/nodes/cockpit-snapshot/index.ts +++ b/src/main/nodes/cockpit-snapshot/index.ts @@ -14,27 +14,20 @@ // pages count as online, and while the link is down "New on the bus", the strays, the Overview's // notice, Connected nodes and About's On the bus all say nothing. // -// Where the Cockpit and another surface answer differently today, this builder keeps the Cockpit's -// own answer as its own function (X3–X8 option a, co-located, not unified — the fix track does -// that): -// X3 strays counted in the Cockpit's band, online and warning (band.ts -// cockpitBand); the "New on the bus" count is isReportableNew -// (attention.ts newCount); strays wait IDENTIFY_WAIT_MS (strayNew) -// X6 fault order the tile and Attention (views.ts nodeHealth), the fault boxes -// (panels.ts nodeFaultBoxes / pduFaultBoxes), the Adopt evidence -// (adopt.ts adoptEvidence) -// X8 registry wording NOT IN REGISTRY / "Not in registry" for a new board (views.ts, -// panels.ts) +// What a board outside your cockpit is to the user is one rule too (node-model.ts newState, X3): +// news (it can be adopted: "New on the bus" and the Overview's notice), still identifying, or a +// stray — a warning counted on every tab alike (the Cockpit's band and Attention, the Overview's +// tile and banner, Connected nodes), once an unidentified one has had IDENTIFY_WAIT_MS since it came +// online. Where it comes from is worded once (node-model.ts newReason, X8): "from NODE_IDS.md". +// +// A board's flags are named in one order everywhere (faults.ts NODE_FLAG_ORDER, X6): the tile and +// Attention, the fault boxes and the Adopt evidence. // Hide and Forget (X5) are offered by the one rule main refuses them by (offline-actions.ts). // Where clicking a board goes is that membership too (D6, members.ts boardOpens): its panel while // it is in your cockpit, else the Adopt dialog on its NODE_ID. // -// The Overview, Connection and Settings read their own sections (R3.2b), each with its own answer -// where it differs from the Cockpit's — the same option a, co-located: -// X3 strays the Overview and Connection count none (summaries.ts: healthBand and -// attentionItems with no strays); the notice is isReportableNew -// (newDevices) -// X8 registry wording the registry's bridge row is "Gateway" (registry-rows.ts registryName) +// The Overview, Connection and Settings read their own sections (R3.2b), from the same views and +// the same strays. The registry's bridge row is "Gateway" (registry-rows.ts registryName). import type { DeviceStatus } from '@shared/gateway' import { isLinkUp } from '../../gateway' import type { @@ -83,7 +76,8 @@ export function buildCockpitSnapshot(i: CockpitSnapshotInput): CockpitBuild { const members = cockpitMembers(i.model) const zones = boardZones(i.model?.nodes ?? [], members.get) // New boards that can't be adopted are active on the bus, so they are warnings (#93) — once an - // unidentified one has had IDENTIFY_WAIT_MS to report its kind, so a normal board never flashes. + // unidentified one has had IDENTIFY_WAIT_MS since it came online to report its kind, so a normal + // board never flashes. One list for every tab (X3). const stray = strayNew(views, i.now, live) const strays: StrayItem[] = stray.strays.map((v) => ({ nodeId: v.entry.nodeId, @@ -101,8 +95,8 @@ export function buildCockpitSnapshot(i: CockpitSnapshotInput): CockpitBuild { opens: boardOpens(views, members), zones: zoneChoices(views, members), adopt: adoptRows(views, members), - overview: overviewSummary(views, outages, live), - nodesCard: nodesSummary(views, outages, zones, live), + overview: overviewSummary(views, outages, strays, live), + nodesCard: nodesSummary(views, outages, strays, zones, live), settings: settingsNodes(i.model, i.registry, live) } return stray.nextAt === undefined ? { snapshot } : { snapshot, nextAt: stray.nextAt } diff --git a/src/main/nodes/cockpit-snapshot/on-the-bus.test.ts b/src/main/nodes/cockpit-snapshot/on-the-bus.test.ts index 1e84286..30e2e44 100644 --- a/src/main/nodes/cockpit-snapshot/on-the-bus.test.ts +++ b/src/main/nodes/cockpit-snapshot/on-the-bus.test.ts @@ -28,8 +28,15 @@ const REG: NodeRegistryInfo = { const LAST = model([ node(1, { name: 'Center_Armament' }), node(2, { name: 'Right_Navigation', console: 'Right', availability: 'offline', offlineAt: 0 }), - node(9, { name: 'Node 9', console: null, source: 'new', firstSeenAt: 0 }), - node(41, { name: 'Node 41', console: null, source: 'new', identified: 'panel', firstSeenAt: 0 }) + node(9, { name: 'Node 9', console: null, source: 'new', firstSeenAt: 0, onlineSince: 0 }), + node(41, { + name: 'Node 41', + console: null, + source: 'new', + identified: 'panel', + firstSeenAt: 0, + onlineSince: 0 + }) ]) function snapshot(state: DeviceState): CockpitSnapshot { @@ -63,8 +70,9 @@ describe('every "on the bus" surface goes quiet with the link down (X2 / R5, #13 expect(up.strays).toEqual([9]) expect(up.bandStrays).toBe(1) expect(up.newDevices).toEqual({ count: 1, panels: 1, pdus: 0, registry: 0 }) - expect(up.nodesCard).toMatchObject({ known: 2, healthy: 1, off: 1 }) - expect(up.nodesCard.rows.map((r) => r.nodeId)).toEqual([2]) + // Connected nodes counts the stray as the band does (D1 / X3, #133): one up, one warning. + expect(up.nodesCard).toMatchObject({ known: 3, healthy: 1, warn: 1, off: 1 }) + expect(up.nodesCard.rows.map((r) => r.nodeId)).toEqual([2, 9]) expect(up.aboutOnBus).toEqual(['serial link', 'yes', 'offline']) }) diff --git a/src/main/nodes/cockpit-snapshot/overview-summary.test.ts b/src/main/nodes/cockpit-snapshot/overview-summary.test.ts index 354e158..94d5e2f 100644 --- a/src/main/nodes/cockpit-snapshot/overview-summary.test.ts +++ b/src/main/nodes/cockpit-snapshot/overview-summary.test.ts @@ -232,20 +232,34 @@ describe('overviewSummary', () => { const off = node(2, { availability: 'offline' }) const waiting = fresh(42, { kind: 'pdu', identified: 'pdu', everHadReadings: true }) - it('is the tile, the banner and the notice over the band and items without strays', () => { + it('is the tile, the banner and the notice over the band and items, the strays counted (X3)', () => { const views = nodeViews(model([node(1), off, waiting]), []) const band = healthBand(views) - expect(overviewSummary(views, [], true)).toEqual({ + expect(overviewSummary(views, [], [], true)).toEqual({ tile: cockpitTile(band, true), banner: attentionSummary(band, attentionItems(views, [])), newDevices: newDevices(views, true) }) - expect(overviewSummary(views, [], true).banner).not.toBeNull() + expect(overviewSummary(views, [], [], true).banner).not.toBeNull() + }) + + it('counts a stray as the Cockpit does: one board up and a warning, named in the banner (D1)', () => { + const views = nodeViews(model([node(1), fresh(9, { identified: 'bridge' })]), []) + const strays = [{ nodeId: 9, flag: 'SECOND GATEWAY · can’t be adopted' }] + expect(overviewSummary(views, [], [], true)).toMatchObject({ + tile: { value: '1 / 1', tone: 'ok' }, + banner: null + }) + expect(overviewSummary(views, [], strays, true)).toMatchObject({ + tile: { value: '2 / 2', symbol: 'warn', tone: 'issue' }, + banner: { head: '1 node needs attention', detail: '1 warning (NODE_ID 9)' }, + newDevices: null + }) }) it('has no banner and no notice while the link is down; the tile says so', () => { const views = nodeViews(model([node(1), off, waiting]), []) - expect(overviewSummary(views, [], false)).toEqual({ + expect(overviewSummary(views, [], [], false)).toEqual({ tile: { value: '—', sub: 'no live node status', tone: 'none' }, banner: null, newDevices: null diff --git a/src/main/nodes/cockpit-snapshot/panels.ts b/src/main/nodes/cockpit-snapshot/panels.ts index d666a9a..dbf397c 100644 --- a/src/main/nodes/cockpit-snapshot/panels.ts +++ b/src/main/nodes/cockpit-snapshot/panels.ts @@ -24,6 +24,7 @@ import { import { PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' import type { StatusFlag } from '../../events' import { nodeFaultTag, pduAlertFlags, pduFlagMeanings, raisedNodeFlags } from '../faults' +import { newReason } from '../node-model' import { displayCurrentMa } from '../node-telem' import { canForget, canHide, type OfflineGate } from '../offline-actions' import { fedBy, zoneLabel } from '../power-zone' @@ -108,11 +109,15 @@ export function nodeFaultBoxes(v: NodeView): FaultBox[] { const e = v.entry const st = e.status if (e.source === 'new') { + // Where it comes from, worded once (X8, node-model.ts newReason). + const reason = newReason(e) return [ { tone: 'new', - title: 'Not in registry', - text: 'this NODE_ID is not in the bundled NODE_IDS.md. Name it and place it on the map.' + title: reason.charAt(0).toUpperCase() + reason.slice(1), + text: e.registry + ? 'this NODE_ID is in the bundled NODE_IDS.md and not added to your cockpit yet.' + : 'this NODE_ID is not in the bundled NODE_IDS.md. Name it and place it on the map.' } ] } diff --git a/src/main/nodes/cockpit-snapshot/summaries.ts b/src/main/nodes/cockpit-snapshot/summaries.ts index 44b9f0e..c135e74 100644 --- a/src/main/nodes/cockpit-snapshot/summaries.ts +++ b/src/main/nodes/cockpit-snapshot/summaries.ts @@ -3,9 +3,8 @@ // Attention banner and new-devices notice, and Connection › Connected nodes. The client adds no // thresholds of its own. // -// Each keeps its own answer where it differs from the Cockpit's (co-located, not unified): -// X3 strays neither counts a new board that can't be adopted: the band is healthBand(views) -// with no strays, the items attentionItems(views, outages) with none +// Both count what the Cockpit counts (X3): its views and its strays — the new boards that can't be +// adopted (node-model.ts newState), each one a board up and a warning, and an Attention item. // What is on the bus is one rule (node-model.ts onBusNow, X2): while the link is down the tile says // "no live node status", and the banner, the notice and Connected nodes say nothing. // A Connected nodes row names its board's resolved power zone (D7, the same one the Cockpit reads): @@ -24,6 +23,7 @@ import { type OverviewSummary, type PduOutage, type PowerZone, + type StrayItem, type SummaryRow } from '@shared/nodes' import { isAdoptableKind } from '../node-kind' @@ -120,18 +120,19 @@ export function newDevices(views: NodeView[], live: boolean): NewDevices | null } /** - * The Overview's tile, banner and notice, as the Overview tab computed them: the band and the items - * without strays (X3), the banner and the notice only while the link is up. + * The Overview's tile, banner and notice: the band and the items with the Cockpit's strays (X3), + * the banner and the notice only while the link is up. */ export function overviewSummary( views: NodeView[], outages: PduOutage[], + strays: StrayItem[], live: boolean ): OverviewSummary { - const band = healthBand(views) + const band = healthBand(views, strays.length) return { tile: cockpitTile(band, live), - banner: live ? attentionSummary(band, attentionItems(views, outages)) : null, + banner: live ? attentionSummary(band, attentionItems(views, outages, strays)) : null, newDevices: newDevices(views, live) } } @@ -139,27 +140,29 @@ export function overviewSummary( // ── Connection › Connected nodes ──────────────────────────────────────────── /** - * Connected nodes: the counts and the rows that need attention. Quiet while the link is down — it + * Connected nodes: the counts and the rows that need attention, the Cockpit's strays included (X3: + * each one a board up and a warning, with its Attention row). Quiet while the link is down — it * says nothing about the bus then (X2), and its tab shows the link's hint instead. */ export function nodesSummary( views: NodeView[], outages: PduOutage[], + strays: StrayItem[], zones: ReadonlyMap, live: boolean ): NodesSummary { if (!live) return COCKPIT_IDLE.nodesCard - const band = healthBand(views) + const band = healthBand(views, strays.length) const known = views.filter((v) => v.entry.source !== 'new') const byId = new Map(views.map((v) => [v.entry.nodeId, v.entry])) - const items = attentionItems(views, outages) + const items = attentionItems(views, outages, strays) const healthy = band.online - band.warn const rows = items.slice(0, ATTENTION_SHOWN).map((i): SummaryRow => { const e = byId.get(i.nodeId)! const meta = [`#${i.nodeId}`] if (i.outage) meta.push(affected(i.outage.nodeIds.length)) - else if (e.kind !== 'pdu') { + else if (e.kind !== 'pdu' && !i.stray) { const zone = zones.get(e.nodeId) meta.push(zone && zone.status !== 'none' ? zone.label : 'no power zone') } @@ -189,7 +192,8 @@ export function nodesSummary( .join(' · ') return { - known: known.length, + // The strays are boards on the bus too: counted like the Cockpit's band (its total). + known: band.total, healthy, warn: band.warn, off: band.off, diff --git a/src/main/nodes/cockpit-snapshot/views.ts b/src/main/nodes/cockpit-snapshot/views.ts index e4b2409..b05f2ff 100644 --- a/src/main/nodes/cockpit-snapshot/views.ts +++ b/src/main/nodes/cockpit-snapshot/views.ts @@ -10,6 +10,7 @@ import type { PduReadings } from '@shared/nodes' import { nodeFlagChips, pduAlertFlags } from '../faults' +import { newReason } from '../node-model' /** A PDU's pages that are fresh right now. A stale page is not a reading. */ function freshReadings(t: NodeTelemSnapshot | undefined): PduReadings { @@ -20,9 +21,15 @@ function freshReadings(t: NodeTelemSnapshot | undefined): PduReadings { return r } +/** newReason as a flag: its words in capitals, the file's name as it is spelled. */ +function newReasonFlag(e: NodeModelEntry): string { + return newReason(e).replace(/^[a-z ]+/, (w) => w.toUpperCase()) +} + /** Offline wins, then bus-off, then any flag the node (or, for a PDU, its pages) raised. */ export function nodeHealth(e: NodeModelEntry, r: PduReadings = {}): NodeHealth { - if (e.source === 'new') return { state: 'new', flag: 'NOT IN REGISTRY' } + // Where it comes from, worded once (X8): `FROM NODE_IDS.md` / `NOT IN NODE_IDS.md`. + if (e.source === 'new') return { state: 'new', flag: newReasonFlag(e) } if (e.availability === 'unknown') return { state: 'unknown' } if (e.notSeen) return { state: 'off', flag: 'NOT SEEN THIS SESSION' } if (e.availability === 'offline') return { state: 'off', flag: 'OFFLINE' } diff --git a/src/main/nodes/faults.test.ts b/src/main/nodes/faults.test.ts index 1fe13cb..188a649 100644 --- a/src/main/nodes/faults.test.ts +++ b/src/main/nodes/faults.test.ts @@ -28,8 +28,8 @@ const st = (over: Partial = {}): NodeStatus => function logRows(over: Partial): string[] { const t = new NodeEventTracker({ name: () => 'Right_Navigation', - isNew: () => false, - unmapped: () => false + outside: () => false, // in your cockpit + entry: () => undefined }) t.status([st()], NODE_MSG, '', 0) t.status([st()], NODE_END_MSG, '', 10) diff --git a/src/main/nodes/new-boards.seam.test.ts b/src/main/nodes/new-boards.seam.test.ts new file mode 100644 index 0000000..a451f40 --- /dev/null +++ b/src/main/nodes/new-boards.seam.test.ts @@ -0,0 +1,159 @@ +// SESSION-seam tests for fix-track chunk 4's new boards and event log (#133: D1 / X3 / R2, S6, +// X8 / C6, D8 / S5 / C4), through the services harness: the Gateway's lines arrive on the fake +// serial port, and the test reads only pushes, intents and files. +import { describe, expect, it } from 'vitest' +import { CTRL } from '@shared/app' +import type { LogEvent } from '@shared/events' +import { eventText } from '@shared/format' +import type { CockpitSnapshot, NodeMappingFile } from '@shared/nodes' +import { statusLine } from '../../test-fixtures/nodes' +import { makeServices, type Services } from '../test-harness/services' + +const PANEL = 0x01 +const UNREPORTED = 0x00 +/** Board 1 (Center_Armament) is in your cockpit. */ +const COCKPIT = { 'node-mapping.json': { version: 4, nodes: { 1: { added: true } } } } +/** And PDU_Left (3). */ +const WITH_PDU = { + 'node-mapping.json': { version: 4, nodes: { 1: { added: true }, 3: { added: true } } } +} + +/** The Gateway's roster reply: its own row, then a row per board, then the END with the count. */ +function reply(app: Services, boards: [id: number, kind: number][]): void { + app.serial.receive(statusLine(0, true, 3)) + for (const [id, kind] of boards) app.serial.receive(statusLine(id, true, kind)) + app.serial.receive(`_NODE_STATUS_END ${boards.length + 1}\n`) +} + +const snap = (app: Services): CockpitSnapshot => app.lastPush('cockpit:snapshot')! + +/** What each tab says about a stray: the Cockpit, the Overview and Connection. */ +function strayCounts(app: Services) { + const s = snap(app) + return { + cockpit: [s.band.band.warn, s.attention.items.filter((i) => i.stray).map((i) => i.nodeId)], + overview: [s.overview.tile.value, s.overview.banner?.detail ?? null], + connection: [s.nodesCard.warn, s.nodesCard.rows.map((r) => r.nodeId)] + } +} + +/** The event-log rows about one board, as `event · detail [severity]`. */ +function rows(app: Services, nodeId: number): string[] { + return app + .pushesOf('events:batch') + .flat() + .filter((e: LogEvent) => e.nodeId === nodeId) + .map((e: LogEvent) => `${eventText(e)} [${e.severity}]`) +} + +const lastSeen = (app: Services, id: number) => + app.files.json('node-mapping.json')?.nodes[id]?.lastSeen + +describe('D1 / X3 / S6: a stray is counted on every tab, 5 s after it comes online', () => { + it('an unidentified board: nothing at first, then one warning on the Cockpit, Overview and Connection', () => { + const app = makeServices({ paths: { files: COCKPIT } }) + app.serial.open() + reply(app, [[1, PANEL]]) + app.serial.receive(statusLine(9, true, UNREPORTED)) // on the bus, kind not reported + app.clock.advance(1000) + const none = { cockpit: [0, []], overview: ['1 / 1', null], connection: [0, []] } + expect(strayCounts(app)).toEqual(none) + app.clock.advance(4200) // 5 s since it came online + expect(strayCounts(app)).toEqual({ + cockpit: [1, [9]], + overview: ['2 / 2', '1 warning (NODE_ID 9)'], + connection: [1, [9]] + }) + }) + + it('the grace counts from coming online: listed absent a minute, then online, it waits 5 s', () => { + const app = makeServices({ paths: { files: COCKPIT } }) + app.serial.open() + reply(app, [[1, PANEL]]) + app.serial.receive(statusLine(9, false, UNREPORTED)) // first seen absent + app.clock.advance(60_000) + app.serial.receive(statusLine(9, true, UNREPORTED)) // online now + app.clock.advance(1000) + expect(snap(app).attention.items.filter((i) => i.stray)).toEqual([]) + expect(snap(app).views.find((v) => v.entry.nodeId === 9)?.entry.onlineSince).toBe( + app.clock.now() - 1000 + ) + app.clock.advance(4200) + expect(strayCounts(app).connection).toEqual([1, [9]]) + }) +}) + +describe('X8 / C6: a registry board waiting to be added is "from NODE_IDS.md"', () => { + it('its flag, and its "new on the bus" row', () => { + const app = makeServices({ paths: { files: COCKPIT } }) + app.serial.open() + reply(app, [ + [1, PANEL], + [2, PANEL] // Right_Navigation, in NODE_IDS.md, not added + ]) + app.clock.advance(1000) + expect(snap(app).views.find((v) => v.entry.nodeId === 2)?.health.flag).toBe('FROM NODE_IDS.md') + expect(rows(app, 2)).toEqual(['new on the bus · from NODE_IDS.md [new]']) + }) +}) + +describe('D8 / S5 / C4: the log is the physical bus', () => { + it('a board outside your cockpit is logged at info, labelled; yours stays an error', () => { + const app = makeServices({ paths: { files: COCKPIT } }) + app.serial.open() + reply(app, [ + [1, PANEL], + [41, PANEL] + ]) + app.clock.advance(1000) + app.serial.receive(statusLine(41, false, PANEL)) + app.serial.receive(statusLine(1, false, PANEL)) + app.clock.advance(1000) + expect(rows(app, 41)).toEqual([ + 'new on the bus · not in NODE_IDS.md [new]', + 'offline · no heartbeat · not in your cockpit [info]' + ]) + expect(rows(app, 1)).toEqual(['offline · no heartbeat [error]']) + const roster = app + .pushesOf('events:batch') + .flat() + .find((e) => e.name === 'roster') + expect(roster && eventText(roster)).toBe('2 nodes online · 1 not in your cockpit') + }) + + it('Forget clears the "seen new" record: plugged back in, it is new again', () => { + const app = makeServices({ paths: { files: COCKPIT } }) + app.serial.open() + reply(app, [[1, PANEL]]) + app.serial.receive(statusLine(41, true, PANEL)) + app.clock.advance(1000) + const spec = { name: 'Left_Oxygen', console: 'Left', pduId: null, kind: 'panel' } as const + expect(app.invoke(CTRL.nodeMappingAdopt, 41, spec).ok).toBe(true) + app.serial.receive(statusLine(41, false, PANEL)) // unplugged + app.clock.advance(1000) + expect(app.invoke(CTRL.nodeMappingForget, 41).ok).toBe(true) + app.serial.receive(statusLine(41, true, PANEL)) // plugged back in + app.clock.advance(1000) + expect(rows(app, 41).filter((r) => r.startsWith('new on the bus'))).toEqual([ + 'new on the bus · not in NODE_IDS.md [new]', + 'new on the bus · not in NODE_IDS.md [new]' + ]) + }) + + it('a board online by its PDU pages alone: last seen, and its online / offline rows', () => { + const app = makeServices({ paths: { files: WITH_PDU } }) + app.serial.open() + reply(app, [[1, PANEL]]) // PDU_Left (3) is not in the roster + app.clock.advance(1000) + const at = app.clock.now() + app.serial.receive('_NODE_TELEM 030100E02E182EBC02\n') // its 12 V page + app.clock.advance(1000) + const came = Date.parse(lastSeen(app, 3)!) // seen at the link's next flush + expect(came - at).toBeGreaterThanOrEqual(0) + expect(came - at).toBeLessThan(1000) + expect(rows(app, 3)).toEqual(['online [info]']) + app.clock.advance(12_000) // its pages go stale + expect(rows(app, 3)).toEqual(['online [info]', 'offline · no telemetry [error]']) + expect(Date.parse(lastSeen(app, 3)!)).toBeGreaterThanOrEqual(came + 10_000) // when it went + }) +}) diff --git a/src/main/nodes/node-events.ts b/src/main/nodes/node-events.ts index 7b35857..72f7249 100644 --- a/src/main/nodes/node-events.ts +++ b/src/main/nodes/node-events.ts @@ -1,23 +1,36 @@ // Node events (#64): the change events the event log gets from the boards' own reports // (`_NODE_STATUS`, `_NODE_TELEM`). Pure, no Node/Electron deps. The client never applies a // threshold, so every alert here is a flag a board raised itself. +// +// The log is the physical bus (D8 / S5): every board on it is logged. A board outside your cockpit +// (never added or adopted, or forgotten) gets its rows at info, labelled `not in your cockpit`, so +// only your cockpit's problems read as warnings and errors. Whether a board is new is the model's +// one answer (node-model.ts newState), never worked out again here. import type { EventSeverity, LogEventInput } from '@shared/events' -import type { NodeStatus, NodeTelem } from '@shared/nodes' +import type { NodeModelEntry, NodeStatus, NodeTelem } from '@shared/nodes' import { faultLabel } from '../events' import { NODE_FLAGS, PDU_STATUS_FLAGS, RAIL_FLAGS, railReading, TEMP_FLAGS } from './faults' +import { newReason, newState, onlineByPages, strayKind } from './node-model' import { BRIDGE_NODE_ID, NODE_END_MSG, ROSTER_REPLY_MS } from './node-status-wire' import { TELEM_PAGE } from './node-telem' -/** What the node mapping says about an id, as the tracker needs it. */ +/** The label on a row about a board outside your cockpit (D8 / S5). */ +export const OUTSIDE_COCKPIT = 'not in your cockpit' + +/** The row a board outside your cockpit gets once, when newState first has something to say. */ +const NEW_ON_BUS = 'new on the bus' + +/** What the node mapping and the model say about an id, as the tracker needs it. */ export interface NodeLookup { /** Effective name; `Node ` when the id has none. */ name(nodeId: number): string - /** Neither in the registry nor adopted, and identified as something adoptable: news. */ - isNew(nodeId: number): boolean - /** Not in your cockpit (not added, not adopted), whatever it has identified as. */ - unmapped(nodeId: number): boolean - /** In NODE_IDS.md — a new one is waiting to be added, pre-filled (#93). Absent = never. */ - inRegistry?(nodeId: number): boolean + /** Not in your cockpit (never added or adopted, or forgotten), whatever it has identified as. */ + outside(nodeId: number): boolean + /** + * Its node-model entry, as the model stands with the line just applied (node-model.ts + * buildNodeModel); undefined when the model leaves it out. + */ + entry(nodeId: number): NodeModelEntry | undefined } /** @@ -33,8 +46,16 @@ export class NodeEventTracker { private pages = new Map() private baseline = true private baselineAt?: number - /** New ids reported this app run — kept through a port reopen, so a replug does not repeat them. */ - private newSeen = new Set() + /** + * Boards outside your cockpit announced this app run ("new on the bus"), as what: news (it can be + * adopted) or a stray. Kept through a port reopen, so a replug does not repeat them; dropped once + * the board is in your cockpit (cockpitChanged), so one you forget is new again when it returns. + */ + private newSeen = new Map() + /** Boards online by their PDU pages alone (no present roster row), as last logged. */ + private byPages = new Set() + /** Boards that were online by their pages at some point since the port opened. */ + private pagesEver = new Set() constructor(private readonly lookup: NodeLookup) {} @@ -42,10 +63,23 @@ export class NodeEventTracker { reset(): void { this.prev.clear() this.pages.clear() + this.byPages.clear() + this.pagesEver.clear() this.baseline = true this.baselineAt = undefined } + /** + * The node mapping changed (an add, an adopt, Forget, Reset…, an import): a board now in your + * cockpit has no "seen new" record, so if it is forgotten later and plugged back in, its "new on + * the bus" row comes again (C4). + */ + cockpitChanged(): void { + for (const id of [...this.newSeen.keys()]) { + if (!this.lookup.outside(id)) this.newSeen.delete(id) + } + } + /** * After one `_NODE_STATUS` / `_NODE_STATUS_END` line was applied to the roster: the roster as it * now stands (fault labels filled in), and the line that changed it. @@ -69,27 +103,58 @@ export class NodeEventTracker { return out } + /** + * After each model push: the boards online by their PDU pages alone (node-model.ts + * onlineByPages) get the rows a roster-seen board gets — online when they come online, offline + * when their pages stop and nothing else says they are on the bus. + */ + pagesOnline(entries: NodeModelEntry[], now: number): LogEventInput[] { + const out: LogEventInput[] = [] + const online = new Set(entries.filter(onlineByPages).map((e) => e.nodeId)) + for (const id of online) { + if (this.byPages.has(id)) continue + this.byPages.add(id) + // A first sighting the baseline counts, or a board outside your cockpit — its "new on the + // bus" row says it came — needs no "online" row, as in diff. + const first = !this.pagesEver.has(id) && this.prev.get(id) === undefined + this.pagesEver.add(id) + if (!first || !(this.baseline || this.lookup.outside(id))) { + out.push(this.row(id, 'info', 'online', undefined, '', now)) + } + } + for (const id of [...this.byPages]) { + if (online.has(id)) continue + this.byPages.delete(id) + if (this.prev.get(id)?.present) continue // the roster lists it now + out.push(this.row(id, 'error', 'offline', 'no telemetry', '', now)) + } + return out + } + /** One decoded `_NODE_TELEM` frame: PDU rail / status flags raised and cleared. */ telemetry(t: NodeTelem, raw: string, now: number): LogEventInput[] { if (t.kind === 'unknown') return [] + // Its pages may be what identifies it (a PDU): its "new on the bus" row comes now (C4). + const out: LogEventInput[] = [] + const announced = this.announce(t.nodeId, raw, now) + if (announced) out.push(announced) const key = `${t.nodeId}:${t.page}` const was = this.pages.get(key) ?? 0 this.pages.set(key, t.flags) - if (was === t.flags) return [] - const out: LogEventInput[] = [] + if (was === t.flags) return out const ev = (bit: number, text: string, severity: EventSeverity, reading?: string): void => { const on = (t.flags & bit) !== 0 if (on === ((was & bit) !== 0)) return - out.push({ - t: now, - source: 'node', - nodeId: t.nodeId, - name: this.lookup.name(t.nodeId), - severity: on ? severity : 'info', - event: `${text} ${on ? 'raised' : 'cleared'}`, - detail: on ? reading : undefined, - raw - }) + out.push( + this.row( + t.nodeId, + on ? severity : 'info', + `${text} ${on ? 'raised' : 'cleared'}`, + on ? reading : undefined, + raw, + now + ) + ) } if (t.kind === 'rail') { const rail = t.page === TELEM_PAGE.PDU_RAIL_12V ? '12V' : '5V' @@ -107,16 +172,67 @@ export class NodeEventTracker { private endBaseline(raw: string, now: number): LogEventInput { this.baseline = false - let online = 0 - for (const n of this.prev.values()) if (n.present) online++ - return { + const on = [...this.prev.values()].filter((n) => n.present).map((n) => n.nodeId) + for (const id of this.byPages) if (!this.prev.get(id)?.present) on.push(id) + const outside = on.filter((id) => this.lookup.outside(id)).length + const row: LogEventInput = { t: now, source: 'node', name: 'roster', severity: 'info', - event: `${online} node${online === 1 ? '' : 's'} online`, + event: `${on.length} node${on.length === 1 ? '' : 's'} online`, raw } + // The bus as it is, every board counted; how many of them are not yours, said (D8 / S5). + if (outside) row.detail = `${outside} ${OUTSIDE_COCKPIT}` + return row + } + + /** + * One row about a board. A board outside your cockpit is on the bus but not yours: its rows are + * info, labelled (D8 / S5) — except its "new on the bus" row, which says so itself. + */ + private row( + id: number, + severity: EventSeverity, + event: string, + detail: string | undefined, + raw: string, + now: number + ): LogEventInput { + const outside = event !== NEW_ON_BUS && this.lookup.outside(id) + return { + t: now, + source: 'node', + nodeId: id, + name: this.lookup.name(id), + severity: outside ? 'info' : severity, + event, + detail: outside ? [detail, OUTSIDE_COCKPIT].filter(Boolean).join(' · ') : detail, + raw + } + } + + /** + * A board outside your cockpit, on the bus now: its one "new on the bus" row, once newState + * (node-model.ts, the rule every tab reads) has something to say — news, where it comes from + * (newReason); or a stray, what it is and that it can't be adopted. Still identifying: nothing + * yet. A stray that later identifies as something adoptable is news then. + */ + private announce(id: number, raw: string, now: number): LogEventInput | undefined { + if (!this.lookup.outside(id) || this.newSeen.get(id) === 'news') return undefined + const e = this.lookup.entry(id) + // A line just arrived over the link, so the link is up. + const s = e && newState(e, now, true) + if (s?.state === 'news') { + this.newSeen.set(id, 'news') + return this.row(id, 'new', NEW_ON_BUS, newReason(e!), raw, now) + } + if (s?.state === 'stray' && !this.newSeen.has(id)) { + this.newSeen.set(id, 'stray') + return this.row(id, 'info', NEW_ON_BUS, `${strayKind(e!)} · can’t be adopted`, raw, now) + } + return undefined } private diff( @@ -129,38 +245,24 @@ export class NodeEventTracker { const out: LogEventInput[] = [] const id = n.nodeId const ev = (severity: EventSeverity, event: string, detail?: string): void => { - out.push({ - t: now, - source: 'node', - nodeId: id, - name: this.lookup.name(id), - severity, - event, - detail, - raw - }) - } - const emittedNew = n.present && this.lookup.isNew(id) && !this.newSeen.has(id) - if (emittedNew) { - this.newSeen.add(id) - ev( - 'new', - 'new on the bus', - this.lookup.inRegistry?.(id) ? 'in NODE_IDS.md' : 'not in registry' - ) + out.push(this.row(id, severity, event, detail, raw, now)) } + const announced = n.present ? this.announce(id, raw, now) : undefined + if (announced) out.push(announced) const wasPresent = p?.present ?? false if (!n.present && (p === undefined || wasPresent)) { // A live delta is the bridge's heartbeat timeout; a roster end finds a node missing from it. + this.byPages.delete(id) ev('error', 'offline', isEnd ? 'missing from roster' : 'no heartbeat') } else if (n.present && !wasPresent) { // A first sighting the baseline counts in its summary row, or that just made its "new" row, - // needs no "online" too; nor does an unmapped node not yet announced — it gets one "new on - // the bus" row once it identifies (#344). A new node seen in an earlier port session does: - // it has no other row. + // needs no "online" too; nor does a board outside your cockpit not yet announced — it gets + // one "new on the bus" row once newState says what it is (#344). A new node seen in an + // earlier port session does: it has no other row. Nor does one already online by its pages. const first = p === undefined - const unannounced = this.lookup.unmapped(id) && !this.newSeen.has(id) - if (!first || !(this.baseline || emittedNew || unannounced)) ev('info', 'online') + const unannounced = this.lookup.outside(id) && !this.newSeen.has(id) + const byPages = this.byPages.delete(id) + if (!byPages && (!first || !(this.baseline || announced || unannounced))) ev('info', 'online') } return this.flagsFrom(wasPresent ? p : undefined, n, ev, out) } @@ -177,9 +279,9 @@ export class NodeEventTracker { for (const f of NODE_FLAGS) { const before = was?.[f.key] ?? false const on = n[f.key] - if (f.key === 'degraded' && on && before && was!.faultId !== n.faultId) { - ev('warn', 'degraded', faultLabel(n)) - } else if (on && !before) { + // One row per episode: raised, then cleared — a fault id that changes while degraded is the + // same episode (D8), shown on the board's panel. + if (on && !before) { const detail = f.key === 'degraded' ? faultLabel(n) : f.key === 'epvf' ? `TEC ${n.tec}` : undefined ev(f.severity, f.label, detail) diff --git a/src/main/nodes/node-hub.seam.test.ts b/src/main/nodes/node-hub.seam.test.ts index 7000025..a146062 100644 --- a/src/main/nodes/node-hub.seam.test.ts +++ b/src/main/nodes/node-hub.seam.test.ts @@ -221,8 +221,8 @@ describe('B10 Hide keeps links: boards fed by a hidden PDU keep their power zone }) }) -describe('B33 a board identified only by PDU telemetry is "new on the bus" at its next status row', () => { - it('no row when the pages arrive; one row, at that time, when its next status row comes', () => { +describe('B33 / C4 (#133): a board identified only by PDU telemetry is "new on the bus" when its pages identify it', () => { + it('one row, at that time, when the pages arrive; none again at its next status row', () => { const { h, events, status, end, node } = hub() status(1, PANEL, 0) end(1, 0) // the baseline is closed: what appears from here on is news @@ -234,11 +234,12 @@ describe('B33 a board identified only by PDU telemetry is "new on the bus" at it const page = '090100E02E182EBC02' // node 9, 12 V rail page: it is a PDU h.telemetry(page, 1500, `_NODE_TELEM ${page}`) expect(node(9, 1500)?.identified).toBe('pdu') - expect(news(), 'the pages identify it, but write no row').toEqual([]) + // Was: no row until its next status row (C4). Now the model's newState says news: one row. + expect(news()).toEqual([ + expect.objectContaining({ nodeId: 9, t: 1500, severity: 'new', raw: `_NODE_TELEM ${page}` }) + ]) status(9, NOT_REPORTED, 5000) // the next roster poll - expect(news()).toEqual([expect.objectContaining({ nodeId: 9, t: 5000, severity: 'new' })]) - status(9, NOT_REPORTED, 10_000) expect(news(), 'once per board').toHaveLength(1) }) diff --git a/src/main/nodes/node-hub.test.ts b/src/main/nodes/node-hub.test.ts index 6c7fa7b..16b2a2d 100644 --- a/src/main/nodes/node-hub.test.ts +++ b/src/main/nodes/node-hub.test.ts @@ -542,10 +542,14 @@ describe('board kind (#344)', () => { roster.applyMessage(NODE_MSG, row(1, 0x02), 5000) // the same report again: no write h.flush(true, 5000) expect(disk.saves).toBe(saves) - // PDU pages are a report too. + // PDU pages are a report too — and, online by them, it was seen now (D8 / S5, #133). h.telemetry('020100E02E182EBC02', 6000) h.flush(false, 6000) - expect(disk.file.nodes[2]).toEqual({ added: true, kind: 'pdu' }) + expect(disk.file.nodes[2]).toEqual({ + added: true, + kind: 'pdu', + lastSeen: new Date(6000).toISOString() + }) }) it('Forget of a PDU that has not reported this session unlinks its boards (S1)', () => { diff --git a/src/main/nodes/node-hub.ts b/src/main/nodes/node-hub.ts index 2d305f6..197ac2b 100644 --- a/src/main/nodes/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -47,7 +47,7 @@ import { NODE_REGISTRY_SOURCE, type NodeFaultName } from '@shared/firmware' -import { availabilityOf, buildNodeModel } from './node-model' +import { availabilityOf, buildNodeModel, type AvailabilitySignals } from './node-model' import { NodeEventTracker } from './node-events' import { boardIdentity, identityOf, isAdoptableKind } from './node-kind' import { @@ -162,19 +162,20 @@ export class NodeHub implements NodeLineSink { private modelDirty = true /** Rises with every `nodes:model` push; every model main hands out carries it (C5). */ private modelRev = 0 - /** Present NODE_IDs at the last roster change, to tell "came back" from "still listed". */ + /** On the bus at the last change (onBusIds), to tell "came back" from "still listed". */ private wasPresent = new Set() - /** Node changes for the event log (#64), named by the mapping. */ + /** + * Node changes for the event log (#64), named by the mapping. Whether a board is new is the + * model's answer (node-model.ts newState), read from the model as it stands with the line just + * applied — built at most once per line, and only when a row needs it (eventModel). + */ private readonly events = new NodeEventTracker({ name: (id) => this.mapped().get(id)?.name ?? `Node ${id}`, - // "New on the bus" once a node not in your cockpit has identified as something adoptable — not - // at first sight, and never for the bridge's own row (node 0, our link). A registry board not - // added yet is new too (#93); its kind comes from the registry, so it is news at once. - unmapped: (id) => !this.mapped().get(id)?.known, - inRegistry: (id) => this.defaults.has(id), - isNew: (id) => - id !== BRIDGE_NODE_ID && !this.mapped().get(id)?.known && isAdoptableKind(this.kindOf(id)) + outside: (id) => !this.mapped().get(id)?.known, + entry: (id) => this.eventModel().nodes.find((e) => e.nodeId === id) }) + /** The model for the line being turned into events: when it arrived, and the model once built. */ + private eventLine?: { at: number; model?: NodeModel } private mappedFor?: NodeMappingFile private mappedCache = new Map() /** The node mapping and its file. */ @@ -357,13 +358,24 @@ export class NodeHub implements NodeLineSink { const t = parseNodeTelem(arg.trim()) if (!t) return this.telem.apply(t, now) + this.eventLine = { at: now } this.report(this.events.telemetry(t, line, now)) + this.eventLine = undefined } /** After a `_NODE_STATUS` / `_NODE_STATUS_END` line reached the roster: what it changed. */ statusLine(identifier: string, line: string, now = Date.now()): void { const roster = this.roster.snapshot().map(withFaultInfo) + this.eventLine = { at: now } this.report(this.events.status(roster, identifier, line, now)) + this.eventLine = undefined + } + + /** The model with the current line applied, for the event log's "new" (built once per line). */ + private eventModel(): NodeModel { + const line = this.eventLine + if (!line) return this.model(Date.now()) + return (line.model ??= this.model(line.at)) } private report(events: LogEventInput[]): void { @@ -386,17 +398,21 @@ export class NodeHub implements NodeLineSink { */ flush(rosterChanged: boolean, now = Date.now()): void { let changed = rosterChanged || this.modelDirty + let pages = false if (now - this.telemPushedAt >= NODE_TELEM_PUSH_MS && this.telem.takeChanged(now)) { this.telemPushedAt = now // The Cockpit's snapshot follows the pages; the renderer reads them only through it (R3.4d: // the `nodes:telemetry` push it no longer listened to is gone). this.cockpit.telemetryPushed(this.telem.snapshot(now)) - changed = true + changed = pages = true } - if (rosterChanged) this.rosterTransitions(now) + // Pages are a report too (X2): a board online by them alone comes and goes like one the + // roster lists — its last seen, and its online / offline rows (D8 / S5). + if (rosterChanged || pages) this.busTransitions(now) if (changed) { this.recordReportedKinds(now) - this.pushModel(now) + const model = this.pushModel(now) + if (rosterChanged || pages) this.report(this.events.pagesOnline(model.nodes, now)) } } @@ -479,14 +495,37 @@ export class NodeHub implements NodeLineSink { * availability by the model's one rule, and whether fresh PDU pages say it is live. */ private offlineSignals(nodeId: number, now: number): OfflineSignals { - const { availability } = availabilityOf({ - status: this.roster.snapshot().find((n) => n.nodeId === nodeId), - seen: this.roster.seenInfo().get(nodeId), - rosterComplete: this.roster.rosterCompleteAt() !== undefined, - telemetry: this.telem.snapshot(now).find((t) => t.nodeId === nodeId), + const { availability } = availabilityOf(this.signals(now)(nodeId)) + return { availability } + } + + /** What the one availability rule reads for each board, as it stands now. */ + private signals(now: number): (nodeId: number) => AvailabilitySignals { + const rows = new Map(this.roster.snapshot().map((n) => [n.nodeId, n])) + const pages = new Map(this.telem.snapshot(now).map((t) => [t.nodeId, t])) + const seen = this.roster.seenInfo() + const rosterComplete = this.roster.rosterCompleteAt() !== undefined + return (nodeId) => ({ + status: rows.get(nodeId), + seen: seen.get(nodeId), + rosterComplete, + telemetry: pages.get(nodeId), now }) - return { availability } + } + + /** + * The boards on the bus now by the one availability rule (X2): listed present by the roster, or + * online by their PDU pages. The bridge's own row is the link, not a board. + */ + private onBusIds(now: number): Set { + const of = this.signals(now) + const ids = new Set([ + ...this.roster.snapshot().map((n) => n.nodeId), + ...this.telem.snapshot(now).map((t) => t.nodeId) + ]) + ids.delete(BRIDGE_NODE_ID) + return new Set([...ids].filter((id) => availabilityOf(of(id)).availability === 'online')) } private refusal(gate: OfflineGate, what: 'hidden' | 'forgotten'): NodeMappingResult | undefined { @@ -591,20 +630,16 @@ export class NodeHub implements NodeLineSink { } /** - * What a roster change means for the saved mapping: + * What a change on the bus means for the saved mapping — the roster's, or a board's PDU pages + * (one availability rule, onBusIds): * - a board that came (back) online is no longer hidden, and it was seen now — that is how a * registry board joins your cockpit the first time it reports; * - a board that dropped off was last seen now. * Only transitions count: a board the bridge still lists (within its 3 s heartbeat timeout, or * about to be removed) stays hidden through later polls, and steady polls write nothing. */ - private rosterTransitions(now: number): void { - const present = new Set( - this.roster - .snapshot() - .filter((n) => n.present) - .map((n) => n.nodeId) - ) + private busTransitions(now: number): void { + const present = this.onBusIds(now) const came = [...present].filter((id) => !this.wasPresent.has(id)) const went = [...this.wasPresent].filter((id) => !present.has(id)) this.wasPresent = present @@ -634,6 +669,8 @@ export class NodeHub implements NodeLineSink { } throw err } + // A board now in your cockpit has no "seen new" record: forgotten later, it is new again (C4). + if (next !== before) this.events.cockpitChanged() // A no-op change (forget an unmapped id, hide a hidden node) writes and pushes nothing. return { ok: true, model: next === before ? this.model(Date.now()) : this.pushModel() } } diff --git a/src/main/nodes/node-model.test.ts b/src/main/nodes/node-model.test.ts index 842f519..d201b6c 100644 --- a/src/main/nodes/node-model.test.ts +++ b/src/main/nodes/node-model.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest' import { buildNodeModel, + IDENTIFY_WAIT_MS, isReportableNew, + newReason, + newState, nodeAvailability, onBusNow, + onlineByPages, + strayKind, type NodeModelInput } from './node-model' import { cockpitMembers } from './cockpit-snapshot/members' @@ -12,8 +17,14 @@ import { nodeViews } from './cockpit-snapshot/views' import { PDU_GROUP_POLL_CATCH_MS, PDU_GROUP_WINDOW_MS } from './power-zone' import { EMPTY_MAPPING } from './mapping-rules' import { registryDefaults } from './registry' -import type { NodeMappingFile, NodeSeen, NodeStatus, NodeTelemSnapshot } from '@shared/nodes' -import { nodeStatus } from '../../test-fixtures/nodes' +import type { + NodeMappingFile, + NodeModelEntry, + NodeSeen, + NodeStatus, + NodeTelemSnapshot +} from '@shared/nodes' +import { modelNode, nodeStatus } from '../../test-fixtures/nodes' const defaults = registryDefaults({ 0: { name: 'PanelBridge', console: '—' }, @@ -633,3 +644,54 @@ describe('board kind (#344)', () => { expect(m.nodes.filter((n) => isReportableNew(n, true)).map((n) => n.nodeId)).toEqual([41, 42]) }) }) + +describe('newState: one answer for every tab and the log (X3 / S6, #133)', () => { + const entry = (over: Partial = {}): NodeModelEntry => + modelNode(9, { source: 'new', console: null, onlineSince: 1000, ...over }) + + it('news once it identifies as a PDU or a panel group; a stray at once for any other kind', () => { + expect(newState(entry({ identified: 'panel' }), 0, true)).toEqual({ state: 'news' }) + expect(newState(entry({ identified: 'pdu' }), 0, true)).toEqual({ state: 'news' }) + expect(newState(entry({ identified: 'bridge' }), 0, true)).toEqual({ state: 'stray' }) + expect(newState(entry({ identified: 'other' }), 0, true)).toEqual({ state: 'stray' }) + }) + + it('unidentified: identifying for IDENTIFY_WAIT_MS after it came online, then a stray', () => { + const e = entry({ firstSeenAt: 0, onlineSince: 60_000 }) + expect(newState(e, 60_001, true)).toEqual({ state: 'identifying', strayAt: 65_000 }) + expect(65_000 - 60_000).toBe(IDENTIFY_WAIT_MS) + expect(newState(e, 65_000, true)).toEqual({ state: 'stray' }) + }) + + it('nothing for a board in your cockpit, one off the bus, or any while the link is down', () => { + expect(newState(modelNode(1), 0, true)).toBeUndefined() + expect( + newState(entry({ identified: 'panel', availability: 'offline' }), 0, true) + ).toBeUndefined() + expect(newState(entry({ identified: 'bridge' }), 0, false)).toBeUndefined() + }) + + it('the model carries onlineSince for a new board: reset each time it comes back online', () => { + const seen = new Map([[41, { firstSeenAt: 0, onlineSince: 60_000 }]]) + const m = buildNodeModel(input({ roster: [st(41)], seen, rosterCompleteAt: 0 })) + expect(m.nodes.find((n) => n.nodeId === 41)).toMatchObject({ + firstSeenAt: 0, + onlineSince: 60_000 + }) + }) + + it('newReason: "from NODE_IDS.md" for a registry board waiting, else "not in NODE_IDS.md" (X8)', () => { + const registry = { name: 'Right_Navigation', console: 'Right', pduId: null } + expect(newReason(entry({ registry }))).toBe('from NODE_IDS.md') + expect(newReason(entry())).toBe('not in NODE_IDS.md') + }) + + it('strayKind and onlineByPages', () => { + expect( + [undefined, 'bridge', 'other'].map((k) => strayKind({ identified: k as never })) + ).toEqual(['unidentified', 'second Gateway', 'unknown board type']) + expect(onlineByPages(modelNode(4, { status: undefined }))).toBe(true) + expect(onlineByPages(modelNode(4))).toBe(false) // its roster row says present + expect(onlineByPages(modelNode(4, { status: undefined, availability: 'offline' }))).toBe(false) + }) +}) diff --git a/src/main/nodes/node-model.ts b/src/main/nodes/node-model.ts index 1a10a7d..ccd538a 100644 --- a/src/main/nodes/node-model.ts +++ b/src/main/nodes/node-model.ts @@ -178,6 +178,7 @@ export function buildNodeModel(i: NodeModelInput): NodeModel { if (st) e.status = st if (seen?.reportedAt !== undefined) e.reportedAt = seen.reportedAt if (source === 'new' && seen) e.firstSeenAt = seen.firstSeenAt + if (source === 'new' && seen?.onlineSince !== undefined) e.onlineSince = seen.onlineSince if (source === 'new' && mapped) { // Its default PDU only while that PDU is in your cockpit (effectiveMapping, D7). e.registry = { name: mapped.name, console: mapped.console, pduId: mapped.pduId } @@ -200,10 +201,65 @@ export function buildNodeModel(i: NodeModelInput): NodeModel { } /** - * A new device worth telling the user about: not in your cockpit, on the bus now (onBusNow), and - * identified as something that can be adopted. Until then it is not news — not on the Cockpit - * count or the Overview notice. One predicate for every tab (node 0 is never in the model). + * How long an unidentified new board has to report its kind once it comes online (S6: counted from + * `onlineSince`, not from when it was first seen). A normal board reports it ~1 s after it appears, + * so until this has passed it is simply still identifying — not an issue, it never flashes a warning. + */ +export const IDENTIFY_WAIT_MS = 5000 + +/** + * What a board outside your cockpit is to the user right now (X3 / D1) — the one rule every tab and + * the event log read: + * - `news`: identified as a PDU or a panel group, so it can be adopted — the Cockpit's "New on the + * bus", the Overview's notice, and the log's one "new on the bus" row; + * - `identifying`: not identified yet, inside its IDENTIFY_WAIT_MS since it came online; `strayAt` + * is when it becomes a stray; + * - `stray`: can't be adopted — it identified as a kind that can't be (a second Gateway, a type this + * client doesn't know), or is still unidentified IDENTIFY_WAIT_MS after it came online. A board on + * the bus is active, so it is a warning on every tab (Cockpit, Overview, Connection). + * undefined: in your cockpit, or not on the bus now (onBusNow). + */ +export type NewState = + | { state: 'news' } + | { state: 'identifying'; strayAt: number } + | { state: 'stray' } + +export function newState(e: NodeModelEntry, now: number, live: boolean): NewState | undefined { + if (e.source !== 'new' || !onBusNow(e, live)) return undefined + if (isAdoptableKind(e.identified)) return { state: 'news' } + if (e.identified) return { state: 'stray' } + const strayAt = (e.onlineSince ?? 0) + IDENTIFY_WAIT_MS + return strayAt <= now ? { state: 'stray' } : { state: 'identifying', strayAt } +} + +/** + * A new device worth telling the user about: newState's `news` (that answer never reads the time). + * One predicate for every tab and the event log (node 0 is never in the model). */ export function isReportableNew(e: NodeModelEntry, live: boolean): boolean { - return e.source === 'new' && onBusNow(e, live) && isAdoptableKind(e.identified) + return newState(e, 0, live)?.state === 'news' +} + +/** What a stray is, for its Attention line and its log row (X3): never inferred from its NODE_ID. */ +export function strayKind(e: Pick): string { + if (e.identified === 'bridge') return 'second Gateway' + return e.identified ? 'unknown board type' : 'unidentified' +} + +/** + * Where a board outside your cockpit comes from (X8 / C6), worded once for main's surfaces — the + * board's flag and fault box and the log's "new on the bus" row — as the Overview's notice words + * it: a registry board waiting to be added is `from NODE_IDS.md`; any other NODE_ID is + * `not in NODE_IDS.md`. + */ +export function newReason(e: Pick): string { + return e.registry ? 'from NODE_IDS.md' : 'not in NODE_IDS.md' +} + +/** + * Online by its PDU pages alone: the one availability rule's `telemetry` evidence (nodeAvailability: + * a present roster row is the roster's evidence; anything else online is the pages'). + */ +export function onlineByPages(e: Pick): boolean { + return e.availability === 'online' && !e.status?.present } diff --git a/src/main/nodes/node-status-wire.test.ts b/src/main/nodes/node-status-wire.test.ts index 95f7ea3..2937ba1 100644 --- a/src/main/nodes/node-status-wire.test.ts +++ b/src/main/nodes/node-status-wire.test.ts @@ -143,10 +143,10 @@ describe('NodeRoster', () => { expect(r.takeDirty()).toBe(true) }) - it('records first-seen, the last report, and when / how a node went offline', () => { + it('records first-seen, online-since, the last report, and when / how a node went offline', () => { const r = new NodeRoster() r.applyMessage(NODE_MSG, '0101000000000000008000000000', 1000) - expect(r.seenInfo().get(1)).toEqual({ firstSeenAt: 1000, reportedAt: 1000 }) + expect(r.seenInfo().get(1)).toEqual({ firstSeenAt: 1000, onlineSince: 1000, reportedAt: 1000 }) r.applyMessage(NODE_MSG, '0100000000000000008000000000', 5000) // live delta: offline expect(r.seenInfo().get(1)).toEqual({ firstSeenAt: 1000, @@ -156,8 +156,23 @@ describe('NodeRoster', () => { }) r.applyMessage(NODE_MSG, '0100000000000000008000000000', 6000) // repeat keeps the first time expect(r.seenInfo().get(1)?.offlineAt).toBe(5000) + // Back online: online since now (S6), first seen still 1000. r.applyMessage(NODE_MSG, '0101000000000000008000000000', 7000) - expect(r.seenInfo().get(1)).toEqual({ firstSeenAt: 1000, reportedAt: 7000 }) + expect(r.seenInfo().get(1)).toEqual({ firstSeenAt: 1000, onlineSince: 7000, reportedAt: 7000 }) + r.applyMessage(NODE_MSG, '0101000000000000008000000000', 9000) // still online: since 7000 + expect(r.seenInfo().get(1)).toEqual({ firstSeenAt: 1000, onlineSince: 7000, reportedAt: 9000 }) + }) + + it('online since: a first absent row sets none; a roster END that finds it missing clears it (S6)', () => { + const r = new NodeRoster() + r.applyMessage(NODE_MSG, '0100000000000000008000000000', 0) // listed absent first + expect(r.seenInfo().get(1)?.onlineSince).toBeUndefined() + r.applyMessage(NODE_MSG, '0101000000000000008000000000', 60_000) + expect(r.seenInfo().get(1)).toMatchObject({ firstSeenAt: 0, onlineSince: 60_000 }) + r.beginBurst(70_000) + r.applyMessage(NODE_END_MSG, '', 70_000) // a reply without it: offline + expect(r.seenInfo().get(1)).toMatchObject({ offlineAt: 70_000 }) + expect(r.seenInfo().get(1)?.onlineSince).toBeUndefined() }) it('an offline found by a roster reply is marked roster, and the first END is remembered', () => { diff --git a/src/main/nodes/node-status-wire.ts b/src/main/nodes/node-status-wire.ts index 26755ce..93f9e40 100644 --- a/src/main/nodes/node-status-wire.ts +++ b/src/main/nodes/node-status-wire.ts @@ -129,6 +129,11 @@ export function endRowCount(arg: string): number | undefined { return /^\d+$/.test(a) ? Number(a) : undefined } +/** A board's timing once it is absent: no longer online since anything. */ +function offline({ onlineSince: _onlineSince, ...seen }: NodeSeen): NodeSeen { + return seen +} + /** * Tracks every node seen since reset. Bare messages are live deltas; a request/boot * burst ends with `_NODE_STATUS_END`, after which nodes absent from the burst are @@ -213,13 +218,17 @@ export class NodeRoster { this.replying = true } this.burst.push(ns) - const wasPresent = this.nodes.get(ns.nodeId)?.present ?? true + const prev = this.nodes.get(ns.nodeId) + const wasPresent = prev?.present ?? true this.nodes.set(ns.nodeId, ns) // keep absent nodes too; present flag carries state const seen = this.seen.get(ns.nodeId) ?? { firstSeenAt: now } - if (ns.present) this.seen.set(ns.nodeId, { firstSeenAt: seen.firstSeenAt, reportedAt: now }) - else if (wasPresent || seen.offlineAt === undefined) { + if (ns.present) { + // Online since its first present row, or since the first one after an absent one (S6). + const onlineSince = prev?.present ? (seen.onlineSince ?? now) : now + this.seen.set(ns.nodeId, { firstSeenAt: seen.firstSeenAt, onlineSince, reportedAt: now }) + } else if (wasPresent || seen.offlineAt === undefined) { this.seen.set(ns.nodeId, { - ...seen, + ...offline(seen), offlineAt: now, offlineVia: this.inReply(now) ? 'roster' : 'delta' }) @@ -241,7 +250,7 @@ export class NodeRoster { if (!inBurst.has(id) && node.present) { this.nodes.set(id, { ...node, present: false }) const seen = this.seen.get(id) ?? { firstSeenAt: now } - this.seen.set(id, { ...seen, offlineAt: now, offlineVia: 'roster' }) + this.seen.set(id, { ...offline(seen), offlineAt: now, offlineVia: 'roster' }) } } this.endReply() diff --git a/src/main/nodes/phase0-probes.test.ts b/src/main/nodes/phase0-probes.test.ts index f82ccb5..83e0a68 100644 --- a/src/main/nodes/phase0-probes.test.ts +++ b/src/main/nodes/phase0-probes.test.ts @@ -3,6 +3,9 @@ // nothing. A fix ticket that changes one of these behaviours on purpose updates its probe. // Main and shared only; the probes that read renderer helpers are in // src/renderer/src/phase0-probes.test.ts (main never imports renderer code). +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import { DEFAULT_PSU, type NodeDefaults, type NodeKind, type NodeMappingFile } from '@shared/nodes' import { NODE_END_MSG, NODE_MSG, NodeRoster } from './node-status-wire' @@ -121,7 +124,7 @@ describe('fix-track probes (today)', () => { expect(a.h.forget(1).ok).toBe(false) }) - it('C4: a node identified only by PDU telemetry logs "new on the bus" only at its next status row', () => { + it('C4 (fixed, #133): a node identified only by PDU telemetry logs "new on the bus" when its pages identify it', () => { const a = hubWith({ version: 4, nodes: {} }) a.status(1, 1, 0) a.roster.applyMessage(NODE_END_MSG, '1', 0) @@ -129,11 +132,43 @@ describe('fix-track probes (today)', () => { a.status(9, 0, 2000) // kind 0, never reports a kind a.h.telemetry('090100E02E182EBC02', 2500, '_NODE_TELEM 090100E02E182EBC02') expect(a.h.snapshot(2500).model.nodes.find((n) => n.nodeId === 9)?.identified).toBe('pdu') - expect(a.events.filter((e) => e.nodeId === 9 && e.event === 'new on the bus')).toEqual([]) - a.status(9, 0, 7000) // next roster poll row: only now is it news + // Was: no row until its next status row (7000). expect(a.events.filter((e) => e.nodeId === 9 && e.event === 'new on the bus')).toMatchObject([ - { t: 7000 } + { t: 2500 } ]) + a.status(9, 0, 7000) // next roster poll row: no second row + expect(a.events.filter((e) => e.nodeId === 9 && e.event === 'new on the bus')).toHaveLength(1) + }) + + it('C4 (fixed, #133): the "seen new" record is cleared on Forget — re-plugged, it is new again', () => { + const a = hubWith({ version: 4, nodes: {} }) + a.status(1, 1, 0) + a.roster.applyMessage(NODE_END_MSG, '1', 0) + a.h.statusLine(NODE_END_MSG, '_NODE_STATUS_END 1', 0) + const news = () => a.events.filter((e) => e.nodeId === 41 && e.event === 'new on the bus') + a.status(41, 1, 1000) // a panel group, not in your cockpit: new + expect(news()).toHaveLength(1) + const spec = { name: 'Left_Oxygen', console: 'Left', pduId: null, kind: 'panel' } as const + expect(a.h.adopt(41, spec).ok).toBe(true) + a.status(41, 1, 2000, false) // unplugged + a.h.flush(true, 2000) + expect(a.h.forget(41, 2000).ok).toBe(true) + a.status(41, 1, 3000) // plugged back in + expect(news()).toHaveLength(2) + }) + + it('X8 / C6 (fixed, #133): no source file words a new board the old way', () => { + // The old wording for a board outside your cockpit — the flag, the fault box's title and the + // log's detail — and the log's old detail for a registry one. Built here so this file passes. + const old = [new RegExp(['not', 'in', 'registry'].join(' '), 'i'), /'in NODE_IDS\.md'/] + const src = fileURLToPath(new URL('../..', import.meta.url)) + const hits = readdirSync(src, { recursive: true, encoding: 'utf8' }) + .filter((f) => /\.(ts|tsx)$/.test(f)) + .flatMap((f) => { + const text = readFileSync(join(src, f), 'utf8') + return old.filter((re) => re.test(text)).map((re) => `${f}: ${re.source}`) + }) + expect(hits).toEqual([]) }) it('M7: hub.reset() (port reopen after error) clears wasPresent without writing lastSeen', () => { diff --git a/src/renderer/src/nodes/AdoptDialog.tsx b/src/renderer/src/nodes/AdoptDialog.tsx index 2151a26..5910327 100644 --- a/src/renderer/src/nodes/AdoptDialog.tsx +++ b/src/renderer/src/nodes/AdoptDialog.tsx @@ -157,7 +157,7 @@ export function AdoptDialog(props: {
{v.entry.registry - ? `${v.entry.registry.name} · in NODE_IDS.md` + ? `${v.entry.registry.name} · from NODE_IDS.md` : seenAgo(v.entry.firstSeenAt, Date.now())}
diff --git a/src/renderer/src/nodes/node-view.test.ts b/src/renderer/src/nodes/node-view.test.ts index 358330c..b4076c5 100644 --- a/src/renderer/src/nodes/node-view.test.ts +++ b/src/renderer/src/nodes/node-view.test.ts @@ -51,6 +51,7 @@ const summary = ( return nodesSummary( nodeViews(m, telemetry), outages, + [], boardZones(m.nodes, cockpitMembers(m).get), true ) diff --git a/src/renderer/src/nodes/phase0-probes.test.ts b/src/renderer/src/nodes/phase0-probes.test.ts index aeb0dba..4e5802c 100644 --- a/src/renderer/src/nodes/phase0-probes.test.ts +++ b/src/renderer/src/nodes/phase0-probes.test.ts @@ -50,7 +50,7 @@ const row = (id: number, kind: number, present = true) => `${id.toString(16).padStart(2, '0')}${present ? '01' : '00'}0000000000000080000000${kind.toString(16).padStart(2, '0')}`.toUpperCase() describe('fix-track probes (today)', () => { - it('D1 conflict: one stray is a Cockpit warning but Overview/Connection see nothing', () => { + it('D1 (fixed, #133 X3): one stray is a warning on the Cockpit, the Overview and Connection alike', () => { const roster = new NodeRoster() roster.applyMessage(NODE_MSG, row(9, 0), 0) const model = buildNodeModel({ @@ -66,27 +66,25 @@ describe('fix-track probes (today)', () => { const cockpit = cockpitViews(model, []) const { strays } = strayNew(cockpit, 6000, true) expect(strays).toHaveLength(1) + const items = [{ nodeId: 9, flag: 'UNIDENTIFIED · can’t be adopted' }] const cockpitBand = cockpitHealthBand(cockpit, strays.length) expect(cockpitBand.warn).toBe(1) - expect( - cockpitAttentionItems(cockpit, [], [{ nodeId: 9, flag: 'UNIDENTIFIED · can’t be adopted' }]) - ).toHaveLength(1) - // Overview - const ovBand = healthBand(views) - expect(cockpitTile(ovBand, true)).toMatchObject({ value: '—', sub: 'no nodes known' }) - expect(attentionSummary(ovBand, attentionItems(views, []))).toBeNull() - expect(newDevices(views, true)).toBeNull() - // Connection - expect( - nodesSummary(views, [], boardZones(model.nodes, cockpitMembers(model).get), true) - ).toMatchObject({ - known: 0, - warn: 0, - rows: [] + expect(cockpitAttentionItems(cockpit, [], items)).toHaveLength(1) + // Overview: the same band and items (was: "no nodes known", no banner) + const ovBand = healthBand(views, strays.length) + expect(cockpitTile(ovBand, true)).toMatchObject({ value: '1 / 1', symbol: 'warn' }) + expect(attentionSummary(ovBand, attentionItems(views, [], items))).toEqual({ + head: '1 node needs attention', + detail: '1 warning (NODE_ID 9)' }) + expect(newDevices(views, true)).toBeNull() // a stray is not news + // Connection (was: known 0, warn 0, no rows) + expect( + nodesSummary(views, [], items, boardZones(model.nodes, cockpitMembers(model).get), true) + ).toMatchObject({ known: 1, warn: 1, rows: [{ nodeId: 9, state: 'warn' }] }) }) - it('S6: grace counts from first sighting — a board seen absent then online late is a stray at once', () => { + it('S6 (fixed, #133): grace counts from coming online — a board seen absent, online late, waits 5 s', () => { const roster = new NodeRoster() roster.applyMessage(NODE_MSG, row(9, 0, false), 0) roster.applyMessage(NODE_MSG, row(9, 0, true), 60_000) @@ -99,10 +97,12 @@ describe('fix-track probes (today)', () => { defaults, file: EMPTY_MAPPING }) - expect(strayNew(cockpitViews(model, []), 60_001, true).strays).toHaveLength(1) + // Was: a stray at once (60 001), the grace counted from the absent row at 0. + expect(strayNew(cockpitViews(model, []), 60_001, true)).toEqual({ strays: [], nextAt: 65_000 }) + expect(strayNew(cockpitViews(model, []), 65_000, true).strays).toHaveLength(1) }) - it('C6: a registry board waiting to be added is labelled "NOT IN REGISTRY" / "Not in registry"', () => { + it('C6 (fixed, #133 X8): a registry board waiting to be added is "from NODE_IDS.md" everywhere', () => { const roster = new NodeRoster() roster.applyMessage(NODE_MSG, row(2, 1), 0) const model = buildNodeModel({ @@ -116,10 +116,11 @@ describe('fix-track probes (today)', () => { }) const e = model.nodes.find((n) => n.nodeId === 2)! expect(e.registry?.name).toBe('Right_Navigation') - expect(cockpitNodeHealth(e).flag).toBe('NOT IN REGISTRY') + // Was: the registry wording, in capitals and as a title (main phase0-probes X8 probe). + expect(cockpitNodeHealth(e).flag).toBe('FROM NODE_IDS.md') expect( nodeFaultBoxes(cockpitViews(model, []).find((v) => v.entry.nodeId === 2)!)[0]!.title - ).toBe('Not in registry') + ).toBe('From NODE_IDS.md') }) it('#95 bug 2 (fixed, #131 X1): a hidden in-cockpit PDU is in your cockpit to Add all and the PDU choices', () => { diff --git a/src/renderer/test/scenarios.ts b/src/renderer/test/scenarios.ts index 3324b87..83cb67e 100644 --- a/src/renderer/test/scenarios.ts +++ b/src/renderer/test/scenarios.ts @@ -124,6 +124,7 @@ export const newBoard = (nodeId: number, firstSeenAt: number, over: Partial { ).toHaveTextContent('NODE_ID 8') }) - it('the 5 s grace counts from firstSeenAt, not from when it came online (F26 today)', async () => { - // contract F26 says the grace should count from `onlineSince` (reset on each offline → online); - // today the UI counts from `firstSeenAt`, which an absent roster row also sets — so a board - // first listed absent a minute ago that has just come online is a stray at once. + it('the 5 s grace counts from when it came online, not from first sighting (F26 / S6, #133)', async () => { + // First listed (absent) a minute ago, online only now: still identifying, a stray 5 s later. vi.useFakeTimers({ now: T0 }) await renderScenario(, { device: connected(), - model: model([newBoard(9, T0 - 60_000, { reportedAt: T0 })]) + model: model([newBoard(9, T0 - 60_000, { onlineSince: T0, reportedAt: T0 })]) + }) + expect(attentionRows()).toEqual([]) + expect(screen.getByText('Nothing needs attention.')).toBeInTheDocument() + act(() => { + vi.advanceTimersByTime(5100) }) expect(attentionRows()).toEqual(['!NODE_ID 9UNIDENTIFIED · can’t be adopted · #9']) }) - it('the Overview does not count the same stray (X1 today)', async () => { - // contract D1 says strays show on every tab; today only the Cockpit shows them. + it('the Overview counts the same stray (D1 / X3, #133)', async () => { + // One rule for every tab: the stray is one board up and a warning, as on the Cockpit. vi.useFakeTimers({ now: T0 }) await renderScenario(, { device: connected(), model: model([newBoard(9, T0 - 60_000, { identified: 'bridge' })]) }) - expect(screen.getByText('no nodes known')).toBeInTheDocument() - expect(screen.queryByText(/need.? attention/)).toBeNull() + expect(screen.queryByText('no nodes known')).toBeNull() + expect(screen.getByRole('status')).toHaveTextContent( + '1 node needs attention — 1 warning (NODE_ID 9)' + ) }) it('a healthy board in your cockpit is not a stray', async () => { diff --git a/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx b/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx index 07a8833..32114c5 100644 --- a/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx +++ b/src/renderer/test/t10-adopt-dialog-queue-form.test.tsx @@ -51,7 +51,7 @@ describe('T10 Adopt dialog: queue and form (B74, B21, B22)', () => { it('lists every new board, oldest first seen first, then by NODE_ID, with its badge', async () => { const { dlg, options } = await openAdopt() expect(options()).toEqual([ - 'NODE_ID 8PANEL GROUPLeft_Oxygen · in NODE_IDS.md', + 'NODE_ID 8PANEL GROUPLeft_Oxygen · from NODE_IDS.md', 'NODE_ID 12PANEL GROUPfirst seen 1 min ago', 'NODE_ID 41PDUfirst seen 1 min ago', 'NODE_ID 9IDENTIFYINGfirst seen just now', diff --git a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx index 625cd3e..8192fde 100644 --- a/src/renderer/test/t11-adopt-dialog-add-all.test.tsx +++ b/src/renderer/test/t11-adopt-dialog-add-all.test.tsx @@ -65,7 +65,9 @@ describe('T11 Adopt dialog: Add all, PDU choices, power-cycle (B74, F15, F24, X3 await addAll(2) expect(sky.adoptNode).toHaveBeenCalledTimes(2) expect(sky.adoptNode.mock.calls.map(([id]) => id)).toEqual([2, 7]) - expect(options().map((o) => o.textContent)).toEqual(['NODE_ID 6PDUPDU_Center · in NODE_IDS.md']) + expect(options().map((o) => o.textContent)).toEqual([ + 'NODE_ID 6PDUPDU_Center · from NODE_IDS.md' + ]) }) it('a double click on Add all runs it once; the dialog is busy until it lands (R6)', async () => { diff --git a/src/renderer/test/t18-node-panel-status.test.tsx b/src/renderer/test/t18-node-panel-status.test.tsx index aabc2a0..ddf0139 100644 --- a/src/renderer/test/t18-node-panel-status.test.tsx +++ b/src/renderer/test/t18-node-panel-status.test.tsx @@ -139,7 +139,7 @@ describe('T18 Node panel, Status tab (B70, B14, F11)', () => { }) it('a zone naming a board not in your cockpit is No power zone: Powered by opens nothing (D7)', async () => { - // contract C6/X5 (the "Not in registry" label) is pinned in main (phase0-probes C6). A new + // contract C6/X8 (a new board's "from NODE_IDS.md" label, #133) is pinned in phase0-probes C6. A new // board's panel was reached only through a placed board's Powered by (X2 R3); with D7 (#131) a // zone is only ever a PDU in your cockpit, so Powered by reads the resolved zone: none here. const { sheet } = await openStatus({ pduId: 41 }, [ diff --git a/src/renderer/test/t22-log-events.test.tsx b/src/renderer/test/t22-log-events.test.tsx index 5ed8536..b991ba2 100644 --- a/src/renderer/test/t22-log-events.test.tsx +++ b/src/renderer/test/t22-log-events.test.tsx @@ -30,7 +30,7 @@ const session = () => [ name: 'Left_Oxygen', severity: 'new', event: 'new on the bus', - detail: 'in NODE_IDS.md' + detail: 'from NODE_IDS.md' }), logEvent(5, { source: 'client', @@ -64,7 +64,7 @@ describe('T22 Log › Events (B78, B55)', () => { const { user, button } = await openEvents() expect(rows()).toEqual([ '12:00:05.000✕Client · Relaystart refused · no board', - '12:00:04.000+Left_Oxygennew on the bus · in NODE_IDS.md', + '12:00:04.000+Left_Oxygennew on the bus · from NODE_IDS.md', '12:00:03.000!PDU_Right5V UNDER-V raised · 4.71 V', '12:00:02.000●Client · SERIALconnected · /dev/cu.usbmodem1', '12:00:01.000✕Center_Armamentoffline · no heartbeat' @@ -73,7 +73,7 @@ describe('T22 Log › Events (B78, B55)', () => { await user.click(button('Nodes')) expect(rows()).toEqual([ - '12:00:04.000+Left_Oxygennew on the bus · in NODE_IDS.md', + '12:00:04.000+Left_Oxygennew on the bus · from NODE_IDS.md', '12:00:03.000!PDU_Right5V UNDER-V raised · 4.71 V', '12:00:01.000✕Center_Armamentoffline · no heartbeat' ]) diff --git a/src/renderer/test/t3-overview-tile-banners.test.tsx b/src/renderer/test/t3-overview-tile-banners.test.tsx index 2656d71..61a8281 100644 --- a/src/renderer/test/t3-overview-tile-banners.test.tsx +++ b/src/renderer/test/t3-overview-tile-banners.test.tsx @@ -87,17 +87,18 @@ describe('T3 Overview: Cockpit tile and banners (B58, B59, B42, X1)', () => { expect(screen.queryByRole('alert')).toBeNull() }) - it('the Overview doesn’t count a stray (X1 today)', async () => { - // contract D1 says strays show on every tab; today the Overview ignores them: the Cockpit - // tile reads "no nodes known" and no banner shows, while the Cockpit lists it as a warning. + it('the Overview counts a stray as the Cockpit does (D1 / X3, #133)', async () => { + // One rule for every tab: the tile counts it up and warning, and the banner names it. await renderScenario(, { device: connected(), model: model([newBoard(9, T0 - 60_000, { identified: 'bridge' })]) }) const tile = cockpitTile() - expect(within(tile).getByText('—')).toBeInTheDocument() - expect(within(tile).getByText('no nodes known')).toBeInTheDocument() - expect(screen.queryByRole('status')).toBeNull() + expect(within(tile).getByText('1 / 1')).toBeInTheDocument() + expect(within(tile).queryByText('no nodes known')).toBeNull() + expect(screen.getByRole('status')).toHaveTextContent( + '1 node needs attention — 1 warning (NODE_ID 9)' + ) }) it('its counts equal the Cockpit band for the same model', async () => { diff --git a/src/renderer/test/t34-overview-cockpit-agreement.test.tsx b/src/renderer/test/t34-overview-cockpit-agreement.test.tsx index 5b031ab..dcfbc38 100644 --- a/src/renderer/test/t34-overview-cockpit-agreement.test.tsx +++ b/src/renderer/test/t34-overview-cockpit-agreement.test.tsx @@ -1,5 +1,5 @@ // T34 — one node model, three surfaces: the Overview, the Cockpit and the Connection tab count the -// same boards the same way — except a stray, which only the Cockpit counts (X1 today). +// same boards the same way — a stray included (D1 / X3, #133). import { describe, expect, it } from 'vitest' import { screen } from '@testing-library/react' import { App } from '../src/app/App' @@ -61,30 +61,29 @@ const connection = () => ['healthy', 'warning', 'offline'].map((l) => screen.getByText(l).previousSibling!.textContent) describe('T34 Overview ↔ Cockpit ↔ Connection agreement (X1, B58, B63, B67)', () => { - it('one nodes:model push: the same offline and warning counts on every tab, but the stray', async () => { + it('one nodes:model push: the same offline and warning counts on every tab, the stray too', async () => { const { sky, user } = await renderScenario(, { device: connected() }) sky.emitScenario({ model: scene() }) - // Overview: 3 of 6 up; 2 offline and 1 warning. The stray is not counted (X1 today). + // Overview: 4 of 7 up; 2 offline and 2 warnings — the stray is one up and one warning (D1). expect(overview()).toEqual({ - value: '✕3 / 6', + value: '✕4 / 7', sub: 'nodes up · — W · 2 PDUs', - banner: '⚠ 3 nodes need attention — 2 offline (PDU_Right + 1 node) · 1 warning. Open Cockpit' + banner: '⚠ 4 nodes need attention — 2 offline (PDU_Right + 1 node) · 2 warnings. Open Cockpit' }) - // Cockpit: the same 2 offline and 1 no status yet; the stray adds one up, one warning and one - // to the total (contract D1 says every tab counts it; today only the Cockpit does — X1). + // Cockpit: the same 2 offline, 2 warnings and 1 no status yet. await user.click(tab('Cockpit')) expect(cockpit()).toEqual(['4/ 7', '2 warning · 2 offline · 1 no status yet']) - // Connection: 3 up = 2 healthy + 1 warning, 2 offline — the Overview's numbers, no stray. + // Connection: 4 up = 2 healthy + 2 warnings, 2 offline — the same numbers, with the stray's row. await user.click(tab('Connection')) - expect(connection()).toEqual(['2', '1', '2']) + expect(connection()).toEqual(['2', '2', '2']) expect(screen.getByText('PDUs · — W').parentElement).toHaveTextContent('2PDUs · — W') expect( - screen.queryByRole('button', { name: 'Open Node 9 in Cockpit' }), - 'no row for the stray' - ).toBeNull() + screen.getByRole('button', { name: 'Open NODE_ID 9 in Cockpit' }), + 'a row for the stray' + ).toBeInTheDocument() }) it('without the stray, all three agree exactly', async () => { @@ -115,11 +114,16 @@ describe('T34 Overview ↔ Cockpit ↔ Connection agreement (X1, B58, B63, B67)' newBoard(9, 0, { identified: 'bridge' }) ]) }) - expect(overview()).toEqual({ value: '5 / 6', sub: 'nodes up · — W · 2 PDUs', banner: null }) + // The stray stays: one warning on every tab. + expect(overview()).toEqual({ + value: '!6 / 7', + sub: 'nodes up · — W · 2 PDUs', + banner: '⚠ 1 node needs attention — 1 warning (NODE_ID 9). Open Cockpit' + }) await user.click(tab('Cockpit')) expect(cockpit()).toEqual(['6/ 7', '1 warning · 0 offline · 1 no status yet']) await user.click(tab('Connection')) - expect(connection()).toEqual(['5', '0', '0']) - expect(screen.getByText('Nothing needs attention.')).toBeInTheDocument() + expect(connection()).toEqual(['5', '1', '0']) + expect(screen.queryByText('Nothing needs attention.')).toBeNull() }) }) diff --git a/src/renderer/test/t4-overview-new-devices.test.tsx b/src/renderer/test/t4-overview-new-devices.test.tsx index d0fbf7d..951bf21 100644 --- a/src/renderer/test/t4-overview-new-devices.test.tsx +++ b/src/renderer/test/t4-overview-new-devices.test.tsx @@ -1,5 +1,5 @@ // T4 — Overview: the new-devices notice. -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { screen, within } from '@testing-library/react' import { Overview } from '../src/app/overview/Overview' import { App } from '../src/app/App' @@ -48,12 +48,17 @@ describe('T4 Overview: new-devices notice (B60)', () => { }) it('a board still identifying, or one that can’t be adopted, is not mentioned', async () => { + vi.useFakeTimers({ now: T0 }) // #9 came online just now: still identifying await renderScenario(, { device: connected(), model: model([newBoard(9, T0), newBoard(10, T0, { identified: 'bridge' })]) }) expect(notice()).toBeUndefined() - expect(screen.queryByRole('status')).toBeNull() + // The one that can't be adopted is a stray: the attention banner's warning (D1 / X3, #133), + // not news. The one still identifying is neither. + expect(screen.getByRole('status')).toHaveTextContent( + '1 node needs attention — 1 warning (NODE_ID 10)' + ) }) it('it is hidden while the link is down', async () => { diff --git a/src/shared/app/ipc.compat.test-d.ts b/src/shared/app/ipc.compat.test-d.ts index 1cd257d..be82626 100644 --- a/src/shared/app/ipc.compat.test-d.ts +++ b/src/shared/app/ipc.compat.test-d.ts @@ -104,6 +104,11 @@ // `failed`: every board refused, in order. Un-hide all now tries every board and lists each one // it couldn't un-hide; Add all still stops at its first refusal (one entry). // +// Fix track #133 (D1/X3, S6: strays on every tab, grace from coming online): a type change only — +// no channel, intent or method was added, removed or renamed. `NodeModelEntry` (`nodes:model`) and +// `NodeSeen` gained the optional `onlineSince`: when a board last came online, which a new board's +// identify grace counts from. `NodesSummary.known` now counts the strays too (no shape change). +// // Type-only: never imported, never run, not matched by vitest (`*.test.ts`) or the build. import type { Mutate, StoreApi } from 'zustand/vanilla' import type {} from 'zustand/middleware' // registers the subscribeWithSelector store mutator diff --git a/src/shared/nodes/ipc.ts b/src/shared/nodes/ipc.ts index 0a3bb3f..3ac0ab1 100644 --- a/src/shared/nodes/ipc.ts +++ b/src/shared/nodes/ipc.ts @@ -30,6 +30,11 @@ export interface NodeStatus { /** When a node was first seen and, while it is offline, when and how it went offline. */ export interface NodeSeen { firstSeenAt: number + /** + * Set while its roster row says present: when it last came online — its first present row, or + * the first one after an absent one (S6: a new board's identify grace counts from here). + */ + onlineSince?: number /** When its latest status row arrived while it was present — what "updated N s ago" counts from. */ reportedAt?: number /** Set while offline: when the client learned of it. */ @@ -256,6 +261,11 @@ export interface NodeModelEntry { reportedAt?: number /** New nodes: when this session first saw it. Not persisted. */ firstSeenAt?: number + /** + * New nodes on the roster: when they last came online this session (offline → online resets it). + * The identify grace counts from here (node-model.ts newState, S6). Not persisted. + */ + onlineSince?: number /** Online PDUs: 'none' = status but no fresh telemetry (never, or > TELEM_STALE_MS). */ readings?: 'live' | 'none' /** PDUs: telemetry has arrived at least once since the session started. */ @@ -757,7 +767,10 @@ export interface SummaryRow { /** Connection › Connected nodes. */ export interface NodesSummary { - /** Known nodes (registry + adopted, PDUs included); new nodes are not counted. */ + /** + * Known nodes (registry + adopted, PDUs included) plus the strays on the bus (X3: the Cockpit's + * band total); other new nodes are not counted. + */ known: number healthy: number warn: number