Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 140 additions & 15 deletions src/main/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
nodeStatus(nodeId, { uptimeSec: 0, rxCount: 0, dieTempC: null, ...over })

const NAMES: Record<number, string> = { 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<number, Partial<NodeModelEntry>> = {}): 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]`. */
Expand Down Expand Up @@ -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([
Expand All @@ -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]'
])
})

Expand All @@ -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<number, Partial<NodeModelEntry>> = { 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<NodeModelEntry> = {}) =>
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', () => {
Expand Down
11 changes: 8 additions & 3 deletions src/main/nodes/cockpit-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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([])
Expand Down
17 changes: 12 additions & 5 deletions src/main/nodes/cockpit-snapshot/adopt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ describe('strayNew', () => {
const ids = (r: ReturnType<typeof strayNew>) => 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])
Expand All @@ -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', () => {
Expand Down
38 changes: 13 additions & 25 deletions src/main/nodes/cockpit-snapshot/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[],
Expand All @@ -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`
}

/**
Expand Down
28 changes: 27 additions & 1 deletion src/main/nodes/cockpit-snapshot/connection-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const summary = (
return nodesSummary(
nodeViews(m, telemetry),
outages,
[],
boardZones(m.nodes, cockpitMembers(m).get),
true
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -136,7 +160,9 @@ describe('nodesSummary rows', () => {
]
}
const meta = (mem: ReturnType<typeof cockpitMembers>) =>
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)}`
])
Expand Down
Loading
Loading