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