Skip to content

Allow receiving data from other vehicles on the network - #2938

Open
rafaellehmkuhl wants to merge 8 commits into
bluerobotics:masterfrom
rafaellehmkuhl:add-multiple-vehicle-data-lake-support
Open

Allow receiving data from other vehicles on the network#2938
rafaellehmkuhl wants to merge 8 commits into
bluerobotics:masterfrom
rafaellehmkuhl:add-multiple-vehicle-data-lake-support

Conversation

@rafaellehmkuhl

@rafaellehmkuhl rafaellehmkuhl commented Aug 14, 2026

Copy link
Copy Markdown
Member

Adds "get" support for extra vehicles in the network by pointing their address in the General settings. Each one's telemetry is mirrored into the data lake under /mavlink/<system id>/..., so widgets can read it and a point of interest can follow each vehicle on the map.

Example below showing my vehicle in Brazil and Tony's vehicle in Hawaii:

image

These links are read-only and bypass the ConnectionManager, since adding a connection there replaces the main one and its onRead is what feeds the main vehicle and the vehicle factory. So none of the extra vehicles can be commanded, or become the vehicle being piloted.

The first commit is a behavior-preserving refactor: turning a MAVLink package into data-lake variables never needed the vehicle instance, only the system id that also gets the legacy unprefixed names, so it moves out of MAVLinkVehicle into a module any connection can call.

Only systems that announce themselves as a vehicle are mirrored, and only from the first link to claim each system ID, so neither two vehicles sharing an ID nor one using the piloted vehicle's can write over each other's variables. The panel warns when that happens, so the user knows why a vehicle is not showing up. The configured addresses are stored machine-local, since they are addresses on the local network, and no migration is involved.

Each mirrored vehicle also gets /vehicles/mavlink/<system id>/isReceivingData, a boolean data-lake variable saying whether data from that vehicle is currently arriving. Without it, a vehicle dropping mid-flight is invisible: its variables just stop updating and widgets keep rendering the last value received. It is kept up to date app-wide with no UI mounted, uses the same silence threshold as the link status in the panel, and is written false once when the address is removed.

A behavior change rides along in its own commit: a {{ variable }} in a transforming function's expression is now substituted as a value instead of being pasted into the code that gets evaluated, so no vehicle on the network can choose what an expression runs. Each value keeps its own type, so a saved expression that adds up a variable which arrives as text (a Generic WebSocket supplier can force that by quoting the value it sends) now concatenates the digits where it used to add them, and a strict comparison against a number stops matching. Placeholders inside a string literal are unaffected, and the expression info popup states that a variable enters with its own type.

Fixes #1098.

@rafaellehmkuhl rafaellehmkuhl changed the title Receive telemetry from other vehicles on the network Allow receiving data from other vehicles on the network Aug 14, 2026
@github-actions

Copy link
Copy Markdown

Automated PR Review — round 1

Warning

⚠️ IMPORTANT FIXES REQUIRED — 7 open: 2 major (1.1, 1.2) and 5 minor.

Adds a settings panel where the user lists extra vehicles by websocket address. Cockpit opens a read-only link to each at startup and copies everything those links carry into the shared pool of telemetry values that widgets and map markers read from. The links never join the connection manager, so none of those vehicles can be commanded or become the one being piloted — that part holds up. What does not hold up is the naming of the copied values: they are filed under the vehicle's own system number, which by factory default is the same number the piloted vehicle uses, so the two overwrite each other.

What still needs attention

# Problem What it means Severity Status
1.1 Other vehicles write into the piloted vehicle's telemetry slots If a second vehicle ships with the factory-default ID — most do — the pilot's own altitude, attitude and position readouts silently start showing the other vehicle's numbers. major
1.2 An existing "off" switch is bypassed Adding one other vehicle also pulls in telemetry from every other device on that vehicle's network, even for users who explicitly turned that off. major
5.1 Reconnect timing is not wired up like the main link A mistyped address reconnects forever in the background, and the status light never settles on anything the user can act on. minor
6.1 The address box tells the user nothing The panel's only input is an unlabeled empty box, and the example address the code prepares is never shown anywhere. minor
7.1 The panel duplicates the one right below it The same list-of-connections screen now exists twice on one settings page, so every future fix has to be made twice. minor
1.3 A malformed packet can re-enable disabled legacy names Telemetry values a user switched off can reappear under their old names when a corrupt packet arrives. minor
8.1 The feature lands as one ~377-line commit Reviewers and anyone bisecting later have to take the transport, the boot wiring and the UI as a single unit. minor
Change map — what was established before judging

Claims (from the PR body — recorded, then checked against the code)

  • "Each one's telemetry is mirrored into the data lake under /mavlink/<system id>/..."verified. onSecondaryData (src/libs/vehicle/mavlink/secondary-connections.ts:254) calls injectMavlinkPackageIntoDataLake with no legacy id, and that builds prefix = /mavlink/${messageSystemId}/${messageComponentId} from the package's own header (src/libs/vehicle/mavlink/data-lake-injection.ts:144-145).
  • "a point of interest can follow each vehicle on the map"verified. ResolvedPointOfInterest resolves its coordinates from data-lake expressions (src/types/mission.ts:403-424), so the documented {{ /mavlink/3/1/GLOBAL_POSITION_INT/lat }} / 1e7 form works.
  • "These links are read-only and bypass the ConnectionManager… So none of the extra vehicles can be commanded, or become the vehicle being piloted"verified structurally. The vehicle factory and the main vehicle attach to ConnectionManager.onRead / onMainConnection (src/libs/vehicle/vehicle-factory.ts:53,174, src/stores/mainVehicle.ts:608,616), and the new module never calls ConnectionManager.addConnection nor connection.write(). ConnectionManager is imported into src/composables/secondaryVehicles.ts only to read mainConnection()?.uri().
  • "The first commit is a behavior-preserving refactor"contradicted in one narrow case. Folding the shouldCreateLegacyDataLakeVariables boolean into the legacyVariablesSystemId?: number parameter makes undefined === undefined true where the old && chain short-circuited to false. See 1.3.
  • "Two vehicles announcing the same system ID would overwrite each other's variables, so the panel warns about the duplicate"verified that it warns, contradicted as a mitigation. The warning fires only for systems that send a HEARTBEAT with a valid autopilot (isVehicleHeartbeat, secondary-connections.ts:236-238), while injection covers every system on the link. See 1.1.

Failure site — n/a, this PR fixes no bug.

Entry points

Function Reached from Frequency
onSecondaryData (secondary-connections.ts:240) WebSocketConnection.onRead.emit_value (websocket-connection.ts:177), registered at secondary-connections.ts:323 per incoming message
injectMavlinkPackageIntoDataLake (data-lake-injection.ts:142) onSecondaryData above, and MAVLinkVehicle.addPackageVariablesToDataLakeonIncomingMessage (vehicle.ts:304,323,330) per incoming message
setVariable (data-lake-injection.ts:127) injectMavlinkPackageIntoDataLake, once per flattened field per incoming message (× fields per message)
MAVLinkVehicle.addPackageVariablesToDataLake (changed, vehicle.ts:1537) onIncomingMessage (vehicle.ts:323,330) per incoming message
syncSecondaryVehicleConnections (secondary-connections.ts:302) watch(secondaryVehicleUris, …, { immediate: true }) in initSecondaryVehicleConnectionssrc/main.ts:105 one-shot at boot, then per user action
initSecondaryVehicleConnections (secondaryVehicles.ts:106) src/main.ts:105, alongside initGnss() one-shot
addSecondaryVehicle / removeSecondaryVehicle (secondaryVehicles.ts:57,95) @keyup.enter / @click in the added ExpansiblePanel per user action
refreshSecondaryVehicleStates (secondaryVehicles.ts:41) useIntervalFn(…, 1000, { immediateCallback: true }) in ConfigurationGeneralView.vue, plus the boot watch per user action (1 Hz timer, only while the General settings view is mounted)
getSecondaryConnectionState / secondaryConnectionStatus / secondaryConnectionStatusLabel refreshSecondaryVehicleStates and the secondaryVehicleRows computed per user action (same 1 Hz window)
duplicatedSecondaryVehicleSystemIds (computed, secondaryVehicles.ts:30) the added panel's template only per user action (recomputes when the 1 Hz refresh replaces secondaryVehicleStates)

No changed or added function traced to never; every export in the three new/changed modules has a call site inside this PR.

Invariants

  1. "A data-lake id /mavlink/<sys>/<comp>/<msg>/<field> names exactly one physical system." Sites that can write such an id: MAVLinkVehicle.addPackageVariablesToDataLake (vehicle.ts:1537) and the new onSecondaryData (secondary-connections.ts:254). The PR covers neither — it only reports a subset of violations after the fact, through duplicatedSecondaryVehicleSystemIds. The single chokepoint where it could be closed is onSecondaryData, which is the only writer the PR controls. → 1.1
  2. "Data-lake variables for systems other than the main vehicle exist only when the user asked for them." Enforced on the main link at vehicle.ts:320-327 against shouldCreateDatalakeVariablesFromOtherSystems, backed by cockpit-enable-datalake-variables-from-other-systems (src/stores/mainVehicle.ts:179, default false, toggled at src/views/ConfigurationMAVLinkView.vue:26-32). The new onSecondaryData does not consult it. → 1.2
  3. "Secondary links are read-only." Holds, and holds structurally rather than by convention: there is no write() call, no ConnectionManager.addConnection, and the factory only listens to ConnectionManager. Nothing further needed.
1. Correctness & Implementation Bugs — 3 findings (2 major, 1 minor)

1.1 — Secondary telemetry is filed under the same ids as the piloted vehicle, and the mitigation is a line of text in a settings panel major

Consequence: if a second vehicle uses the same system ID as the one being piloted — ArduPilot's factory default — the pilot's own altitude, attitude and position readouts silently start showing the other vehicle's numbers.

onSecondaryData (src/libs/vehicle/mavlink/secondary-connections.ts:254) hands every package straight to injectMavlinkPackageIntoDataLake, which derives the id from the package's own header (src/libs/vehicle/mavlink/data-lake-injection.ts:144-145). A secondary vehicle left on ArduPilot's default SYSID_THISMAV = 1 therefore writes /mavlink/1/1/ATTITUDE/roll, /mavlink/1/1/GLOBAL_POSITION_INT/lat and the rest — the exact ids the main vehicle writes from vehicle.ts:1537, and the exact ids consumed by:

  • the altitude data sources, all four of which are /mavlink/{{autopilotSystemId}}/1/… (src/libs/data-sources/altitude.ts:15-30),
  • useResolvedDataLakeTemplate (src/composables/useResolvedDataLakeTemplate.ts:8),
  • every widget or POI a user configured against a literal /mavlink/1/1/… path.

setDataLakeVariableData is last-writer-wins (src/libs/actions/data-lake.ts:120-138), so the two vehicles interleave at whatever rate each streams. Nothing in the flight view indicates it.

The PR's mitigation is duplicatedSecondaryVehicleSystemIds (src/composables/secondaryVehicles.ts:30-35) rendered as yellow text inside the added panel. Two gaps:

  • It only counts systems that pass isVehicleHeartbeat (secondary-connections.ts:236-238), i.e. a HEARTBEAT whose autopilot is not MAV_AUTOPILOT_INVALID. Every other colliding system on the link — companion computers, gimbals, cameras, other ground stations — writes into /mavlink/<sys>/<comp>/… with no warning at all.
  • It is only visible inside a collapsible panel on one settings page. Once the user navigates away, the corruption continues with no indication anywhere.

Per the invariant rule, close this at the single chokepoint the PR owns rather than reporting it after the fact. In onSecondaryData, either drop packages whose system_id is already claimed by the main vehicle (getDataLakeVariableData('autopilotSystemId')) or by another link, or namespace secondary variables per connection so two links structurally cannot produce the same id. A warning is a reasonable addition; it is not the fix.

1.2 — Secondary links ignore enableDatalakeVariablesFromOtherSystems, which is off by default and exists for exactly this major

Consequence: adding one other vehicle also pulls in telemetry from every other device on that vehicle's network, even for users who explicitly turned that off.

On the main link, Cockpit already refuses to create data-lake variables for non-main systems unless the user opts in:

// src/libs/vehicle/mavlink/vehicle.ts:320-327
if (system_id !== this.currentSystemId || component_id !== 1) {
  // For non-main systems, only inject variables from the MAVLink messages into the DataLake if the user wants to
  if (this.shouldCreateDatalakeVariablesFromOtherSystems) {
    this.addPackageVariablesToDataLake(mavlink_message)
  }
  return
}

That flag is driven by cockpit-enable-datalake-variables-from-other-systems (src/stores/mainVehicle.ts:179-182), which defaults to false and is surfaced as "Enable DataLake variables from other systems" (src/views/ConfigurationMAVLinkView.vue:26-32). The added onSecondaryData has no equivalent gate, so a single secondary address admits everything on that network — BlueOS services, other GCS instances, peripherals — as data-lake variables.

Two costs follow. Each first sighting calls createDataLakeVariable, which fires notifyDataLakeVariableInfoListeners() (src/libs/actions/data-lake.ts:95); the variable pickers in Plotter.vue:268, ToolsDataLakeView.vue:287, DataLakeExpressionInput.vue:98 and monaco-manager.ts:158 all rebuild off that list. And it directly feeds 1.1, since the un-warned collisions are precisely the non-autopilot components.

Either consult the same setting from onSecondaryData, or restrict secondary injection to systems that passed isVehicleHeartbeat — the module already computes that.

1.3 — undefined doubles as "legacy names off" and "system id unknown" in the extracted helper minor

Consequence: telemetry values a user switched off can reappear under their old names when a corrupt packet arrives.

vehicle.ts:1538 collapses a boolean and a number into one optional number:

const legacySystemId = this.shouldCreateLegacyDataLakeVariables ? this.currentSystemId : undefined

and data-lake-injection.ts:147 tests it by equality:

const shouldCreateLegacyVariables = legacyVariablesSystemId === messageSystemId && messageComponentId === 1

When the user has legacy names disabled (cockpit-enable-legacy-datalake-variable-names, src/stores/mainVehicle.ts:186) and a package arrives whose header.system_id is missing, undefined === undefined is true; with component_id === 1 the helper creates the legacy unprefixed variables anyway. The pre-refactor form (this.shouldCreateLegacyDataLakeVariables && messageSystemId === this.currentSystemId && …, removed at vehicle.ts:1547-1551 of the old code) short-circuited to false and could not do this. Note the new secondary path guards against exactly this shape of package at secondary-connections.ts:247; the vehicle path does not.

Keep the boolean as its own parameter, or guard the comparison with legacyVariablesSystemId !== undefined.

2. Persistence & User Data — inventory, no findings

Inventory

Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local — useStoragelocalStorage (src/composables/secondaryVehicles.ts:21; key declared at src/libs/vehicle/mavlink/secondary-connections.ts:202) added: string[] of normalized ws:///wss:// addresses

Nothing else is added, reshaped or removed, and the PR contains no migration.

Judgement

  • Backend is the right one, and for the reason AGENTS.md gives rather than by accident. These are addresses on the topside computer's own network, and main.ts:105 auto-connects to every stored entry at boot — an automatic action on a synced value is exactly what the rule forbids, so machine-local is required here, not merely preferred. useStorage for machine-local cockpit-* keys matches existing practice (cockpit-vehicle-address, src/stores/mainVehicle.ts:85; cockpit-ignored-update-versions, src/components/UpdateNotification.vue:53).
  • Key starts with cockpit- as AGENTS.md requires.
  • Shape is sound: a flat array of the normalized URI, no id field duplicating the key, no nested state that could go stale. secondaryVehicleUris re-reads it defensively with Array.isArray (secondaryVehicles.ts:24), so a hand-corrupted value degrades to an empty list rather than throwing at boot.
  • No default or behaviour change strands existing users: the key is new and empty, so nothing runs for anyone who does not opt in.
  • Worth recording, not a finding: the sibling feature in the same panel persists its list through the vehicle-synced settingsManager instead (cockpit-generic-websocket-connections, src/libs/generic-websocket.ts:32,70,93). This PR's choice is the safer of the two, but it is one more axis on which the two near-identical panels diverge — see 7.1.
  • Data-lake variables created by a removed link are deliberately left behind (secondary-connections.ts:296-298); those live in memory only, so nothing is orphaned on disk.
5. Performance — 1 finding (minor)

5.1 — Secondary sockets take the built-in 4 s watchdog, not the one the same settings page exposes, and the status window is 5 s minor

Consequence: a mistyped address reconnects forever in the background, and the status light never settles on anything the user can act on.

secondary-connections.ts:322 constructs the connection with no options:

const connection = new WebSocketConnection(parsedUri, Protocol.Type.MAVLink)

so _getWatchdogTimeoutMs falls back to DEFAULT_WATCHDOG_TIMEOUT_MS = 4_000 (src/libs/connection/websocket-connection.ts:18,67). Every other WebSocketConnection in the tree passes the user's value — ConfigurationGeneralView.vue:679 and src/stores/mainVehicle.ts:617 both pass getWatchdogTimeoutMs: () => vehicleConnectionWatchdogTimeoutMs. That value is edited by the "Watchdog timeout" field on this very page (ConfigurationGeneralView.vue:487-495), so a user who raises it for a slow link will find it silently ignored by the panel two sections above.

The two timeouts also disagree with each other. receivingTimeoutMs = 5000 (secondary-connections.ts:208) defines the 'connecting''connected' boundary in secondaryConnectionStatus, but _runWatchdog tears the socket down and reopens it after 4 s of silence (websocket-connection.ts:324-334). An open-but-silent link is therefore always recycled before the 5 s state can be reached, so an address pointing at a non-MAVLink endpoint — or a right host with the wrong path — produces an endless 4-second reconnect cycle with a console.warn per iteration, while the row flickers between disconnected and connected, waiting for data instead of settling on something the user can act on.

Pass { getWatchdogTimeoutMs } like the main connection does, and derive receivingTimeoutMs from the same source (or set it above the watchdog) so the two cannot contradict each other.

Checked and clean elsewhere in this section: useIntervalFn self-disposes via the component scope, so the 1 Hz poll stops on unmount; syncSecondaryVehicleConnections calls disconnect() and clears all three module maps on removal (secondary-connections.ts:305-312), and WebSocketConnection.disconnect() stops both the watchdog interval and the pending reconnect timer; the per-message injection cost carries a ponytail: comment naming its ceiling and upgrade path (secondary-connections.ts:252-253), which is the marking AGENTS.md asks for — the part of that cost the comment does not cover is filed as 1.2.

6. UI / UX — 1 finding (minor)

6.1 — The "Other vehicles" panel gives the user nothing to type and nothing to read minor

Consequence: the panel's only input is an unlabeled empty box, and the example address the code prepares is never shown anywhere.

Two breaches on one surface, grouped:

  • The example address is bound to a slot that is switched off. The added v-text-field carries :hint="exampleSecondaryVehicleUri" and hide-details, so the hint area never renders and exampleSecondaryVehicleUri = 'ws://192.168.2.4/mavlink2rest/ws/mavlink' is dead. There is no label and no placeholder either, and the panel's #info block only shows exampleSecondaryVehicleCoordinate — so mavlink2rest/ws/mavlink, the one part a user cannot guess, appears nowhere in the UI. The sibling panel survives the same hint + hide-details combination only because it seeds the model with the example (newGenericWebSocketUrl = ref(exampleGenericWebSocketUrl), ConfigurationGeneralView.vue:968), which this one does not (ref('')). Drop hide-details, or use placeholder, or add the address example beside the coordinate example in #info.
  • The remove button has no accessible name. <v-btn icon="mdi-close" size="x-small" variant="text" @click="removeSecondaryVehicle(row.uri)" /> carries neither v-tooltip nor aria-label, so a screen reader announces nothing for the control that deletes a configured entry. The same view labels its other icon controls (v-tooltip.bottom="'Reset to default'", ConfigurationGeneralView.vue:461,498). Graded minor because the glyph and the adjacent address convey the action to a sighted user.

Checked and clean on this surface: logUserAction fires on both add and remove in the owning handlers, in the past tense naming the target (secondaryVehicles.ts:81,97); both paths give visible feedback via openSnackbar, with no paired console.* of the same message; the panel is an ExpansiblePanel and so satisfies the space-economy rule; titles and labels are sentence case; the divider chain (no-top-divider) matches the panels on either side; the duplicate-ID warning avoids MAVLink jargon and names the concrete parameter the user must change.

7. Code Quality & Style — 1 finding (minor)

7.1 — The new panel duplicates the Generic WebSocket panel that sits ~100 lines below it in the same file minor

Consequence: the same list-of-connections screen now exists twice on one settings page, so every future fix has to be made twice.

The added markup is a near-verbatim copy of ConfigurationGeneralView.vue:376-415, class strings included: the same flex items-center justify-between py-2 px-3 mb-2 rounded bg-[#FFFFFF11] row, the same getLoadingStatusColor / getLoadingStatusIcon pair, the same truncated-address span, the same (status) span, the same v-btn icon="mdi-close" size="x-small" variant="text", the same empty-state line, and the same v-text-field + Add … button footer down to :class="interfaceStore.isOnSmallScreen ? 'ml-1' : 'ml-5'". That is how the hide-details defect in 6.1 was inherited.

The plumbing then diverges on all three axes, so the page now carries two of everything:

Generic WebSocket (src/libs/generic-websocket.ts) Secondary vehicles (this PR)
Persistence settingsManager (vehicle-synced), lines 32, 70, 93 useStorage (machine-local), secondaryVehicles.ts:21
Status delivery push listener, listenToGenericWebSocketConnections:292 1 Hz poll, useIntervalFn(refreshSecondaryVehicleStates, 1000)
Reconnect hand-rolled scheduleReconnect, line 176 WebSocketConnection's watchdog + backoff

AGENTS.md, "Reuse before reinventing": "If the same logic would live in two or more places, extract it once and reuse it." The two-copy threshold is now met. The smallest move is to lift the row list + add-row into one small child component under src/components/configuration/ and have both panels render it with their own callbacks; the persistence and status mechanisms can stay different if the divergence is deliberate, but the near-identical 40 lines of template should not.

Nothing else in this section produced a finding: ConfigurationGeneralView.vue grows 1017 → 1127 lines, well under the ~2000 threshold, and the domain logic correctly went to src/libs/ with the reactive orchestration in src/composables/ rather than into the <script setup>; no scoped CSS was added; no comment was deleted or reworded over unchanged code; the extraction into data-lake-injection.ts genuinely deletes 62 lines and collapses four copies of create-then-set into one helper.

8. Commit Hygiene — 1 finding (minor)

8.1 — The feature commit is ~377 added lines across four files minor

Consequence: reviewers and anyone bisecting later have to take the transport, the boot wiring and the UI as a single unit.

2bfaa0d vehicle: receive telemetry from other vehicles on the network bundles three steps that are each independently reviewable and revertable:

  • the transport module, src/libs/vehicle/mavlink/secondary-connections.ts (+151),
  • the composable and the boot wiring, src/composables/secondaryVehicles.ts (+109) and src/main.ts (+4),
  • the settings panel, src/views/ConfigurationGeneralView.vue (+110).

AGENTS.md: "Keep a commit reviewable in one sitting. Several hundred lines in a single commit is hard to follow even when it is nominally one thing, so look for the atomic steps inside it."

The rest of this section is clean: two commits, no wip / fixup! / address review noise, no commit reverting or reimplementing another on the branch, no commits replicated from a sibling PR, and no GitHub issue or PR reference in either message. Both prefixes describe their change and match the git log style on master (refactor: vehicle: and vehicle: alongside map:, widgets:, ci:). 0dc37cc correctly isolates the refactor from the feature ahead of it — the one behavioural slip it carries is filed as 1.3.

Sections with nothing to report (5)

3. AGENTS.md Adherence — ✅ (no dependency added — @vueuse/core 9.8.1 and vue already at package.json:49,92, so ordering is untouched; every exported symbol in the three new/changed modules traced to a call site inside this PR, so no groundwork; JSDoc present and non-empty on all six exported functions plus all three SecondaryConnectionState property signatures, satisfying the jsdoc/require-jsdoc contexts in .eslintrc.cjs:39; the per-message throughput corner-cut carries a ponytail: comment naming both the ceiling and the upgrade path at secondary-connections.ts:252; vehicle.ts shows no rename, import reorder or formatter reflow outside the extraction)

4. Security — ✅ (scanned pr.diff for zero-width, bidi-override and homoglyph codepoints — none; no encoded blob, eval, Function() or v-html; no new dependency, no env var, token or credential; no change under scripts/, .github/ or src/electron/, and no build, postinstall or workflow file touched; the only network activity is a WebSocket to an address the user types into the panel, and the diff contains nothing addressed to an automated reviewer)

9. Tests — ✅ (no file under tests/ or *.spec.* appears in the six changed paths, so nothing was removed, skipped or weakened; the extraction of injectMavlinkPackageIntoDataLake out of a private class method into a free function in a Vue-free module makes that logic testable where it previously was not)

10. Documentation — ✅ (no README.md Lite/Standalone table entry is owed: WebSocket, useStorage and the data lake behave identically in both builds and the diff touches no window.electronAPI, electron-* module or other Electron-only API, so the isElectron() guard rule does not apply; the module header at secondary-connections.ts:180-190 documents the read-only and ConnectionManager-bypass decisions where they are made)

11. Nitpicks / Optional — ✅ (reviewed the added code for naming, ordering and small refactors; simple-import-sort ordering holds in all four import blocks, func-style and explicit-function-return-type are satisfied on every added arrow function, and nothing remained that was worth raising separately from findings 1.3, 5.1, 6.1 and 7.1)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from 2bfaa0d to 060b76f Compare August 14, 2026 13:44
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Review follow-up — round 1

Done

  • src/libs/vehicle/mavlink/data-lake-injection.ts (1.3 — undefined doubling as "legacy names off"): guarded the comparison with legacyVariablesSystemId !== undefined. src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts covers it and fails without the guard.
  • src/libs/vehicle/mavlink/secondary-connections.ts (5.1 — reconnect timing): the connections now get { getWatchdogTimeoutMs } from the same setting the main link uses, and the "waiting for data" window is measured against that timeout instead of the hardcoded 5 s, so the status can no longer contradict the recycle it is about to do.
  • src/components/configuration/ConnectionsList.vue (6.1 — the address box tells the user nothing): the example address is the field's placeholder now (the hint never rendered next to hide-details), the field has a label, and the remove button has a tooltip and an aria-label.
  • src/components/configuration/ConnectionsList.vue (7.1 — duplicated panel): the row list and the add field are one component, rendered by both this panel and the generic websocket one below it. Persistence and status delivery stay as they were on each side; only the markup is shared.
  • history (8.1 — one ~377-line commit): five commits now — the injection extraction, the list-component extraction, the transport module, the boot wiring, and the settings panel.

Done differently

  • src/libs/vehicle/mavlink/secondary-connections.ts (1.1 — colliding data-lake ids): closed at onSecondaryData rather than by namespacing per connection, since the ids are the feature's interface — the panel documents /mavlink/<system id>/... and users point POIs and widgets at it. A systemIdOwners map hands each system ID to the first link that announces a vehicle with it, and a package is dropped when it arrives on any other link, or when its ID is the piloted vehicle's. The latter is checked per message, not only when the ID is claimed, because the main vehicle can connect after a link already claimed it. The warning stays, as the addition you described.
  • src/libs/vehicle/mavlink/secondary-connections.ts (1.2 — enableDatalakeVariablesFromOtherSystems): took the second option and restricted injection to system IDs that announced a vehicle on that link, rather than consulting the setting. That setting governs the main link, where "other systems" means devices sharing the piloted vehicle's network; on these links every system is by definition another vehicle's, so making the feature depend on a checkbox in the MAVLink page would only be confusing. Other ground stations, and any system on the link that never announces an autopilot, no longer create variables. Components of an announced vehicle still do, which is what the feature is for.

Panel copy and the PR body were updated to describe the new behaviour, since "they overwrite each other" is no longer what happens.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 2

Warning

⚠️ IMPORTANT FIXES REQUIRED — 3 open: 1 major (3.1) and 2 minor; 7 closed since round 1.

Adds a settings panel where the user lists extra vehicles by websocket address. Cockpit opens a read-only link to each at startup and copies their telemetry into the shared pool of values that widgets and map markers read from. This round closes the naming problem: a link now only mirrors a vehicle that announced itself on that link, only the first link to claim a given vehicle number gets to write it, and anything using the piloted vehicle's number is thrown away — so another vehicle can no longer overwrite the pilot's own readouts once the piloted vehicle is known. The panel's screen was also factored out and is now shared with the near-identical panel below it. What is left is a block of placeholder characters committed inside a documentation comment in the new test file, which the project's own rules forbid and the lint step is set up to reject.

What still needs attention

# Problem What it means Severity Status
3.1 Placeholder text left inside two documentation comments A new test file carries a comment filled with repeated letters and another that is empty, which the project forbids and the automated style check is configured to reject. major
1.4 The overwrite guard only works once the piloted vehicle has been identified For the first seconds after Cockpit starts, another vehicle sharing the pilot's vehicle number can still write into the pilot's telemetry slots, and the panel cannot warn about it yet. minor
6.2 The duplicate warning describes the wrong outcome When another vehicle uses the piloted vehicle's number the panel tells the user the first one to speak up wins, when in fact that vehicle is never received at all. minor
Since round 1 — 7 closed, 3 new, comparing 2bfaa0d060b76f

Range2bfaa0d09ff29fad77618fab09a4e8a08074e6fe060b76ffda67ed96a80f4468ff32527117c14948.

incremental.diff was not usable this round and was ignored. The branch was rebased: none of the five commits in pr.json is 2bfaa0d, and the file presents secondary-connections.ts, data-lake-injection.ts and secondaryVehicles.ts as added files although all three existed at 2bfaa0d. It is therefore a base→head diff, not a previous-head→head one. Every status below was judged against pr.diff and the checked-out base tree instead.

resolutions.json is empty — no maintainer /resolve has been issued on this PR, so nothing was closed by decision; all seven closures below are code changes.

Findings that changed status

  • 1.1 — Secondary telemetry filed under the piloted vehicle's ids — ✅ Addressed. The finding asked for the fix at the onSecondaryData chokepoint, by dropping packages whose system_id is claimed by the main vehicle or by another link. Both landed: systemIdOwners (src/libs/vehicle/mavlink/secondary-connections.ts:52) hands each system ID to the first link that announces a vehicle with it, secondary-connections.ts:81 drops a package arriving on any other link, and :82 drops one whose ID equals getDataLakeVariableData('autopilotSystemId'), re-checked per message so a late-connecting main vehicle also wins. The warning survives as an addition, which is what the finding asked for. The residual boot-order window in the mechanism is filed separately as 1.4.
  • 1.2 — Secondary links ignore enableDatalakeVariablesFromOtherSystems — ✅ Addressed. The finding offered two exits; the author took the second. Injection now requires systemIdOwners.get(systemId) === uri (secondary-connections.ts:81), and an entry is only ever created from isVehicleHeartbeat (:58, :71-72), so companion computers, gimbals and other ground stations on that network no longer create data-lake variables. The author's reasoning for not consulting the MAVLink-page checkbox — that it governs "other systems on the piloted vehicle's network", where every system on a secondary link is by definition another vehicle's — is consistent with vehicle.ts:320-327.
  • 1.3 — undefined doubling as "legacy names off" — ✅ Addressed. src/libs/vehicle/mavlink/data-lake-injection.ts:26-27 now reads legacyVariablesSystemId !== undefined && legacyVariablesSystemId === messageSystemId && messageComponentId === 1, and src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts:56-60 pins it with a package carrying no system id.
  • 5.1 — Default 4 s watchdog and the contradicting 5 s status window — ✅ Addressed. secondary-connections.ts:164 constructs the socket with { getWatchdogTimeoutMs }, fed from mainVehicleStore.vehicleConnectionWatchdogTimeoutMs (src/composables/secondaryVehicles.ts:107) — the same source as src/stores/mainVehicle.ts:617. The hardcoded receivingTimeoutMs = 5000 is gone; secondaryConnectionStatus now takes the timeout as a parameter (:109-116), so the reported status and the recycle it precedes can no longer disagree.
  • 6.1 — Unlabeled address field and unnamed remove button — ✅ Addressed. The field gained :label and :placeholder (src/components/configuration/ConnectionsList.vue:35-36), the example address is passed in as the placeholder (ConfigurationGeneralView.vue:271), and the remove button carries both v-tooltip.bottom and :aria-label (ConnectionsList.vue:18-19). The generic websocket panel inherits all three.
  • 7.1 — The panel duplicates the Generic WebSocket panel — ✅ Addressed. src/components/configuration/ConnectionsList.vue holds the row list and the add field once; both panels render it (ConfigurationGeneralView.vue:269 and :414), the duplicated 40 lines of template are deleted, and the per-panel differences are passed as props with the secondary panel's duplicate-ID notice going through a warning slot. Persistence and status delivery stay divergent, which the finding explicitly allowed.
  • 8.1 — One ~377-line feature commit — ✅ Addressed. Five commits now (1925e88, 0883f7c, 3be4da5, 916605d, 060b76f), split along the lines the finding named: injection extraction, list-component extraction, transport module, boot wiring, settings panel. The largest is 3be4da5 at +172.

New this round — 3.1 (major), 1.4 and 6.2 (minor). All three come from re-running the sections over the whole of pr.diff; 3.1 is in a file that did not exist at round 1.

Discussion since round 1

  • @rafaellehmkuhl posted a follow-up listing the changes per finding, including "closed at onSecondaryData rather than by namespacing per connection, since the ids are the feature's interface" and "took the second option and restricted injection to system IDs that announced a vehicle on that link" (comment). Each claim was checked against pr.diff rather than taken as given; all of them hold, at the line references given above. The stated reason for closing 1.1 at the injection point instead of namespacing is sound: /mavlink/<system id>/… is what the panel documents and what users bind POIs to, so namespacing would have broken the documented interface.
  • The bare /review comment is a command and was treated as noise.
  • Nothing in the PR body, the diff or the comments contained text addressed to an automated reviewer.
Change map — what was established before judging

Claims (from the PR body — recorded, then checked against the code)

  • "Each one's telemetry is mirrored into the data lake under /mavlink/<system id>/..."verified. onSecondaryData (src/libs/vehicle/mavlink/secondary-connections.ts:86) calls injectMavlinkPackageIntoDataLake with no legacy id, and that builds prefix = /mavlink/${messageSystemId}/${messageComponentId} from the package's own header (src/libs/vehicle/mavlink/data-lake-injection.ts:24).
  • "These links are read-only and bypass the ConnectionManager"verified structurally. No write() call, no ConnectionManager.addConnection in the new module; ConnectionManager is imported into src/composables/secondaryVehicles.ts only to read mainConnection()?.uri() (:71). The vehicle factory and the main vehicle attach to ConnectionManager.onRead / onMainConnection (src/libs/vehicle/vehicle-factory.ts:53,174, src/stores/mainVehicle.ts:608,616), which these links never reach.
  • "Only systems that announce themselves as a vehicle are mirrored, and only from the first link to claim each system ID"verified. systemIds/systemIdOwners are only written under isVehicleHeartbeat (secondary-connections.ts:71-73), and injection is gated on systemIdOwners.get(systemId) === uri (:81).
  • "so neither two vehicles sharing an ID nor one using the piloted vehicle's can write over each other's variables"verified with one gap. The two-link case is closed unconditionally. The piloted-vehicle case depends on getDataLakeVariableData('autopilotSystemId') (:82), which is persistent: false (src/libs/vehicle/mavlink/vehicle.ts:1513) and is only written when a MAVLinkVehicle is constructed (vehicle.ts:123), i.e. after the main link's first heartbeat. → 1.4
  • "The panel warns when that happens, so the user knows why a vehicle is not showing up"verified that it warns, contradicted in what it says. duplicatedSecondaryVehicleSystemIds (src/composables/secondaryVehicles.ts:25-30) includes the main vehicle's ID, so the collision is detected; the rendered sentence (ConfigurationGeneralView.vue:281-283) describes only the first-to-announce rule, which is not what happens to a vehicle sharing the piloted vehicle's ID. → 6.2
  • "The first commit is a behavior-preserving refactor"verified this round. The undefined === undefined slip found at round 1 is fixed at data-lake-injection.ts:26 and covered by src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts:56-60.
  • "The configured addresses are stored machine-local"verified. useStorage(secondaryVehicleUrisKey, []) (secondaryVehicles.ts:16) → localStorage.
  • The body's Fixes #1098 sits in the PR body only; no commit message in pr.json carries a GitHub reference, which is what AGENTS.md requires.

Failure site — n/a. The PR fixes no reported bug; the only defect it repairs is one this review raised at round 1, and it is repaired at the site the round-1 finding named (data-lake-injection.ts:26).

Entry points

Function Reached from Frequency
onSecondaryData (secondary-connections.ts:62) WebSocketConnection.onRead.emit_value (websocket-connection.ts:177), registered at secondary-connections.ts:165 per incoming message
isVehicleHeartbeat (secondary-connections.ts:58) onSecondaryData:71 per incoming message
injectMavlinkPackageIntoDataLake (data-lake-injection.ts:21) onSecondaryData:86, and MAVLinkVehicle.addPackageVariablesToDataLakeonIncomingMessage (vehicle.ts:323,330) per incoming message
setVariable (data-lake-injection.ts:6) injectMavlinkPackageIntoDataLake, once per flattened field per incoming message (× fields per message)
MAVLinkVehicle.addPackageVariablesToDataLake (changed, vehicle.ts:1538) onIncomingMessage (vehicle.ts:323,330) per incoming message
syncSecondaryVehicleConnections (secondary-connections.ts:141) watch(secondaryVehicleUris, …, { immediate: true }) (secondaryVehicles.ts:104) one-shot at boot, then per user action
initSecondaryVehicleConnections (secondaryVehicles.ts:101) src/main.ts:105, after app.use(createPinia()) at main.ts:86, so the useMainVehicleStore() call at secondaryVehicles.ts:102 is safe one-shot
addSecondaryVehicle / removeSecondaryVehicle (secondaryVehicles.ts:52,90) ConnectionsList's add/remove emits (ConnectionsList.vue:23,45,53) via ConfigurationGeneralView.vue:277-278 per user action
refreshSecondaryVehicleStates (secondaryVehicles.ts:36) useIntervalFn(…, 1000, { immediateCallback: true }) (ConfigurationGeneralView.vue:1010) plus the boot watch per user action (1 Hz, only while the General settings view is mounted)
getSecondaryConnectionState / secondaryConnectionStatus / secondaryConnectionStatusLabel (secondary-connections.ts:95,109,131) refreshSecondaryVehicleStates and the secondaryVehicleRows computed (ConfigurationGeneralView.vue:993) per user action (same 1 Hz window)
duplicatedSecondaryVehicleSystemIds (secondaryVehicles.ts:25) the added panel's template (ConfigurationGeneralView.vue:281) per user action
ConnectionsList (new component) both panels, ConfigurationGeneralView.vue:269 and :414 per user action
genericWebSocketRows (ConfigurationGeneralView.vue:1024) the generic panel's template per user action

No changed or added function traced to never; every export in the four new/changed modules has a call site inside this PR, and the two getLoadingStatus* imports dropped from ConfigurationGeneralView.vue had no remaining use there (their only two call sites, old lines 383-384, are deleted).

Invariants

  1. "A data-lake id /mavlink/<sys>/<comp>/<msg>/<field> names exactly one physical system." Writers: MAVLinkVehicle.addPackageVariablesToDataLake (vehicle.ts:1538) and onSecondaryData (secondary-connections.ts:86). The PR now closes it at the second, which is the only one it owns: one owner per system ID across all links (:52,72,81) plus a per-message drop against the piloted vehicle's ID (:82). Remaining hole: the piloted vehicle's ID is unknown until it connects. → 1.4
  2. "Data-lake variables for systems other than the main vehicle exist only when the user asked for them." Main link: gated on shouldCreateDatalakeVariablesFromOtherSystems (vehicle.ts:320-327). Secondary links: now gated on having announced a vehicle heartbeat on that link. Both writers covered.
  3. "Secondary links are read-only." Holds structurally — no write(), no ConnectionManager.addConnection.
  4. "A system ID's owner is released when its link goes away." Enforced at secondary-connections.ts:150-152, on configuration removal only — a link whose socket merely drops keeps its claim. That is the right choice (the same address reconnects and re-claims), and no other site mutates systemIdOwners.
1. Correctness & Implementation Bugs — 1 finding (minor)

1.4 — The piloted-vehicle guard reads a value that does not exist until the piloted vehicle connects minor

Consequence: for the first seconds after Cockpit starts, another vehicle sharing the pilot's system ID can still write into the pilot's telemetry slots, and the panel cannot warn about it because it does not know the pilot's ID yet.

onSecondaryData drops a package whose system ID belongs to the piloted vehicle:

// src/libs/vehicle/mavlink/secondary-connections.ts:82
if (systemId === getDataLakeVariableData('autopilotSystemId')) return

autopilotSystemId is created with persistent: false, persistValue: false (src/libs/vehicle/mavlink/vehicle.ts:1513) and written only from the MAVLinkVehicle constructor (vehicle.ts:123), which the vehicle factory runs on the main link's first heartbeat. So on every launch the value is undefined until the piloted vehicle is discovered. In that window a secondary link streaming ArduPilot's factory-default SYSID_THISMAV = 1:

  • passes isVehicleHeartbeat, claims 1 in systemIdOwners (secondary-connections.ts:72), and
  • writes /mavlink/1/1/GLOBAL_POSITION_INT/lat, /mavlink/1/1/ATTITUDE/roll and the rest — the ids src/libs/data-sources/altitude.ts:15-30 resolve {{autopilotSystemId}} into, and the ids any widget or POI configured against a literal /mavlink/1/1/… reads.

duplicatedSecondaryVehicleSystemIds cannot warn either, because it too depends on getDataLakeVariableData('autopilotSystemId') (src/composables/secondaryVehicles.ts:26) and finds no duplicate while it is undefined.

It self-heals: once the main vehicle connects, :82 starts dropping and the main vehicle overwrites the ids at its own stream rate. Graded minor for that reason — but "the pilot's altitude and position briefly show another vehicle's numbers right after startup" is still a user-visible wrong value on the flight view, at the moment an operator is most likely reading it.

The cheapest closure is to release the claim rather than only to stop writing: when autopilotSystemId becomes known, drop it from systemIdOwners and stop the owning link from having ever been the writer. If you would rather accept the window, mark it — a ponytail: comment at :82 naming "the guard depends on the piloted vehicle already being identified" is what AGENTS.md asks for on a deliberate corner-cut with a known ceiling, and the module already uses that marker at :84.

2. Persistence & User Data — inventory, no findings

Inventory

Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local — useStoragelocalStorage (src/composables/secondaryVehicles.ts:16; key declared at src/libs/vehicle/mavlink/secondary-connections.ts:24) added: string[] of normalized ws:///wss:// addresses

Nothing else is added, reshaped or removed, and the PR contains no migration. Unchanged since round 1 — the round-2 push touched no persistence.

Judgement

  • Backend is the right one, and for the reason AGENTS.md gives rather than by accident. These are addresses on the topside computer's own network, and main.ts:105 auto-connects to every stored entry at boot — an automatic action on a synced value is exactly what the rule forbids, so machine-local is required here, not merely preferred. useStorage for machine-local cockpit-* keys matches existing practice (cockpit-vehicle-address, src/stores/mainVehicle.ts:85; cockpit-ignored-update-versions, src/components/UpdateNotification.vue:53).
  • Key starts with cockpit- as AGENTS.md requires.
  • Shape is sound: a flat array of the normalized URI, no id field duplicating the key, no nested state that could go stale. secondaryVehicleUris re-reads it defensively with Array.isArray (secondaryVehicles.ts:19), so a hand-corrupted value degrades to an empty list rather than throwing at boot. No undefined is ever written to it — removal writes a filtered array (:91).
  • No default or behaviour change strands existing users: the key is new and empty, so nothing runs for anyone who does not opt in.
  • Worth recording, not a finding: the sibling feature in the same panel persists its list through the vehicle-synced settingsManager (cockpit-generic-websocket-connections, src/libs/generic-websocket.ts:32,70,93). The two backends still differ, which is deliberate and is now the only axis on which the two panels diverge in the UI layer, since they share ConnectionsList.
  • Data-lake variables created by a removed link are deliberately left behind (secondary-connections.ts:136-138); those live in memory only, so nothing is orphaned on disk.
3. AGENTS.md Adherence — 1 finding (major)

3.1 — Two placeholder JSDoc blocks committed in the new test file major

Consequence: a new test file carries a comment filled with repeated letters and another that is empty, which the project forbids outright and which the repo's own lint gate is configured to reject.

src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts contains, at lines 30-33, a documentation block whose two content lines are a run of 44 c characters each (the second of them ending in a stray *), wrapped around the variables property of an inline cast:

const variables = (
  dataLake as unknown as {
    /**
cccc… *
cccc…
     */
    variables: Map<string, string | number>
  }
).variables

and at lines 18-20 a second block with no summary at all:

getDataLakeVariableInfo: (id: string): { /** * */ id: string } | undefined => 

AGENTS.md, "Keep JSDocs updated": "Never write a JSDoc whose summary line is empty, whitespace-only, or filler (placeholder characters, repeated letters, lorem-ipsum). If you have nothing useful to say, omit the block entirely instead of leaving it blank." Repeated letters and an empty summary are the two named cases.

Both blocks exist only to satisfy jsdoc/require-jsdoc, which .eslintrc.cjs:39 extends to TSPropertySignature — so the inline anonymous object types are what force them. Two consequences beyond the rule itself:

  • plugin:jsdoc/recommended (.eslintrc.cjs:8) enables jsdoc/check-alignment and jsdoc/no-multi-asterisks. Lines beginning with cccc… rather than an aligned *, and one ending in *, are what those two rules exist to catch, and yarn lint runs with --max-warnings=0 (package.json), so this very likely fails the lint gate outright rather than merely breaching the convention.
  • AGENTS.md requires yarn lint:fix before finishing, and neither of these is auto-fixable, so both would have surfaced.

The fix removes the need for the blocks rather than filling them in: hoist the map with vi.hoisted and reference it directly instead of casting the mocked module to an inline object type, and drop the explicit return type on the mocked getDataLakeVariableInfo@typescript-eslint/explicit-function-return-type is configured with allowExpressions: true (.eslintrc.cjs:84), so a function expression does not need one. With no inline TSPropertySignature left, jsdoc/require-jsdoc has nothing to demand.

Nothing else in this section produced a finding — see the clean-sections note for what was checked.

6. UI / UX — 1 finding (minor)

6.2 — The duplicate-ID warning states a rule that does not apply to the collision it most often reports minor

Consequence: when another vehicle uses the piloted vehicle's number the panel tells the user the first one to speak up wins, when in fact that vehicle is never received at all.

ConfigurationGeneralView.vue:281-283 renders one sentence for every duplicate:

More than one vehicle is using system ID {{ … }}, so only the first one to announce it is being received. Give each vehicle a different system ID.

But duplicatedSecondaryVehicleSystemIds (src/composables/secondaryVehicles.ts:25-30) deliberately folds two different collisions into one list — it pushes the main vehicle's ID into the set before looking for repeats (:28). For the main-vehicle collision the code does not apply "first to announce": secondary-connections.ts:82 drops the package unconditionally, on every message, whoever claimed the ID first. A user whose secondary vehicle announced before the piloted one connected reads that they are the first, and therefore that their vehicle is the one being received, while the opposite is true.

The panel's #info paragraph does state both rules correctly (:263-264, "otherwise only the first one to announce each ID is received, and one using the piloted vehicle's ID is ignored entirely"), which makes the warning the odd one out — and the warning is the line the user actually sees when something is wrong, since #info is behind the panel's info toggle.

Split the sentence on the case, e.g. keep the current wording for links colliding with each other and emit "A vehicle is using the same system ID as the vehicle you are piloting, so it is not being received. Change its system ID." when the duplicate is the main vehicle's ID — the computed already knows which one that is at :26.

Checked and clean on this surface: the extracted ConnectionsList gives both panels a labelled field, a placeholder carrying the example address, and a remove button with a tooltip and an aria-label (ConnectionsList.vue:18-19,35-36); logUserAction fires on add and remove in the owning handlers, in the past tense naming the target (secondaryVehicles.ts:69,91); both paths give visible feedback via openSnackbar with no paired console.* of the same message; the panel is an ExpansiblePanel with no-top-divider, matching the divider chain of the panels on either side; labels and titles are sentence case ("Other vehicles (telemetry only)", "Add vehicle", "Vehicle telemetry address"); the add button is a text button sitting beside the field it acts on rather than in a row of its own; no overlay-teleporting Vuetify control was added, so no theme="dark" is owed; no z-index, no hand-written backdropFilter, no nested glass layer; and the user-facing copy names SYSID_THISMAV — an autopilot parameter the user must change — rather than protocol jargon for its own sake.

Sections with nothing to report (7)

4. Security — ✅ (pr.diff contains no non-ASCII byte at all, so no zero-width, bidi-override or homoglyph codepoint; no encoded blob, eval, Function() or v-html; no new dependency, env var, token or credential; nothing under scripts/, .github/, src/electron/, no build, postinstall or workflow file touched; the only network activity is a WebSocket to an address the user types into the panel)

5. Performance — ✅ (the per-message path gained two Map lookups and one getDataLakeVariableData, which is a plain object index at data-lake.ts:116-118, and the two gates now reject most traffic before injectMavlinkPackageIntoDataLake runs, so the hot path is cheaper than at round 1; useIntervalFn self-disposes with the component scope; syncSecondaryVehicleConnections:145-153 disconnects and clears all four module maps on removal, and WebSocketConnection.disconnect() stops both the watchdog interval and the pending reconnect timer; the residual throughput ceiling carries its ponytail: comment at secondary-connections.ts:84)

7. Code Quality & Style — ✅ (ConfigurationGeneralView.vue goes 1017 → ~1079 lines, far short of the ~2000 threshold, and the round-2 push is net-negative on it — 44 lines of duplicated template out, the shared component in; domain logic sits in src/libs/, reactive orchestration in src/composables/, presentation in src/components/configuration/; no scoped CSS added; no comment deleted or reworded over unchanged code; simple-import-sort ordering holds in all five import blocks and the two dropped getLoadingStatus* imports have no remaining call site in that file; every added arrow function satisfies func-style and explicit-function-return-type; no stray any outside the two deliberate as unknown as casts in the test file)

8. Commit Hygiene — ✅ (five commits, largest +172; each prefix — refactor: vehicle:, refactor: configuration:, vehicle:, configuration: — describes its own change and matches the scope-prefixed style on master per git log; no wip/fixup!/address review noise, no commit reverting or reimplementing another on the branch, no commits replicated from a sibling PR, and no #N or closing keyword in any message, the issue reference living in the PR body where AGENTS.md wants it; the two behaviour changes folded into 0883f7c — the placeholder and the accessible name — are inherent to the extraction that commit performs and fall under the refactor exception)

9. Tests — ✅ (no existing test was removed, skipped or weakened; the one added file is new, and its three cases pin the extraction's contract including the exact undefined system-id regression this review raised at round 1 — the mock's shared module-level map is not reset between cases but no case depends on another's writes, so ordering does not make them brittle; the placeholder comments in that file are filed as 3.1, not as a test defect)

10. Documentation — ✅ (no README.md Lite/Standalone entry is owed: WebSocket, useStorage and the data lake behave identically in both builds and the diff touches no window.electronAPI, electron-* module or other Electron-only API, so the isElectron() guard rule does not apply; the module header at secondary-connections.ts:1-11 documents the read-only and ConnectionManager-bypass decisions where they are made, and the ownership rule is explained at :51-52 and :77-79)

11. Nitpicks / Optional — ✅ (reviewed the added code for naming, ordering and small refactors; the only candidates were the ConnectionRow interface being unexported so neither parent can type its computed against it, and the generic panel still seeding its field with the example address so the new placeholder never shows there — neither is worth a finding of its own)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from 060b76f to 42578d2 Compare August 14, 2026 14:10
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Review follow-up — round 2

Done

  • src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts (3.1 — placeholder JSDoc blocks): both blocks are gone, along with the inline object types that forced them. vi.hoisted is not available on vitest 0.20.3, so instead of hoisting the map the mock is now three plain vi.fn()s and the assertions read setDataLakeVariableData's calls back through vi.mocked(...).mock.calls. No cast to an anonymous object type is left in the file, so jsdoc/require-jsdoc has nothing to ask for. The three cases still assert the same things, and now assert that nothing was even attempted for the legacy names rather than that no value landed.
  • src/views/ConfigurationGeneralView.vue, src/composables/secondaryVehicles.ts (6.2 — the duplicate warning describes the wrong outcome): split on the case. duplicatedSecondaryVehicleSystemIds no longer folds the piloted vehicle's ID in, so it covers only links colliding with each other and its first-to-announce sentence is now true of every ID it lists. The main-vehicle collision goes through the new secondaryVehicleUsesMainVehicleSystemId and renders "A vehicle is using the same system ID as the vehicle you are piloting, so it is not being received. Change its system ID." Both can show at once, which is what happens when a third vehicle also duplicates.

Done differently

  • src/libs/vehicle/mavlink/secondary-connections.ts:82 (1.4 — the guard depends on the piloted vehicle being identified): took the second exit and marked the window with a ponytail: comment naming the ceiling and the upgrade path. Releasing the claim from systemIdOwners when autopilotSystemId arrives would not have closed it: the wrong values are written during the window, before the ID is known, and :82 already stops the writing the moment it becomes known — so dropping the claim buys nothing the guard does not already do, at the cost of a data-lake listener. The upgrade path in the comment is the one that would actually help, which is to clear what the link wrote once the piloted vehicle appears.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from 42578d2 to ae7ca9f Compare August 14, 2026 14:14
@github-actions

Copy link
Copy Markdown

Automated PR Review — round 3

Tip

✅ READY TO MERGE — nothing open; 10 closed, 3 of them this round.

Adds a settings panel where the user lists extra vehicles by websocket address. Cockpit opens a read-only link to each at startup and copies their telemetry into the shared pool of values that widgets and map markers read from. This round removes the placeholder text from the new test file by deleting the inline types that forced it, splits the panel's duplicate-ID warning so the message matches what actually happens to each kind of collision, and marks the one remaining startup window with the project's deliberate-corner-cut marker naming both the ceiling and the way out.

What still needs attention

Nothing is open. Every finding raised across the three rounds is closed by a code change; none was closed by argument or by maintainer decision.

Since round 2 — 3 closed, 0 new, comparing 060b76f42578d2

Range060b76ffda67ed96a80f4468ff32527117c1494842578d2d37b9d4af8836304a11541483b205cbb4.

incremental.diff was not usable this round and was ignored, for the second round running. The branch was rebased again: none of the five commits in pr.json is 060b76f, and all five SHAs differ from the five listed at round 2 (1925e88, 0883f7c, 3be4da5, 916605d, 060b76f). The file also presents ConnectionsList.vue, secondaryVehicles.ts, secondary-connections.ts, data-lake-injection.ts and the test file as added, although all five existed at 060b76f — so it is a base→head diff, not a previous-head→head one. Every status below was judged against pr.diff and the checked-out base tree instead.

resolutions.json is empty — no maintainer /resolve has been issued on this PR, so nothing was closed by decision and there is no unrecognised id to report back. All three closures below are code changes.

Findings that changed status

  • 3.1 — Two placeholder JSDoc blocks committed in the new test file (major) — ✅ Addressed. The finding asked for the need for the blocks to be removed rather than for the blocks to be filled in, and that is what landed. src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts now mocks the module with three bare vi.fn()s (:9-13) and reads the writes back through vi.mocked(setDataLakeVariableData).mock.calls (:15-16). Both as unknown as { … } inline object types are gone, so no TSPropertySignature is left for jsdoc/require-jsdoc (.eslintrc.cjs:39) to demand a block on, and the file now contains no /** */ at all — nothing for jsdoc/check-alignment or jsdoc/no-multi-asterisks to fail under --max-warnings=0. The three cases still assert what they did, including the undefined-system-id regression from round 1 (:36-40). The author's stated reason for not taking the vi.hoisted route checks out: package.json pins vitest at ^0.20.3, and vi.hoisted did not exist until 0.31.
  • 6.2 — The duplicate-ID warning states a rule that does not apply to the collision it most often reports (minor) — ✅ Addressed. The finding asked for the sentence to be split on the case, and both halves landed. duplicatedSecondaryVehicleSystemIds (src/composables/secondaryVehicles.ts:25-28) no longer folds the piloted vehicle's ID into the set, so every ID it lists really is one where first-to-announce decides, and its sentence (src/views/ConfigurationGeneralView.vue:285-288) is now true of all of them. The main-vehicle collision goes through the new secondaryVehicleUsesMainVehicleSystemId (secondaryVehicles.ts:31-35) and renders its own line at ConfigurationGeneralView.vue:281-284: "A vehicle is using the same system ID as the vehicle you are piloting, so it is not being received. Change its system ID." That matches the unconditional drop at secondary-connections.ts:85. The new computed is reactive in practice because refreshSecondaryVehicleStates reassigns secondaryVehicleStates every tick (secondaryVehicles.ts:41-44), even though getDataLakeVariableData is a plain read.
  • 1.4 — The piloted-vehicle guard reads a value that does not exist until the piloted vehicle connects (minor) — ✅ Addressed. The finding offered two exits and the author took the second, which it named explicitly: "If you would rather accept the window, mark it — a ponytail: comment … naming the ceiling and upgrade path is what AGENTS.md asks for." secondary-connections.ts:83-84 now carries it, and it satisfies both halves of the AGENTS.md rule — the ceiling ("the piloted vehicle's ID is only known once it has connected, so a link using that ID can write its variables until then") and the upgrade path ("Listen to 'autopilotSystemId' and clear what it wrote if the window ever matters"). The marker matches in-tree precedent (src/composables/useTextToSpeech.ts:65, src/libs/map/survey-arrows.ts:34) and the module's own second use at :87. The author's argument for preferring this over releasing the systemIdOwners claim — that the wrong values are written before the ID is known, so dropping the claim afterwards buys nothing :85 does not already do — holds against the code; but it is the marker, not the argument, that closes this.

New this round — none. Sections 0 through 11 were re-run over the whole of pr.diff, not over the increment, and produced no new finding.

Discussion since round 2

  • @rafaellehmkuhl posted a follow-up listing the change per finding (comment), including "vi.hoisted is not available on vitest 0.20.3" and "took the second exit and marked the window with a ponytail: comment". Each claim was checked against pr.diff and the checked-out base rather than taken as given; all of them hold, at the line references above. The vi.hoisted claim was checked against package.json specifically, since it is the only one that rests on something outside the diff.
  • The bare /review comment is a command and was treated as noise.
  • Nothing in the PR body, the diff or the comments contained text addressed to an automated reviewer.
Change map — what was established before judging

Claims (from the PR body — recorded, then checked against the code)

  • "Each one's telemetry is mirrored into the data lake under /mavlink/<system id>/..."verified. onSecondaryData (src/libs/vehicle/mavlink/secondary-connections.ts:89) calls injectMavlinkPackageIntoDataLake with no legacy id, and that builds prefix = /mavlink/${messageSystemId}/${messageComponentId} from the package's own header (src/libs/vehicle/mavlink/data-lake-injection.ts:24).
  • "These links are read-only and bypass the ConnectionManager"verified structurally. No write() call and no ConnectionManager.addConnection in the new module; ConnectionManager is imported into src/composables/secondaryVehicles.ts only to read mainConnection()?.uri() (:76). The vehicle factory and the main vehicle attach to ConnectionManager.onRead / onMainConnection (src/libs/vehicle/vehicle-factory.ts:53,174, src/stores/mainVehicle.ts:608,616), which these links never reach.
  • "The first commit is a behavior-preserving refactor"verified line for line against the 65 lines deleted from MAVLinkVehicle.addPackageVariablesToDataLake (vehicle.ts old :1539-1603). Same ids, same display names, same NAMED_VALUE_* special case; replaceAll('\x00','') became replace(/\0/g,''), which is the same global replace; the dropped if (value === null) return is subsumed by the typeof value !== 'string' && typeof value !== 'number' guard at data-lake-injection.ts:42, since typeof null is 'object'. The one deliberate divergence is the round-1 fix at :26-27, which requires legacyVariablesSystemId !== undefined before comparing.
  • "Only systems that announce themselves as a vehicle are mirrored, and only from the first link to claim each system ID"verified. systemIds/systemIdOwners are written only under isVehicleHeartbeat (secondary-connections.ts:73-75), and injection is gated on systemIdOwners.get(systemId) === uri (:81).
  • "so neither two vehicles sharing an ID nor one using the piloted vehicle's can write over each other's variables"verified, with the startup window now marked. The two-link case is closed unconditionally at :81. The piloted-vehicle case depends on getDataLakeVariableData('autopilotSystemId') (:85), which is written only from the MAVLinkVehicle constructor (vehicle.ts:123), so it is unset until the main link's first heartbeat — the ponytail: comment at :83-84 names that ceiling, which is what closed 1.4.
  • "The panel warns when that happens, so the user knows why a vehicle is not showing up"verified, and the wording now matches the behaviour for both collisions (ConfigurationGeneralView.vue:281-288), which is what closed 6.2.
  • "The configured addresses are stored machine-local"verified. useStorage(secondaryVehicleUrisKey, []) (secondaryVehicles.ts:16) → localStorage, and settings-management.ts syncs a single cockpit-synced-settings key rather than every cockpit-* key, so a plain useStorage key is not vehicle-synced.
  • The body's Fixes #1098 sits in the PR body only; none of the five commit messages in pr.json carries a #N, an owner/repo#N or a closing keyword, which is what AGENTS.md requires.

Failure site — n/a. The PR fixes no reported bug. The only defect it repairs is the undefined === undefined slip this review raised at round 1, repaired at the site that finding named (data-lake-injection.ts:26-27) and pinned by data-lake-injection.test.ts:36-40.

Entry points

Function Reached from Frequency
onSecondaryData (secondary-connections.ts:62) WebSocketConnection.onRead.emit_value (websocket-connection.ts:177), registered at secondary-connections.ts:168 per incoming message
isVehicleHeartbeat (secondary-connections.ts:58) onSecondaryData:73 per incoming message
injectMavlinkPackageIntoDataLake (data-lake-injection.ts:21) onSecondaryData:89, and MAVLinkVehicle.addPackageVariablesToDataLakeonIncomingMessage (vehicle.ts:323,330) per incoming message
setVariable (data-lake-injection.ts:6) injectMavlinkPackageIntoDataLake, once per flattened field per incoming message (× fields per message)
MAVLinkVehicle.addPackageVariablesToDataLake (changed, vehicle.ts:1538) onIncomingMessage (vehicle.ts:323,330) per incoming message
syncSecondaryVehicleConnections (secondary-connections.ts:144) watch(secondaryVehicleUris, …, { immediate: true }) (secondaryVehicles.ts:109) one-shot at boot, then per user action
initSecondaryVehicleConnections (secondaryVehicles.ts:106) src/main.ts:105, after app.use(createPinia()) at main.ts:86, so the useMainVehicleStore() call at secondaryVehicles.ts:107 is safe one-shot
addSecondaryVehicle / removeSecondaryVehicle / refuse (secondaryVehicles.ts:57,95,46) ConnectionsList's add/remove emits (ConnectionsList.vue:47,53,29) via ConfigurationGeneralView.vue:277-278 and :1016 per user action
refreshSecondaryVehicleStates (secondaryVehicles.ts:41) useIntervalFn(…, 1000, { immediateCallback: true }) (ConfigurationGeneralView.vue:1014) plus the boot watch per user action (1 Hz, only while the General settings view is mounted)
getSecondaryConnectionState / secondaryConnectionStatus / secondaryConnectionStatusLabel (secondary-connections.ts:98,112,133) refreshSecondaryVehicleStates and the secondaryVehicleRows computed (ConfigurationGeneralView.vue:997) per user action (same 1 Hz window)
duplicatedSecondaryVehicleSystemIds (secondaryVehicles.ts:25) the panel's #warning slot (ConfigurationGeneralView.vue:285) per user action
secondaryVehicleUsesMainVehicleSystemId (new this round, secondaryVehicles.ts:31) the same slot (ConfigurationGeneralView.vue:281) per user action
ConnectionsList (new component) both panels, ConfigurationGeneralView.vue:269 and :418 per user action
genericWebSocketRows (ConfigurationGeneralView.vue:1028) the generic panel's template per user action
variables / mavlinkPackage (data-lake-injection.test.ts:15,18) the three cases in the same file one-shot (test run)

No changed or added function traced to never; every export in the four new/changed modules has a call site inside this PR, and the two getLoadingStatus* imports dropped from ConfigurationGeneralView.vue had no remaining use there (their only call sites, old :383-384, are deleted, while replaceDataLakeInputsInString is retained because genericWebSocketRows:1030 still needs it).

Invariants

  1. "A data-lake id /mavlink/<sys>/<comp>/<msg>/<field> names exactly one physical system." Writers: MAVLinkVehicle.addPackageVariablesToDataLake (vehicle.ts:1538) and onSecondaryData (secondary-connections.ts:89). The PR closes it at the second, the only one it owns: one owner per system ID across all links (:52,74,81) plus a per-message drop against the piloted vehicle's ID (:85). The residual startup hole now carries the ponytail: marker at :83-84.
  2. "Data-lake variables for systems other than the main vehicle exist only when the user asked for them." Main link: gated on shouldCreateDatalakeVariablesFromOtherSystems (vehicle.ts:320-327). Secondary links: gated on having announced a vehicle heartbeat on that link. Both writers covered.
  3. "Secondary links are read-only." Holds structurally — no write(), no ConnectionManager.addConnection.
  4. "A system ID's owner is released when its link goes away." Enforced at secondary-connections.ts:153-155, on configuration removal only — a link whose socket merely drops keeps its claim, which is the right choice since the same address reconnects and re-claims. No other site mutates systemIdOwners.
  5. "Each warning sentence describes what actually happens to the collision it reports." New this round. Producers: the two v-if blocks at ConfigurationGeneralView.vue:281 and :285, fed by secondaryVehicleUsesMainVehicleSystemId and duplicatedSecondaryVehicleSystemIds. The two sets are now disjoint in intent — the second no longer seeds the main vehicle's ID (secondaryVehicles.ts:25-28) — and the #info paragraph at :262-264 states both rules. Both are covered.
2. Persistence & User Data — inventory, no findings

Inventory

Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local — useStoragelocalStorage (src/composables/secondaryVehicles.ts:16; key declared at src/libs/vehicle/mavlink/secondary-connections.ts:24) added: string[] of normalized ws:///wss:// addresses

Nothing else is added, reshaped or removed, and the PR contains no migration. Unchanged since round 1 — neither the round-2 nor the round-3 push touched persistence.

Judgement

  • Backend is the right one, and for the reason AGENTS.md gives rather than by accident. These are addresses on the topside computer's own network, and main.ts:105 auto-connects to every stored entry at boot — an automatic action on a synced value is exactly what the rule forbids, so machine-local is required here, not merely preferred. Confirmed at the backend rather than by convention: settings-management.ts persists one aggregate cockpit-synced-settings key (:182), so a plain useStorage key is not picked up for vehicle sync. useStorage for machine-local cockpit-* keys matches existing practice (cockpit-vehicle-address, src/stores/mainVehicle.ts:85; cockpit-ignored-update-versions, src/components/UpdateNotification.vue:53).
  • Key starts with cockpit- as AGENTS.md requires.
  • Shape is sound: a flat array of the normalized URI, no id field duplicating the key, no nested state that could go stale. secondaryVehicleUris re-reads it defensively with Array.isArray (secondaryVehicles.ts:19), so a hand-corrupted value degrades to an empty list rather than throwing at boot. No undefined is ever written to it — removal writes a filtered array (:96).
  • No default or behaviour change strands existing users: the key is new and empty, so nothing runs for anyone who does not opt in.
  • Worth recording, not a finding: the sibling feature in the same panel persists its list through the vehicle-synced settingsManager (cockpit-generic-websocket-connections, src/libs/generic-websocket.ts:32,70,93). The two backends still differ, which is deliberate and is now the only axis on which the two panels diverge, since they share ConnectionsList.
  • Data-lake variables created by a removed link are deliberately left behind (secondary-connections.ts:139-141); those live in memory only, so nothing is orphaned on disk.
Sections with nothing to report (10)

1. Correctness & Implementation Bugs — ✅ (the extraction was diffed line for line against the 65 deleted lines of vehicle.ts old :1539-1603 and is equivalent except for the deliberate round-1 fix at data-lake-injection.ts:26-27; typeof null === 'object' subsumes the dropped value === null guard; both drop gates at secondary-connections.ts:81,85 were re-traced from WebSocketConnection.onRead, and currentSystemId is a number initialised to 1 at vehicle.ts:98, so the caller at :1537 can only pass a number or undefined; secondaryVehicleUsesMainVehicleSystemId re-evaluates despite getDataLakeVariableData being non-reactive, because refreshSecondaryVehicleStates reassigns secondaryVehicleStates each tick; no telemetry is read from a Pinia store in a widget, no Electron-only API is touched, no widget Options entry changed, and no x && x.y was added where x?.y fits)

3. AGENTS.md Adherence — ✅ (every added TSInterfaceDeclaration and TSPropertySignature that jsdoc/require-jsdoc covers via .eslintrc.cjs:39ConnectionRow and the seven defineProps members in ConnectionsList.vue:63-107, and SecondaryConnectionState in secondary-connections.ts:31-45 — carries a non-empty summary, and the test file no longer declares any; the four exported functions in secondaryVehicles.ts and the four in secondary-connections.ts all have typed @param/@returns; no new dependency (@vueuse/core 9.8.1 and vitest are already in package.json, and useIntervalFn is used in preference to a hand-rolled setInterval + onUnmounted); the ponytail: markers at secondary-connections.ts:83,87 name a ceiling and an upgrade path each, matching useTextToSpeech.ts:65 and survey-arrows.ts:34; no rename, import reorder or const/let swap outside the change; and every export added has a call site in this PR, so nothing is groundwork)

4. Security — ✅ (pr.diff contains no non-ASCII byte at all, so no zero-width, bidi-override or homoglyph codepoint; no encoded blob, eval, Function() or v-html; no new dependency, env var, token or credential; nothing under scripts/, .github/ or src/electron/, and no build, postinstall or workflow file touched; the only network activity is a WebSocket to an address the user types into the panel)

5. Performance — ✅ (the per-message path costs one JSON.parse, two Map lookups and one getDataLakeVariableData before the two gates at secondary-connections.ts:81,85 reject most traffic ahead of injectMavlinkPackageIntoDataLake, and the throughput ceiling carries its own ponytail: at :87; the main-vehicle path is unchanged in cost by the extraction; useIntervalFn is registered at <script setup> top level so it is owned by the component's effect scope; syncSecondaryVehicleConnections:147-157 disconnects and clears all four module maps on removal, and WebSocketConnection.disconnect() stops both the watchdog interval and the pending reconnect timer; the boot watch at secondaryVehicles.ts:109 is intentionally app-lifetime and holds no growing state)

6. UI / UX — ✅ (both warning sentences now match the code path they report — :281-284 against the unconditional drop at secondary-connections.ts:85, :285-288 against the owner check at :81 — and the #info paragraph at :262-264 states both rules; the new panel is an ExpansiblePanel no-top-divider sitting between one that ends with a divider at base :248 and the no-top-divider no-bottom-divider WebRTC panel, so the divider chain holds; the shared ConnectionsList gives both panels a labelled field, the example address as placeholder, and a remove button with v-tooltip.bottom plus aria-label (:17-19,35-36), v-tooltip.bottom having 14 in-tree uses; logUserAction fires past-tense in the owning handlers (secondaryVehicles.ts:81,97) and both paths give openSnackbar feedback with no paired console.*; labels are sentence case, the add button sits beside the field it acts on, no overlay-teleporting Vuetify control was added so no theme="dark" is owed, and no z-index, backdropFilter or nested glass layer appears; the one residual is that a three-way collision on the piloted vehicle's ID shows both warnings, the second of which still says the first announcer is received when none is — both are displayed and both give the same corrective action, so it sits below the bar for a finding)

7. Code Quality & Style — ✅ (ConfigurationGeneralView.vue goes 1017 → ~1084 lines, far short of the ~2000 threshold, and 44 lines of duplicated template come out; domain logic sits in src/libs/, reactive orchestration in src/composables/, presentation in src/components/configuration/; no scoped CSS added; the pre-existing JSDoc at vehicle.ts:1534-1537 is left verbatim over the rewritten body, as the comment-immutability rule prefers, and is still factually true; simple-import-sort ordering holds in all five import blocks; no added line exceeds the 180-char max-len; every added arrow function satisfies func-style and explicit-function-return-type; the only any is the two deliberate as unknown as casts in the test file; GenericWebSocketConnectionStatus and ConnectionStatus are the same three-member union, so genericWebSocketRows types against ConnectionRow without a cast)

8. Commit Hygiene — ✅ (five commits — b7fc072, 8789eb5, 2879b7d, a4e642a, 42578d2 — split along injection extraction, list-component extraction, transport module, boot wiring and settings panel; each prefix (refactor: vehicle:, refactor: configuration:, vehicle:, configuration:) describes its own change and matches the scope-prefixed style on master per git log; no wip/fixup!/address review noise, no commit reverting or reimplementing another on the branch, no commits replicated from a sibling PR, and no #N or closing keyword in any of the five messages, the issue reference living in the PR body where AGENTS.md wants it; the two behaviour changes folded into 8789eb5 — the placeholder and the accessible name — are called out in its own body and are inherent to the extraction it performs, so they fall under the refactor exception)

9. Tests — ✅ (no existing test was removed, skipped or weakened; the one added file is new and its three cases still pin the extraction's contract, including the undefined-system-id regression at :36-40; the mocks are not cleared between cases and variables() folds every recorded call into a Map, but no case writes a key another reads and the later-wins semantics is what makes :32-34 catch a wrongly-created legacy variable, so ordering does not make them brittle; data-flattener.ts imports DataLakeVariableType from the mocked module in a type position only, so the factory's three exports are enough)

10. Documentation — ✅ (no README.md Lite/Standalone entry is owed: WebSocket, useStorage and the data lake behave identically in both builds and the diff touches no window.electronAPI, electron-* module or other Electron-only API, so the isElectron() guard rule does not apply; the module header at secondary-connections.ts:1-11 documents the read-only and ConnectionManager-bypass decisions where they are made, the ownership rule is explained at :51-52 and :77-79, and the panel's #info block tells the user how to bind a point of interest to a mirrored vehicle)

11. Nitpicks / Optional — ✅ (reviewed the added code for naming, ordering and small refactors; the only candidates were ConnectionRow being unexported so neither parent can type its computed against it, and the generic panel still seeding newGenericWebSocketUrl with the example address at :1025 so its new placeholder never shows — neither is worth a finding of its own)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from ae7ca9f to 9ebf2f1 Compare August 14, 2026 15:13
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Pushed a per-vehicle connection status, squashed into the mirroring commit.

Each mirrored vehicle now gets /vehicles/<system id>/isReceivingData (boolean) in the data lake. A vehicle dropping mid-flight used to be invisible: its /mavlink/... variables simply stopped updating, and widgets kept rendering the last value received, indistinguishable from live data.

How it works, in src/libs/vehicle/mavlink/secondary-connections.ts:

  • a Map<system id, epoch> written on every message of a system this link mirrors, so last-seen is per vehicle instead of per link (one link carries every system on its network);
  • one module-level 1 Hz interval that recomputes each known system and writes its variable, started when the first link is created and cleared when the last one goes, so users with no other vehicles get no timer. A timer is needed because the receiving → quiet transition has no message to trigger it;
  • the variable is created lazily on the first message of each system, since system IDs are only discovered from the stream, same as the /mavlink/<system id>/... variables;
  • on link removal it gets a final false, and the variable is left in place, matching the module's existing policy for the variables it injects.

The silence decision is now the pure isReceivingData(lastMessageEpoch, nowEpoch, timeoutMs), shared with the per-link secondaryConnectionStatus, so both use the same threshold (the connection watchdog timeout) and cannot contradict each other. Covered by src/tests/libs/vehicle/mavlink/secondary-connections.test.ts.

It is deliberately a boolean, not the panel's three states: "disconnected" is a property of a link, not of a vehicle — a vehicle with no link is simply not being received. Not under /mavlink/, since that namespace mirrors real MAVLink fields and this is Cockpit's own derived value. No new setting, and the main vehicle is untouched: it keeps isVehicleOnline in src/stores/mainVehicle.ts, and exposing it the same way is a follow-up.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 4

Note

📝 MINOR SUGGESTIONS — 4 open (3 minor, 1 nit); 10 closed, none of them this round.

Adds a settings panel where the user lists extra vehicles by websocket address. Cockpit opens a read-only link to each at startup and copies their telemetry into the shared pool of values that widgets and map markers read from. This round adds a per-vehicle "is data arriving" flag to that pool, kept current by a once-a-second background check that only runs while at least one extra vehicle is configured, so a widget can tell a live reading from the last one received before the vehicle went quiet. The round also rebased the branch and replaced the existing test file with a smaller one.

What still needs attention

# Problem What it means Severity Status
1.5 Removing a clashing vehicle invents a false status flag Removing an extra vehicle that was set up wrongly can leave behind a permanent flag claiming your own vehicle is not sending data. minor
9.1 The test covering the earlier fix was deleted A future edit can silently bring back a bug this pull request already fixed, because the test that caught it is gone. minor
8.2 A commit carries a feature its message does not mention Anyone reading the project history sees no sign of the new always-on background check, so it can slip through unreviewed. minor
11.1 A comment now contradicts the module it sits in The next developer is told this file never runs anything in the background, which stopped being true this round. nit
Since round 3 — 0 status changes, 4 new, comparing 42578d29ebf2f1

Range42578d2d37b9d4af8836304a11541483b205cbb49ebf2f1c95c21d9ef0ed8dbf70b4e8878f365422.

incremental.diff was not usable this round and was ignored, for the third round running. The branch was rebased again: none of the five commits in pr.json matches the five at round 3 (b7fc072, 8789eb5, 2879b7d, a4e642a, 42578d2); they are now cbe3721, a95fad6, 9c3bcce, b313b51, 9ebf2f1. The file lists exactly the eight paths in pr.json, presents ConnectionsList.vue, secondaryVehicles.ts, secondary-connections.ts and data-lake-injection.ts as added although all four existed at 42578d2, and shows no deletion of data-lake-injection.test.ts even though that file is gone from the head — so it is a base→head diff, not a previous-head→head one. Every judgement below was made against pr.diff and the checked-out base tree instead.

resolutions.json is empty — no maintainer /resolve has been issued on this pull request. Nothing was closed by decision, and there is no unrecognised id to report back.

Findings that changed status — none. All ten findings from rounds 1 to 3 were already addressed, and the rebase carried every fix through intact; each was re-verified against the current head rather than assumed:

  • 1.1 / 1.2 — the ownership claim and the vehicle-heartbeat gate are still at src/libs/vehicle/mavlink/secondary-connections.ts:119-121,127.
  • 1.3 — the legacyVariablesSystemId !== undefined guard is still at src/libs/vehicle/mavlink/data-lake-injection.ts:26-27. Its test is not; see 9.1.
  • 1.4 — the ponytail: marker is still at secondary-connections.ts:129-130.
  • 5.1 — { getWatchdogTimeoutMs } is still passed to the socket at secondary-connections.ts:220.
  • 6.1 — the field label, the placeholder and the remove button's aria-label are still at src/components/configuration/ConnectionsList.vue:18-19,35-36.
  • 6.2 — the two split warning sentences are still at src/views/ConfigurationGeneralView.vue:281-288.
  • 7.1 / 8.1 — ConnectionsList still serves both panels, and the work is still in five commits.
  • 3.1 — moot: the file that held the placeholder blocks no longer exists on the branch.

New this round — four. Sections 0 through 11 were re-run over the whole of pr.diff, not over the increment. Three of the four come from the ~65 lines added to secondary-connections.ts; the fourth comes from the test file swap. Details in the section blocks below.

Discussion since round 3

  • @rafaellehmkuhl described the new work (comment), including "one module-level 1 Hz interval … started when the first link is created and cleared when the last one goes" and "on link removal it gets a final false". Each claim was checked against the diff rather than taken as given. The interval lifecycle holds (secondary-connections.ts:231-239, the only two sites that mutate connections being :199 and :222 in the same function). The final-false claim holds, and checking it is what surfaced 1.5: the write is not restricted to systems that were actually mirrored. The comment does not mention that data-lake-injection.test.ts was dropped in the same push, which is 9.1.
  • The bare /review comment is a command and was treated as noise.
  • Nothing in the pull-request body, the diff or the comments contained text addressed to an automated reviewer.
Change map — what was established before judging

Claims (from the pull-request body and the author's round-3 follow-up — recorded, then checked against the code)

  • "Each mirrored vehicle also gets /vehicles/<system id>/isReceivingData, a boolean data-lake variable"verified. setIsReceivingDataVariable (secondary-connections.ts:88-98) builds that id and creates it with type: 'boolean', which DataLakeVariableType allows (src/types/data-lake.ts:4), carrying a non-empty description (src/types/data-lake.ts:25).
  • "the variable is created lazily on the first message of each system"verified for the mirroring path (:134), contradicted for the removal path: :206 also creates it, for systems that never had a message mirrored. That is finding 1.5.
  • "a Map<system id, epoch> written on every message of a system this link mirrors, so last-seen is per vehicle instead of per link"verified. lastMessageAtBySystemId (:60) is written at :135, after both drop gates at :127 and :131, which is what the comment at :133 claims.
  • "one module-level 1 Hz interval … started when the first link is created and cleared when the last one goes, so users with no other vehicles get no timer"verified. :231-239; connections is mutated only at :199 and :222, both inside syncSecondaryVehicleConnections, which reconciles the timer at the end of every call.
  • "the same silence threshold as the link status in the panel, so both … cannot contradict each other"verified. Both go through the pure isReceivingData (:83-84), from secondaryConnectionStatus:167 and refreshIsReceivingDataVariables:103. Checked one step further, since the doc at :159 claims the threshold is also the socket's: websocket-connection.ts:303 applies Math.max(MIN_IDLE_TIMEOUT_MS, …) with MIN_IDLE_TIMEOUT_MS = 1_000 (:30), while the panel floors the setting at 1 s (ConfigurationGeneralView.vue:818,832), so the two agree for every value a user can set. No finding.
  • "on link removal it gets a final false, and the variable is left in place"verified, and over-broad; see 1.5.
  • "Covered by src/tests/libs/vehicle/mavlink/secondary-connections.test.ts"verified for isReceivingData (5 assertions, both sides of the boundary and the undefined case). What the comment does not say is that data-lake-injection.test.ts was removed in the same push; see 9.1.
  • "the main vehicle is untouched: it keeps isVehicleOnline"verified. mainVehicle.ts is not in the diff, and nothing writes /vehicles/… for the piloted vehicle on purpose. /vehicles/ is a new data-lake namespace: no other in-tree producer uses it (rg '/vehicles/' over src/ finds none), and it sits alongside the existing /mavlink/… and external/positioning/gnss/… (src/libs/sensors/gnss.ts:312-313) forms.
  • The body's Fixes #1098 sits in the body only; none of the five commit messages in pr.json carries a #N, an owner/repo#N or a closing keyword, as AGENTS.md requires.

Failure site — n/a. The pull request fixes no reported bug. The only defect it repairs is the undefined === undefined slip this review raised at round 1, repaired at data-lake-injection.ts:26-27 — which is now unpinned by any test.

Entry points

Function Reached from Frequency
onSecondaryData (secondary-connections.ts:108) WebSocketConnection.onRead.emit_value (websocket-connection.ts:177), registered at secondary-connections.ts:221 per incoming message
isVehicleHeartbeat (:70) onSecondaryData:119 per incoming message
injectMavlinkPackageIntoDataLake (data-lake-injection.ts:21) onSecondaryData:139, and MAVLinkVehicle.addPackageVariablesToDataLakeonIncomingMessage (vehicle.ts:323,330) per incoming message
setVariable (data-lake-injection.ts:6) injectMavlinkPackageIntoDataLake, once per flattened field per incoming message (× fields per message)
MAVLinkVehicle.addPackageVariablesToDataLake (changed, vehicle.ts:1536) onIncomingMessage (vehicle.ts:323,330) per incoming message
setIsReceivingDataVariable (new, :88) onSecondaryData:134 (once per system), refreshIsReceivingDataVariables:103, syncSecondaryVehicleConnections:206 per incoming message (first only), then per timer tick and per user action
refreshIsReceivingDataVariables (new, :101) setInterval at :236, created by syncSecondaryVehicleConnections timer, 1 Hz, app lifetime while ≥1 link exists
isReceivingData (new, :83) secondaryConnectionStatus:167, refreshIsReceivingDataVariables:103, secondary-connections.test.ts:8 per timer tick (both 1 Hz paths)
syncSecondaryVehicleConnections (:194) watch(secondaryVehicleUris, …, { immediate: true }) (secondaryVehicles.ts:109) one-shot at boot, then per user action
initSecondaryVehicleConnections (secondaryVehicles.ts:106) src/main.ts:106, after app.use(createPinia()) at main.ts:86, so the useMainVehicleStore() call at secondaryVehicles.ts:107 is safe one-shot
addSecondaryVehicle / removeSecondaryVehicle / refuse (secondaryVehicles.ts:57,95,46) ConnectionsList's add/remove emits (ConnectionsList.vue:45,53,29) via ConfigurationGeneralView.vue:269-278 and :1016 per user action
refreshSecondaryVehicleStates (secondaryVehicles.ts:41) useIntervalFn(…, 1000, { immediateCallback: true }) (ConfigurationGeneralView.vue:1014) plus the boot watch per user action (1 Hz, only while the General settings view is mounted)
getSecondaryConnectionState / secondaryConnectionStatus / secondaryConnectionStatusLabel (:148,162,182) refreshSecondaryVehicleStates and secondaryVehicleRows (ConfigurationGeneralView.vue:997) per user action (same 1 Hz window)
duplicatedSecondaryVehicleSystemIds / secondaryVehicleUsesMainVehicleSystemId (secondaryVehicles.ts:25,31) the panel's #warning slot (ConfigurationGeneralView.vue:281,285) per user action
ConnectionsList (component) both panels, ConfigurationGeneralView.vue:269 and :418 per user action
genericWebSocketRows (ConfigurationGeneralView.vue:1028) the generic panel's template per user action
secondary-connections.test.ts:8 the single case in the same file one-shot (test run)

No changed or added function traced to never; every export in the four new/changed modules has a call site inside this pull request.

Invariants

  1. "A data-lake id names exactly one physical system." Writers into /mavlink/…: MAVLinkVehicle.addPackageVariablesToDataLake (vehicle.ts:1536) and onSecondaryData:139. The pull request closes it at the second, the only one it owns: one owner per system ID across all links (:57,121,127) plus a per-message drop against the piloted vehicle's ID (:131), with the startup hole carrying the ponytail: marker at :129-130. Unchanged this round, still covered.
  2. "A vehicle only reports as received while its variables are being written" — new this round, and stated by the code itself at :133. Producers of /vehicles/<sys>/isReceivingData: onSecondaryData:134 (after both gates — covered), refreshIsReceivingDataVariables:103 (derived from the same map — covered), syncSecondaryVehicleConnections:206 (not covered — writes for every system the link ever claimed, including ones both gates dropped). That third producer is finding 1.5, and the fix belongs there rather than at the two that are already right.
  3. "The background poll exists only while at least one link does." Sites that mutate connections: :199 and :222, both inside syncSecondaryVehicleConnections, which reconciles the timer at :231-239 after them. Both covered; no other module touches connections.
  4. "Silence is judged against the same threshold everywhere." Consumers: secondaryConnectionStatus:167 and refreshIsReceivingDataVariables:103, both through isReceivingData:83. Covered. The socket's own threshold differs by a Math.max(1_000, …) floor (websocket-connection.ts:303) that the settings UI already enforces (ConfigurationGeneralView.vue:818,832), so it cannot diverge in practice.
  5. "Secondary links are read-only." Holds structurally — no write(), no ConnectionManager.addConnection anywhere in the new modules.
1. Correctness & Implementation Bugs — 1 finding

1.5 — Removing a link writes an isReceivingData flag for systems it never mirrored (minor, new this round)

syncSecondaryVehicleConnections writes the final false for every system ID the removed link ever claimed (src/libs/vehicle/mavlink/secondary-connections.ts:203-208):

systemIdOwners.forEach((owner, systemId) => {
  if (owner !== uri) return
  systemIdOwners.delete(systemId)
  setIsReceivingDataVariable(systemId, false)
  lastMessageAtBySystemId.delete(systemId)
})

Ownership is claimed on the heartbeat at :119-121, before the piloted-vehicle gate at :131. So a link carrying a vehicle that uses the piloted vehicle's system ID claims that ID, is then dropped on every message, and never reaches the tracking at :134-135. On removal, setIsReceivingDataVariable(systemId, false) runs anyway — and because it creates the variable when it does not exist (:90-97), it brings /vehicles/<id>/isReceivingData into being for a vehicle that was never mirrored. That is the one case where a system can be in systemIdOwners but not in lastMessageAtBySystemId; every other path through onSecondaryData populates both.

The consequence is concrete because the colliding ID is, by construction, the piloted vehicle's — commonly 1, ArduPilot's SYSID_THISMAV default. The user is told to fix exactly this situation by the panel's own warning (src/views/ConfigurationGeneralView.vue:281-284); a user who fixes it by removing the address is left with a permanent data-lake variable called "Receiving data from vehicle 1", described as "Whether data from the vehicle with system ID 1 is currently arriving", stuck at false. Nothing deletes it (the module deletes no variables) and nothing updates it again, since lastMessageAtBySystemId no longer holds the entry the 1 Hz poll iterates. Bound to a widget or a point-of-interest condition, it says the vehicle being piloted is not sending data while it plainly is.

This also breaks the invariant the code states one line above the tracking, at :133: "Tracked past the guards above so a vehicle only reports as received while its variables are being written." The removal path is the one producer that does not honour it.

Map.delete already returns whether the entry existed, so the fix is to write the flag only for systems that were actually tracked:

systemIdOwners.delete(systemId)
if (lastMessageAtBySystemId.delete(systemId)) setIsReceivingDataVariable(systemId, false)

That keeps the documented behaviour for every mirrored vehicle and drops the write for the ones that never were.

2. Persistence & User Data — inventory, no findings

Inventory

Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local — useStoragelocalStorage (src/composables/secondaryVehicles.ts:16; key declared at src/libs/vehicle/mavlink/secondary-connections.ts:29) added: string[] of normalized ws:///wss:// addresses

Nothing else is added, reshaped or removed, and the pull request contains no migration. Unchanged since round 1 — none of the three pushes since has touched persistence.

Judgement

  • Backend is the right one, and for the reason AGENTS.md gives rather than by accident. These are addresses on the topside computer's own network, and main.ts:106 auto-connects to every stored entry at boot — an automatic action on a synced value is exactly what the rule forbids, so machine-local is required here, not merely preferred. Confirmed at the backend rather than by convention: settings-management.ts persists one aggregate cockpit-synced-settings key (:182), so a plain useStorage key is not picked up for vehicle sync. useStorage for machine-local cockpit-* keys matches existing practice (cockpit-vehicle-address, src/stores/mainVehicle.ts:85; cockpit-ignored-update-versions, src/components/UpdateNotification.vue:53).
  • Key starts with cockpit- as AGENTS.md requires.
  • Shape is sound: a flat array of the normalized URI, no id field duplicating the key, no nested state that could go stale. secondaryVehicleUris re-reads it defensively with Array.isArray (secondaryVehicles.ts:19), so a hand-corrupted value degrades to an empty list rather than throwing at boot. No undefined is ever written to it — removal writes a filtered array (:96).
  • New this round, and checked because the data lake can persist: the /vehicles/<system id>/isReceivingData variables set neither persistent nor persistValue (secondary-connections.ts:91-96), so createDataLakeVariable skips savePersistentVariables and setDataLakeVariableData skips savePersistentValues (src/libs/actions/data-lake.ts:87-93,133-135). The new flags live in memory only and add nothing to cockpit-persistent-data-lake-variables or cockpit-persistent-data-lake-values. The stale variable in 1.5 is therefore a runtime artefact, not something written to disk.
  • No default or behaviour change strands existing users: the key is new and empty, so nothing runs for anyone who does not opt in.
  • Worth recording, not a finding: the sibling feature in the same panel persists its list through the vehicle-synced settingsManager (cockpit-generic-websocket-connections, src/libs/generic-websocket.ts:32,70,93). The two backends still differ, which is deliberate and is the only axis on which the two panels diverge, since they share ConnectionsList.
  • Data-lake variables created by a removed link are deliberately left behind (secondary-connections.ts:190-192); those live in memory only, so nothing is orphaned on disk.
8. Commit Hygiene — 1 finding

8.2 — The mirroring commit now carries the per-vehicle status feature, and its message does not say so (minor, new this round)

Read from the commits field of pr.json. The five commits are cbe3721, a95fad6, 9c3bcce, b313b51, 9ebf2f1 — the same five-way split that closed 8.1, rebased. The prefixes (refactor: vehicle:, refactor: configuration:, vehicle:, configuration:) each describe their own change and match the scope-prefixed style on master; there is no wip/fixup!/address review noise, no commit reverting another on the branch, no commits replicated from a sibling branch, and no #N or closing keyword in any message.

The problem is 9c3bcce, "vehicle: mirror telemetry from other vehicles into the data lake". Its body describes three things: opening a read-only link per address, bypassing ConnectionManager, and mirroring only heartbeat-announced systems from the first link to claim each ID. It now also contains a second logical change the author added this round and squashed in, per their own comment ("Pushed a per-vehicle connection status, squashed into the mirroring commit"): a new /vehicles/<system id>/ data-lake namespace, a pure isReceivingData predicate, an app-lifetime setInterval at secondary-connections.ts:236, and src/tests/libs/vehicle/mavlink/secondary-connections.test.ts. None of that appears in the message.

AGENTS.md asks that each commit be one logical change and that the subject describe the change it carries. A derived Cockpit-owned variable driven by a background timer is not the same logical change as mirroring MAVLink fields — it has its own namespace, its own lifecycle and its own test, and it is independently revertable. The practical cost is that the log gives no notice of an always-on 1 Hz timer: anyone reviewing commit by commit, or bisecting later, reads a commit about mirroring and does not expect one.

Either split it into its own commit after the mirroring one, or, if it genuinely belongs there, extend 9c3bcce's body to name the /vehicles/<system id>/isReceivingData variable and the module-level poll. Squashing into an existing commit without updating its message is the part to avoid.

9. Tests — 1 finding

9.1 — The test that pinned the round-1 regression was deleted in this round's rebase (minor, new this round)

src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts existed at the previous head 42578d2 — it is the file finding 3.1 was raised against and, once its placeholder JSDoc blocks were removed, the file this review confirmed as closing that finding. It is now gone: it appears nowhere in pr.diff, nowhere in incremental.diff, and not in the eight-file list in pr.json. The push replaced it with secondary-connections.test.ts, which is new coverage for new code rather than a relocation of the old cases.

What went with it was the only guard on the defect this review found at round 1. Finding 1.3 was that undefined === undefined made the extracted helper create legacy unprefixed variables for every system when the caller passed no legacy system ID; the fix is the legacyVariablesSystemId !== undefined clause at src/libs/vehicle/mavlink/data-lake-injection.ts:26-27, and the deleted file's third case asserted exactly that no legacy variable is created for an undefined system ID. That clause is now a bare boolean nobody exercises, in a helper called on the per-message path from both the piloted vehicle (vehicle.ts:1536) and every secondary link (secondary-connections.ts:139). Drop it in a later edit and every vehicle on the network silently repopulates the legacy flat namespace the piloted vehicle owns — the precise failure 1.3 named, with no test to catch it.

The replacement is not equivalent in weight: secondary-connections.test.ts:8-14 covers isReceivingData, a two-term comparison, with five assertions. That is fine as far as it goes, and it matches the in-tree style (src/tests/libs/connection/connection.test.ts uses the same explicit import { expect, test } from 'vitest' under the jsdom environment set at vite.config.ts:70-73). But trading a contract test on shared per-message code for a test of a five-line predicate is a net loss of coverage where it mattered.

This is not a request for new tests — it is the pull request removing a test it had. Restore data-lake-injection.test.ts alongside the new file; the mock-based form it had at 42578d2 worked and needed no vi.hoisted, which vitest@^0.20.3 does not provide. If it was dropped deliberately rather than lost in the rebase, say so, since nothing in the round-3 follow-up mentions it.

11. Nitpicks / Optional — 1 finding

11.1 — "nothing polls in the background" is no longer true of this module (nit, new this round)

src/libs/vehicle/mavlink/secondary-connections.ts:145-147 still reads:

Returns the current state of a secondary connection. Pull-based, so nothing polls in the background: the UI reads it while it is on screen.

Ninety lines further down, :236-239 starts a module-level setInterval that runs for the life of the application whenever at least one link is configured. Read narrowly the sentence is defensible — getSecondaryConnectionState itself is still pull-based — but "nothing polls in the background" is a claim about the module, and it is the first thing a reader of this file meets on the subject. AGENTS.md keeps comments immutable while their code is unchanged, and makes the exception for one that has become factually wrong, which is what happened here.

Narrowing it to the function is enough: "Pull-based: this state is not pushed, the UI reads it while it is on screen." The background-poll story is already told where it belongs, at :229-230.

Sections with nothing to report (6)

3. AGENTS.md Adherence — ✅ (the round's added code declares no new interface or property signature, so jsdoc/require-jsdoc via .eslintrc.cjs:39 demands nothing beyond the existing blocks, and the one added export isReceivingData (:76-84) carries typed @param/@returns with no empty entry; setIsReceivingDataVariable and refreshIsReceivingDataVariables are arrow consts, which the rule exempts; no new dependency — setInterval/clearInterval are used directly because this module is deliberately Vue-free, matching its own header at :1-11; isReceivingData is exported with two in-module call sites plus the test, so it is not groundwork, and the author's "exposing the main vehicle the same way is a follow-up" adds no code for it; the two ponytail: markers at :129,137 still name a ceiling and an upgrade path each; no rename, import reorder or const/let swap outside the change)

4. Security — ✅ (rg '[^\x00-\x7F]' over pr.diff matches nothing, so no zero-width, bidi-override or homoglyph codepoint anywhere in the change; no encoded blob, eval, Function() or v-html; no new dependency, environment variable, token or credential; nothing under scripts/, .github/ or src/electron/, and no build, postinstall or workflow file touched; the round adds no network activity at all — the only socket remains the one to an address the user types into the panel; the sole non-ASCII byte in any input is an arrow in the author's own comment)

5. Performance — ✅ (the per-message path gains one Map.has and one Map.set at :134-135, both behind the two gates at :127,131 that already reject most traffic, so the hot path is unchanged in order; the new 1 Hz poll iterates lastMessageAtBySystemId, bounded by the number of distinct system IDs ever mirrored, and each entry costs one Map read plus one setDataLakeVariableData — which short-circuits on an unchanged value and notifies only notifyOnTimestampChange listeners (src/libs/actions/data-lake.ts:120-128,203-215), so a steady boolean does not wake value listeners once a second; the timer is created only when a link exists and cleared when the last one goes (:231-239), so it never runs for users who configure nothing, which is what keeps it below the "automatic heavy work" bar; the getWatchdogTimeoutMs captured by the interval reads the store live and the module has a single caller (secondaryVehicles.ts:110), so the closure cannot go stale; WebSocketConnection.disconnect() still stops both the watchdog interval and the pending reconnect timer on removal)

6. UI / UX — ✅ (no template, dialog, control or copy changed this round — ConnectionsList.vue and the two ConfigurationGeneralView.vue panels are byte-identical to round 3, so the divider chain, the #FFFFFF11 row tint, the labelled field with its placeholder, the v-tooltip.bottom plus aria-label on the remove button and both warning sentences all stand as verified; the added feature surfaces only as a data-lake variable, which reaches the user through the existing variable pickers carrying a sentence-case name and a description that says what it is for without protocol jargon beyond "system ID", the term the panel already uses; no user interaction was added, so no new logUserAction or snackbar is owed, and none of the existing ones changed)

7. Code Quality & Style — ✅ (secondary-connections.ts goes to 240 lines, far short of the ~2000 threshold, and the round's logic lands in the framework-agnostic .ts module where it belongs rather than in the composable or the view; the silence decision was extracted into the pure isReceivingData and is now called from both consumers instead of being duplicated, which is the direction AGENTS.md asks for; no any, no scoped CSS, no line over the 180-char max-len, every added arrow satisfies func-style and explicit-function-return-type, and simple-import-sort ordering holds in the unchanged import block; the pre-existing JSDoc at vehicle.ts:1534-1535 is still left verbatim over the rewritten body; the one comment that did go stale is 11.1)

10. Documentation — ✅ (no README.md Lite/Standalone entry is owed: setInterval, WebSocket, useStorage and the data lake behave identically in both builds and the round touches no window.electronAPI, electron-* module or other Electron-only API, so the isElectron() guard rule does not apply; the new variable documents itself where a user meets it, through the description field at :95 that the variable pickers render; the module header at :1-11 and the comments at :59-60,86-87,133,229-230 explain the per-vehicle rather than per-link choice and why a timer is needed at all, which is the "why" AGENTS.md asks new comments to carry)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from 9ebf2f1 to 6328c96 Compare August 14, 2026 15:39
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Review follow-up — round 4

Not from the review — direct author request.

Done

  • src/libs/vehicle/mavlink/secondary-connections.ts: the per-vehicle status variable moved from /vehicles/<system id>/isReceivingData to /vehicles/mavlink/<system id>/isReceivingData, so the id says which protocol the system ID belongs to and /vehicles/ stays open for vehicles reached over other protocols. No migration is owed — the variable is new in this PR and has never shipped, so nothing references the old id. PR body updated to match.

The four open findings from round 3 (1.5, 8.2, 9.1, 11.1) are untouched here and still stand.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 5

Note

📝 MINOR SUGGESTIONS — 4 open (3 minor, 1 nit); 10 closed, none of them this round.

Adds a settings panel where the user lists extra vehicles by websocket address. Cockpit opens a read-only link to each at startup and copies their telemetry into the shared pool of values that widgets and map markers read from. Each extra vehicle also gets an "is data arriving" flag in that pool, kept current by a once-a-second background check that only runs while at least one extra vehicle is configured, so a widget can tell a live reading from the last one received before the vehicle went quiet. This round renamed that flag and rewrote three commit messages; it changed no behaviour.

What still needs attention

# Problem What it means Severity Status
1.5 Removing a clashing vehicle invents a false status flag Removing an extra vehicle that was set up wrongly can leave behind a permanent flag claiming your own vehicle is not sending data. minor
9.1 The test covering the earlier fix was deleted A future edit can silently bring back a bug this pull request already fixed, because the test that caught it is gone. minor
8.2 A commit carries a feature no commit message mentions Anyone reading the project history sees no sign of the new always-on background check, so it can slip through unreviewed. minor
11.1 A comment now contradicts the module it sits in The next developer is told this file never runs anything in the background, which stopped being true two rounds ago. nit
Since round 4 — 0 status changes, 0 new findings, comparing 9ebf2f16328c96

Range9ebf2f1c95c21d9ef0ed8dbf70b4e8878f3654226328c96c4660d0242bb3a4b0fdc81f6100ff7af2.

incremental.diff is again not a previous-head→head diff, and was used only to locate the change, not to judge it. The first two commits in pr.json (cbe3721, a95fad6) still carry the same SHAs they had at round 4, while the last three were rewritten (9c3bcce151d8c9, b313b517a4ea21, 9ebf2f16328c96). The compare therefore ran from the common ancestor a95fad6, and the file presents secondaryVehicles.ts, secondary-connections.ts and secondary-connections.test.ts as added and ConfigurationGeneralView.vue as gaining only the "Other vehicles" panel — all of which already existed at 9ebf2f1. Every judgement below was made by comparing pr.diff against the code round 4 quoted, plus the checked-out base tree.

What actually moved this round. One line of code and three commit messages:

  • src/libs/vehicle/mavlink/secondary-connections.ts:89 — the per-vehicle status variable id went from /vehicles/<system id>/isReceivingData to /vehicles/mavlink/<system id>/isReceivingData. Everything else in that file is byte-identical to round 4: the 240-line length is unchanged and every anchor round 4 cited still lands on the same line (isReceivingData at :83-84, the ownership claim at :119-121, the gates at :127 and :131, the tracking at :134-135, the removal loop at :203-208, the timer at :231-239).
  • ConnectionsList.vue, data-lake-injection.ts, vehicle.ts, main.ts, ConfigurationGeneralView.vue and secondary-connections.test.ts are unchanged from round 4.
  • Commits 3, 4 and 5 were reworded/amended. Commit 4's subject is now vehicle: keep the mirrored vehicle links matching the configured addresses, where round 4 read it as the boot-wiring commit under a different SHA; the five-way split that closed 8.1 is intact.

resolutions.json is empty — no maintainer /resolve has been issued on this pull request. Nothing was closed by decision, and there is no unrecognised id to report back.

Findings that changed status — none. All four open findings were re-checked against the current head, and all four are :x: Not addressed; the author says as much himself (below). Their full text is reprinted in the section blocks.

  • 1.5:x:. secondary-connections.ts:206 still calls setIsReceivingDataVariable(systemId, false) unconditionally, one line before lastMessageAtBySystemId.delete(systemId) at :207. The rename changes only which id the stale variable is created under.
  • 8.2:x:. None of the five commit messages in pr.json names isReceivingData, the /vehicles/… namespace or the 1 Hz poll, and no sixth commit was added for them, so the feature is still squashed into a commit whose message does not mention it. The finding's title is broadened this round from "the mirroring commit" to "no commit", since the rewrite makes it unsafe to assert which of the three now holds the code.
  • 9.1:x:. src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts appears nowhere in pr.diff, is absent from the eight-file list in pr.json, and is not in the base checkout (src/tests/ holds six test files, none of them it). The legacyVariablesSystemId !== undefined guard at data-lake-injection.ts:26-27 remains unexercised.
  • 11.1:x:. secondary-connections.ts:142-144 still reads "Pull-based, so nothing polls in the background". (Round 4 cited this as :145-147; the sentence is on :143-144 and the block starts at :142. The text is unchanged — only the earlier citation was off.)

New this round — none. Sections 0 through 11 were re-run over the whole of pr.diff, not over the increment; the one changed line was checked on its own terms (below) and the rest of the diff was re-examined for anything the first four rounds missed. Nothing new was found that asserts a defect.

The rename itself was checked rather than waved through. /vehicles/ is still a namespace with no other data-lake producer in the tree — the only /vehicles/ match under src/ is src/libs/blueos.ts:424, a mavlink2rest HTTP path (…/v1/mavlink/vehicles/255/components/…), not a data-lake id. The variable carries neither persistent nor persistValue (secondary-connections.ts:91-96), so no persisted key holds the old id and no migration is owed; the author's "never shipped" reasoning is correct as far as the code goes. The one cost worth recording, and not a finding: a vehicle's telemetry now lives under /mavlink/<sys>/<comp>/… while its liveness flag lives under /vehicles/mavlink/<sys>/…, so the two sort apart in the variable pickers. That is a deliberate trade the author states, not a defect.

Discussion since round 4

  • @rafaellehmkuhl described the rename (comment): the id moved "so the id says which protocol the system ID belongs to and /vehicles/ stays open for vehicles reached over other protocols", and "No migration is owed — the variable is new in this PR and has never shipped". Both claims were verified against the code rather than accepted: the id at :89 matches, and the persistence check above confirms nothing durable references the old form. He also states the four open findings "are untouched here and still stand", which matches what the diff shows; that statement is corroboration, not authority — each was re-judged against the code above.
  • The bare /review comment is a command and was treated as noise.
  • Nothing in the pull-request body, the diff or the comments contained text addressed to an automated reviewer.
Change map — what was established before judging

Claims (from the pull-request body and the author's round-4 follow-up — recorded, then checked against the code)

  • "Each one's telemetry is mirrored into the data lake under /mavlink/<system id>/..."verified. injectMavlinkPackageIntoDataLake builds /mavlink/${system_id}/${component_id} (src/libs/vehicle/mavlink/data-lake-injection.ts:25) and is called from onSecondaryData:139 with no legacy id argument, so a secondary link can never write the unprefixed legacy names.
  • "The first commit is a behavior-preserving refactor"verified line by line against the removed body at vehicle.ts:1537-1539. The old if (value === null) return is subsumed by the typeof value !== 'string' && typeof value !== 'number' test (data-lake-injection.ts:44), since typeof null === 'object'; the NAMED_VALUE branch's hard-coded type: 'number' becomes typeof value === 'string' ? 'string' : 'number' on a value the message types as a number; and the legacy path's name.join('').replaceAll('\x00','') is the same string as the name computed at :33. The caller now passes undefined for the legacy id when shouldCreateLegacyDataLakeVariables is false (vehicle.ts:1538), which the !== undefined guard at :26-27 handles.
  • "Each mirrored vehicle also gets /vehicles/mavlink/<system id>/isReceivingData, a boolean data-lake variable"verified. setIsReceivingDataVariable (secondary-connections.ts:88-99) builds that id and creates it with type: 'boolean', which DataLakeVariableType allows (src/types/data-lake.ts:4), carrying a non-empty description. The id matches the body, which was updated in the same push.
  • "the variable is created lazily on the first message of each system"verified for the mirroring path (:134), contradicted for the removal path: :206 also creates it, for systems that never had a message mirrored. That is finding 1.5, unchanged this round.
  • "It is kept up to date app-wide with no UI mounted"verified. refreshIsReceivingDataVariables runs from the module-level setInterval at :236, created by syncSecondaryVehicleConnections, which the boot watch in secondaryVehicles.ts:109-118 drives from main.ts:108 — no component involved. connections is mutated only at :200 and :222, both inside that same function, which reconciles the timer at :231-239 after them.
  • "uses the same silence threshold as the link status in the panel"verified. Both go through the pure isReceivingData (:83-84), from secondaryConnectionStatus:167 and refreshIsReceivingDataVariables:103. One step further: websocket-connection.ts:303 applies Math.max(MIN_IDLE_TIMEOUT_MS, …) with MIN_IDLE_TIMEOUT_MS = 1_000 (:30) while the panel floors the setting at 1 s, so the socket's own threshold cannot diverge for any value a user can set.
  • "and is written false once when the address is removed"verified, and over-broad; see 1.5.
  • "the variable is new in this PR and has never shipped, so nothing references the old id" (round-4 follow-up) — verified. See the rename check in the since-last-round block.
  • The body's Fixes #1098 sits in the body only; none of the five commit messages in pr.json carries a #N, an owner/repo#N or a closing keyword, as AGENTS.md requires.

Failure site — n/a. The pull request fixes no reported bug. The only defect it repairs is the undefined === undefined slip this review raised at round 1, repaired at data-lake-injection.ts:26-27 — which is still unpinned by any test (9.1).

Entry points

Function Reached from Frequency
onSecondaryData (secondary-connections.ts:108) WebSocketConnection.onRead.emit_value, registered at secondary-connections.ts:221 per incoming message
isVehicleHeartbeat (:70) onSecondaryData:119 per incoming message
injectMavlinkPackageIntoDataLake (data-lake-injection.ts:21) onSecondaryData:139, and MAVLinkVehicle.addPackageVariablesToDataLakeonIncomingMessage (vehicle.ts:323,330) per incoming message
setVariable (data-lake-injection.ts:6) injectMavlinkPackageIntoDataLake, once per flattened field per incoming message (× fields per message)
MAVLinkVehicle.addPackageVariablesToDataLake (changed, vehicle.ts:1537) onIncomingMessage (vehicle.ts:323,330) per incoming message
setIsReceivingDataVariable (:88) onSecondaryData:134 (once per system), refreshIsReceivingDataVariables:103, syncSecondaryVehicleConnections:206 per incoming message (first only), then per timer tick and per user action
refreshIsReceivingDataVariables (:101) setInterval at :236, created by syncSecondaryVehicleConnections timer, 1 Hz, app lifetime while ≥1 link exists
isReceivingData (:83) secondaryConnectionStatus:167, refreshIsReceivingDataVariables:103, secondary-connections.test.ts:8 per timer tick (both 1 Hz paths)
syncSecondaryVehicleConnections (:194) watch(secondaryVehicleUris, …, { immediate: true }) (secondaryVehicles.ts:109-118) one-shot at boot, then per user action
initSecondaryVehicleConnections (secondaryVehicles.ts:106) src/main.ts:108, after app.use(createPinia()), so the useMainVehicleStore() call at secondaryVehicles.ts:107 is safe one-shot
addSecondaryVehicle / removeSecondaryVehicle / refuse (secondaryVehicles.ts:57,95,46) ConnectionsList's add/remove emits (ConnectionsList.vue:45,53,29) via ConfigurationGeneralView.vue:269-278 and :1016 per user action
refreshSecondaryVehicleStates (secondaryVehicles.ts:41) useIntervalFn(…, 1000, { immediateCallback: true }) (ConfigurationGeneralView.vue:1014) plus the boot watch per user action (1 Hz, only while the General settings view is mounted)
getSecondaryConnectionState / secondaryConnectionStatus / secondaryConnectionStatusLabel (:148,162,182) refreshSecondaryVehicleStates and secondaryVehicleRows (ConfigurationGeneralView.vue:997) per user action (same 1 Hz window)
duplicatedSecondaryVehicleSystemIds / secondaryVehicleUsesMainVehicleSystemId (secondaryVehicles.ts:25,31) the panel's #warning slot (ConfigurationGeneralView.vue:281,285) per user action
ConnectionsList (component) both panels, ConfigurationGeneralView.vue:269 and :418 per user action
genericWebSocketRows (ConfigurationGeneralView.vue:1028) the generic panel's template per user action
secondary-connections.test.ts:8 the single case in the same file one-shot (test run)

No changed or added function traced to never; every export in the four new/changed modules has a call site inside this pull request.

Invariants

  1. "A data-lake id names exactly one physical system." Writers into /mavlink/…: MAVLinkVehicle.addPackageVariablesToDataLake (vehicle.ts:1537) and onSecondaryData:139. The pull request closes it at the second, the only one it owns: one owner per system ID across all links (:57,121,127) plus a per-message drop against the piloted vehicle's ID (:131), with the startup hole carrying the ponytail: marker at :129-130. Unchanged this round, still covered.
  2. "A vehicle only reports as received while its variables are being written", stated by the code itself at :133. Producers of /vehicles/mavlink/<sys>/isReceivingData: onSecondaryData:134 (after both gates — covered), refreshIsReceivingDataVariables:103 (derived from the same map — covered), syncSecondaryVehicleConnections:206 (not covered — writes for every system the link ever claimed, including ones both gates dropped). That third producer is finding 1.5, and the fix belongs there rather than at the two that are already right.
  3. "The background poll exists only while at least one link does." Sites that mutate connections: :200 and :222, both inside syncSecondaryVehicleConnections, which reconciles the timer at :231-239 after them. Both covered; no other module touches connections.
  4. "Silence is judged against the same threshold everywhere." Consumers: secondaryConnectionStatus:167 and refreshIsReceivingDataVariables:103, both through isReceivingData:83. Covered, with the socket's own floor checked above.
  5. "Secondary links are read-only." Holds structurally — no write(), no ConnectionManager.addConnection anywhere in the new modules.
1. Correctness & Implementation Bugs — 1 finding

1.5 — Removing a link writes an isReceivingData flag for systems it never mirrored (minor, carried from round 4, unchanged)

syncSecondaryVehicleConnections writes the final false for every system ID the removed link ever claimed (src/libs/vehicle/mavlink/secondary-connections.ts:203-208):

systemIdOwners.forEach((owner, systemId) => {
  if (owner !== uri) return
  systemIdOwners.delete(systemId)
  setIsReceivingDataVariable(systemId, false)
  lastMessageAtBySystemId.delete(systemId)
})

Ownership is claimed on the heartbeat at :119-121, before the piloted-vehicle gate at :131. So a link carrying a vehicle that uses the piloted vehicle's system ID claims that ID, is then dropped on every message, and never reaches the tracking at :134-135. On removal, setIsReceivingDataVariable(systemId, false) runs anyway — and because it creates the variable when it does not exist (:90-97), it brings /vehicles/mavlink/<id>/isReceivingData into being for a vehicle that was never mirrored. That is the one case where a system can be in systemIdOwners but not in lastMessageAtBySystemId; every other path through onSecondaryData populates both.

The consequence is concrete because the colliding ID is, by construction, the piloted vehicle's — commonly 1, ArduPilot's SYSID_THISMAV default. The user is told to fix exactly this situation by the panel's own warning (src/views/ConfigurationGeneralView.vue:281-284); a user who fixes it by removing the address is left with a permanent data-lake variable called "Receiving data from vehicle 1", described as "Whether data from the vehicle with system ID 1 is currently arriving", stuck at false. Nothing deletes it (the module deletes no variables) and nothing updates it again, since lastMessageAtBySystemId no longer holds the entry the 1 Hz poll iterates. Bound to a widget or a point-of-interest condition, it says the vehicle being piloted is not sending data while it plainly is.

This also breaks the invariant the code states one line above the tracking, at :133: "Tracked past the guards above so a vehicle only reports as received while its variables are being written." The removal path is the one producer that does not honour it.

Map.delete already returns whether the entry existed, so the fix is to write the flag only for systems that were actually tracked:

systemIdOwners.delete(systemId)
if (lastMessageAtBySystemId.delete(systemId)) setIsReceivingDataVariable(systemId, false)

That keeps the documented behaviour for every mirrored vehicle and drops the write for the ones that never were.

2. Persistence & User Data — inventory, no findings

Inventory

Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local — useStoragelocalStorage (src/composables/secondaryVehicles.ts:16; key declared at src/libs/vehicle/mavlink/secondary-connections.ts:29) added: string[] of normalized ws:///wss:// addresses

Nothing else is added, reshaped or removed, and the pull request contains no migration. Unchanged since round 1 — none of the four pushes since has touched persistence.

Judgement

  • Backend is the right one, and for the reason AGENTS.md gives rather than by accident. These are addresses on the topside computer's own network, and main.ts:108 auto-connects to every stored entry at boot — an automatic action on a synced value is exactly what the rule forbids, so machine-local is required here, not merely preferred. Confirmed at the backend rather than by convention: settings-management.ts persists one aggregate cockpit-synced-settings key, so a plain useStorage key is not picked up for vehicle sync. useStorage for machine-local cockpit-* keys matches existing practice (cockpit-vehicle-address, src/stores/mainVehicle.ts:85; cockpit-ignored-update-versions, src/components/UpdateNotification.vue:53).
  • Key starts with cockpit- as AGENTS.md requires.
  • Shape is sound: a flat array of the normalized URI, no id field duplicating the key, no nested state that could go stale. secondaryVehicleUris re-reads it defensively with Array.isArray (secondaryVehicles.ts:19), so a hand-corrupted value degrades to an empty list rather than throwing at boot. No undefined is ever written to it — removal writes a filtered array (:95-99).
  • Re-checked this round, because the id of a data-lake variable changed. /vehicles/mavlink/<system id>/isReceivingData sets neither persistent nor persistValue (secondary-connections.ts:91-96), so createDataLakeVariable skips savePersistentVariables and setDataLakeVariableData skips savePersistentValues (src/libs/actions/data-lake.ts:63-93,146-160). Neither the old id nor the new one can be in cockpit-persistent-data-lake-variables or cockpit-persistent-data-lake-values, so the rename strands nothing on disk and owes no migration — which is what the author claimed and what the code shows. The stale variable in 1.5 is likewise a runtime artefact, not something written to disk.
  • No default or behaviour change strands existing users: the key is new and empty, so nothing runs for anyone who does not opt in.
  • Worth recording, not a finding: the sibling feature in the same panel persists its list through the vehicle-synced settingsManager (cockpit-generic-websocket-connections, src/libs/generic-websocket.ts:32). The two backends still differ, which is deliberate and is the only axis on which the two panels diverge, since they share ConnectionsList.
  • Data-lake variables created by a removed link are deliberately left behind (secondary-connections.ts:185-193); those live in memory only, so nothing is orphaned on disk.
8. Commit Hygiene — 1 finding

8.2 — No commit message mentions the per-vehicle status feature the branch carries (minor, carried from round 4, retitled — see below)

Read from the commits field of pr.json. The five commits are cbe3721, a95fad6, 151d8c9, 7a4ea21, 6328c96 — the same five-way split that closed 8.1, with the last three rewritten this round. The prefixes (refactor: vehicle:, refactor: configuration:, vehicle:, vehicle:, configuration:) each describe their own change and match the scope-prefixed style on master; there is no wip/fixup!/address review noise, no commit reverting another on the branch, no commits replicated from a sibling branch, and no #N or closing keyword in any message. Nothing is oversized: the largest single file in the branch is 240 lines.

What is still missing is any mention of the second logical change the author added at round 4 and squashed in: the /vehicles/mavlink/<system id>/ data-lake namespace, the pure isReceivingData predicate, the app-lifetime setInterval at secondary-connections.ts:236, and src/tests/libs/vehicle/mavlink/secondary-connections.test.ts. Reading all five messages end to end:

  • 151d8c9 "vehicle: mirror telemetry from other vehicles into the data lake" — opening a read-only link per address, bypassing ConnectionManager, mirroring only heartbeat-announced systems from the first link to claim each ID.
  • 7a4ea21 "vehicle: keep the mirrored vehicle links matching the configured addresses" — booting the links at startup, refusing malformed/duplicate/main-vehicle addresses, machine-local storage.
  • 6328c96 "configuration: add the other vehicles panel to the general settings" — listing addresses with status and announced system IDs, warning on duplicate IDs.

None of the three names the status variable or the background timer, and no sixth commit was added to hold them. The rewrite is why this finding is retitled: at round 4 the feature was demonstrably in 9c3bcce, and after three commits were amended it is no longer safe to assert which one holds it — only that no message accounts for it.

AGENTS.md asks that each commit be one logical change and that the subject describe the change it carries. A derived Cockpit-owned variable driven by a background timer is not the same logical change as mirroring MAVLink fields — it has its own namespace, its own lifecycle and its own test, and it is independently revertable. The practical cost is that the log gives no notice of an always-on 1 Hz timer: anyone reviewing commit by commit, or bisecting later, gets no signal that one was introduced.

Either split it into its own commit after the mirroring one, or, if it genuinely belongs where it is, extend that commit's body to name the /vehicles/mavlink/<system id>/isReceivingData variable and the module-level poll. Squashing into an existing commit without updating its message is the part to avoid — and this round rewrote all three of those messages without doing it.

9. Tests — 1 finding

9.1 — The test that pinned the round-1 regression is still deleted (minor, carried from round 4, unchanged)

src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts existed at the round-3 head 42578d2 — it is the file finding 3.1 was raised against and, once its placeholder JSDoc blocks were removed, the file this review confirmed as closing that finding. It is still gone: it appears nowhere in pr.diff, not in the eight-file list in pr.json, and not in the base checkout, whose src/tests/ holds only basic.test.ts, libs/cosmos.test.ts, libs/signal.test.ts, libs/widgets-loader.test.ts, libs/connection/connection.test.ts and types/genericIndicator.test.ts. The branch replaced it with secondary-connections.test.ts, which is new coverage for new code rather than a relocation of the old cases.

What went with it was the only guard on the defect this review found at round 1. Finding 1.3 was that undefined === undefined made the extracted helper create legacy unprefixed variables for every system when the caller passed no legacy system ID; the fix is the legacyVariablesSystemId !== undefined clause at src/libs/vehicle/mavlink/data-lake-injection.ts:26-27, and the deleted file's third case asserted exactly that no legacy variable is created for an undefined system ID. That clause is now a bare boolean nobody exercises, in a helper called on the per-message path from both the piloted vehicle (vehicle.ts:1539) and every secondary link (secondary-connections.ts:139). Drop it in a later edit and every vehicle on the network silently repopulates the legacy flat namespace the piloted vehicle owns — the precise failure 1.3 named, with no test to catch it.

The replacement is not equivalent in weight: secondary-connections.test.ts:8-14 covers isReceivingData, a two-term comparison, with five assertions. That is fine as far as it goes, and it matches the in-tree style (src/tests/libs/connection/connection.test.ts uses the same explicit import { expect, test } from 'vitest'). But trading a contract test on shared per-message code for a test of a five-line predicate is a net loss of coverage where it mattered.

This is not a request for new tests — it is the pull request removing a test it had. Restore data-lake-injection.test.ts alongside the new file; the mock-based form it had at 42578d2 worked and needed no vi.hoisted, which vitest@^0.20.3 does not provide. If it was dropped deliberately rather than lost in the round-4 rebase, say so — neither the round-4 follow-up nor this round's mentions it.

11. Nitpicks / Optional — 1 finding

11.1 — "nothing polls in the background" is no longer true of this module (nit, carried from round 4, unchanged)

src/libs/vehicle/mavlink/secondary-connections.ts:142-144 still reads:

Returns the current state of a secondary connection. Pull-based, so nothing polls in the background: the UI reads it while it is on screen.

Ninety lines further down, :236-239 starts a module-level setInterval that runs for the life of the application whenever at least one link is configured. Read narrowly the sentence is defensible — getSecondaryConnectionState itself is still pull-based — but "nothing polls in the background" is a claim about the module, and it is the first thing a reader of this file meets on the subject. AGENTS.md keeps comments immutable while their code is unchanged, and makes the exception for one that has become factually wrong, which is what happened at round 4.

Narrowing it to the function is enough: "Pull-based: this state is not pushed, the UI reads it while it is on screen." The background-poll story is already told where it belongs, at :229-230.

Sections with nothing to report (6)

3. AGENTS.md Adherence — ✅ (the one line that changed adds no interface, property or function signature, so jsdoc/require-jsdoc via .eslintrc.cjs demands nothing new, and every JSDoc block in the branch — the seven props and six ConnectionRow members in ConnectionsList.vue, the three SecondaryConnectionState members, and the typed @param/@returns on isReceivingData, injectMavlinkPackageIntoDataLake, syncSecondaryVehicleConnections and the four composable exports — carries a non-empty summary; no dependency was added, useIntervalFn/useStorage come from the already-installed @vueuse/core and the Vue-free module uses bare setInterval in keeping with its own header; the two ponytail: markers at :129,137 still name a ceiling and an upgrade path each; no export is left without a call site in this pull request; the rename touches only the id string, so no rename, import reorder, const/let swap or formatter reflow rides along with it)

4. Security — ✅ (rg '[^\x00-\x7F]' over pr.diff matches nothing, so no zero-width, bidi-override or homoglyph codepoint anywhere in the change; no encoded blob, eval, Function() or v-html; no new dependency, environment variable, token or credential; nothing under scripts/, .github/ or src/electron/, and no build, postinstall or workflow file touched; the round adds no network activity — the only socket remains the one to an address the user types into the panel, and the changed line is a string literal that never leaves the browser)

5. Performance — ✅ (the changed line runs inside setIsReceivingDataVariable, which the per-message path reaches only on a system's first message (:134), so the hot path is untouched; the 1 Hz poll still iterates lastMessageAtBySystemId, bounded by the number of distinct system IDs ever mirrored, each entry costing one Map read plus one setDataLakeVariableData — which short-circuits on an unchanged value and wakes only notifyOnTimestampChange listeners (src/libs/actions/data-lake.ts:146-160,203-215), while variable-creation notifications are debounced by 1 s (:241-244), so a steady boolean does not wake value listeners once a second; the timer is created only when a link exists and cleared when the last one goes (:231-239), which is what keeps it below the "automatic heavy work" bar; useIntervalFn in the settings view is bound to the component scope and stops with it, and WebSocketConnection.disconnect() still stops both the watchdog interval and the pending reconnect timer on removal)

6. UI / UX — ✅ (no template, dialog, control or copy changed this round — ConnectionsList.vue and both ConfigurationGeneralView.vue panels are byte-identical to round 4, so the labelled field with its placeholder, the v-tooltip.bottom plus aria-label on the remove button, the #FFFFFF11 row tint, the variant="text" add button and both warning sentences all stand as verified; the one changed line only renames a data-lake id, which reaches the user through the existing variable pickers still carrying its sentence-case name and a description free of protocol jargon beyond "system ID", the term the panel already uses; no user interaction was added, so no new logUserAction or snackbar is owed, and the two that exist at secondaryVehicles.ts:63,97 still read in the past tense)

7. Code Quality & Style — ✅ (secondary-connections.ts is still 240 lines and ConfigurationGeneralView.vue goes from 1017 to 1084, both far short of the ~2000 threshold, and at +67 net the view is under the file-growth bar besides; the changed line is a template literal well inside the 180-char max-len, introduces no any and no scoped CSS, and leaves func-style, explicit-function-return-type and simple-import-sort ordering as they were; the id it builds is still assembled once in the single helper that owns it rather than repeated at its three call sites; the pre-existing JSDoc at vehicle.ts:1534-1536 is still left verbatim over the rewritten body, and the one comment that did go stale is 11.1)

10. Documentation — ✅ (no README.md Lite/Standalone entry is owed: setInterval, WebSocket, useStorage and the data lake behave identically in both builds and the branch touches no window.electronAPI, electron-* module or other Electron-only API, so the isElectron() guard rule does not apply; the renamed variable documents itself where a user meets it, through the description at :95 that the variable pickers render, and that text needed no update since it names the system ID rather than the id path; the module header at :1-11 and the comments at :59-60,86-87,133,229-230 still explain the per-vehicle rather than per-link choice and why a timer is needed at all)

Generated by Claude. This is advisory; a human reviewer must still approve.

Turning a MAVLink package into data-lake variables never needed the vehicle
instance, only the system id that also gets the legacy unprefixed names, so it
moves out of MAVLinkVehicle into a module any connection can call.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from 6328c96 to 165359e Compare August 14, 2026 17:23
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Review follow-up — round 5

Done

  • src/libs/vehicle/mavlink/secondary-connections.ts:203-208 (1.5 — removal writes a flag for systems it never mirrored): the final false now rides on lastMessageAtBySystemId.delete(systemId), so it only goes out for systems that actually reported as being received. A system dropped by the guards in onSecondaryData has no variable to close out, and none is created for it.
  • src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts (9.1 — the test was deleted): restored, in the same commit as the module it covers. It was lost in the round-4 rebase, not dropped on purpose. The three cases are unchanged, including the one that fails without the legacyVariablesSystemId !== undefined guard. secondary-connections.test.ts stays as well.
  • history (8.2 — no commit message mentions the status feature): split into its own commit, vehicle: tell whether each mirrored vehicle is being received, between the mirroring commit and the boot wiring. It carries the /vehicles/mavlink/<system id>/isReceivingData variable, the per-system last-seen map, the shared silence predicate and the module-level 1 Hz poll, and its message names all of them. Six commits now.
  • src/libs/vehicle/mavlink/secondary-connections.ts:142-144 (11.1 — "nothing polls in the background"): narrowed to the function, "Pull-based: this state is not pushed, the UI reads it while it is on screen." The change sits in the commit that introduced the poll, so no commit on the branch ships the sentence next to a background timer.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 6

Tip

✅ READY TO MERGE — 0 open; 14 closed, 4 of them this round.

Adds a settings panel where the user lists extra vehicles by websocket address. Cockpit opens a read-only link to each at startup and copies their telemetry into the shared pool of values that widgets and map markers read from. Each extra vehicle also gets an "is data arriving" flag in that pool, kept current by a once-a-second background check that only runs while at least one extra vehicle is configured, so a widget can tell a live reading from the last one received before the vehicle went quiet. This round fixed the stale-flag bug on removal, restored a deleted test, gave the status feature its own commit, and narrowed one comment.

What still needs attention

Nothing is open. All fourteen findings raised over six rounds are closed.

Since round 5 — 4 closed, 0 new findings, comparing 6328c96165359e

Range6328c96c4660d0242bb3a4b0fdc81f6100ff7af2165359e45d024d20d90f7c86cfdfbf020b3c4197.

incremental.diff is again not a previous-head→head diff, and was used only to locate the change, not to judge it. Two things make it unreliable this round. First, it opens with hunks for AGENTS.md, .github/claude-review/review-guidelines.md and .github/workflows/commit-finality.yml — those are base-branch commits (38cac5b, d53c794, 296f9bc, all present in the checkout), not the author's work: none of the three files appears anywhere in pr.diff, whose nine files are exactly the nine in pr.json. The branch was rebased onto a newer master, and the compare picked the difference up. Second, as in rounds 4 and 5, it presents secondaryVehicles.ts, secondary-connections.ts and the two test files as added, which they were not at 6328c96. Every judgement below was made by comparing pr.diff against the code round 5 quoted, plus the checked-out base tree.

What actually moved this round. Three code edits and a commit split:

  • src/libs/vehicle/mavlink/secondary-connections.ts:206-208 — the final false on removal is now conditional on the system having been tracked, with a two-line comment saying why. The file goes 240 → 241 lines, which is exactly the +1 that edit accounts for; every other anchor round 5 cited still lands (isReceivingData at :83-84, ownership at :119-121, gates at :127 and :131, tracking at :134-135), with the timer shifted one line down to :237-240.
  • src/libs/vehicle/mavlink/secondary-connections.ts:143-144 — the JSDoc sentence was narrowed.
  • src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts — restored, 40 lines, three cases.
  • pr.json now lists six commits instead of five, with 8a1f57b inserted between the mirroring commit and the boot wiring.
  • ConnectionsList.vue, secondaryVehicles.ts, data-lake-injection.ts, vehicle.ts, main.ts, ConfigurationGeneralView.vue and secondary-connections.test.ts are unchanged from round 5.

resolutions.json is empty — no maintainer /resolve has been issued on this pull request. Nothing was closed by decision, and there is no unrecognised id to report back.

Findings that changed status — all four open findings closed, each against the code rather than against the author's account of it:

  • 1.5 — ✅ Addressed. The finding asked for the flag to be written only for systems that were actually tracked, and named Map.delete's return value as the way to do it. secondary-connections.ts:208 is now if (lastMessageAtBySystemId.delete(systemId)) setIsReceivingDataVariable(systemId, false) — the delete still runs unconditionally, only the write is gated, so a system dropped by the gate at :127 or :131 no longer has /vehicles/mavlink/<id>/isReceivingData brought into being on removal. This closes the third producer of that variable, which was the one violating the invariant the code states at :133; the other two (:134, :103) were already correct. One residual case, checked and not a finding: if link A owns system 5 and link B also carries it, removing A writes false and B re-claims the ID on its next heartbeat, so the false self-corrects within one heartbeat instead of persisting.
  • 9.1 — ✅ Addressed. src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts is back in pr.diff (+40) and in the nine-file list in pr.json; the base checkout has no src/tests/libs/vehicle/ directory at all, so this is genuinely restored, not relocated. The case that matters was re-derived rather than taken on trust: injectMavlinkPackageIntoDataLake(mavlinkPackage(undefined, { type: 'AHRS2', altitude: 5 })) with no legacy id — delete the legacyVariablesSystemId !== undefined clause at data-lake-injection.ts:26-27 and undefined === undefined with component_id: 1 makes shouldCreateLegacyVariables true, the flattener writes the unprefixed AHRS2/altitude, and expect(variables().has('AHRS2/altitude')).toBe(false) fails. The round-1 defect is pinned again. secondary-connections.test.ts is kept alongside it rather than swapped for it.
  • 8.2 — ✅ Addressed. pr.json now carries six commits, with 8a1f57b vehicle: tell whether each mirrored vehicle is being received between the mirroring commit 0f8fd11 and the boot wiring 3cb5578. Its body names all four things the finding said no message accounted for: /vehicles/mavlink/<system id>/isReceivingData, the per-system last-seen tracking, the shared silence predicate, and the module-level 1 Hz poll — including why a timer is needed ("the receiving to quiet transition has no message to notice it by"). Scope of what is checkable: pr.json carries commit messages, not per-commit diffs, and there is no network access, so this confirms the history now announces the feature; it cannot confirm which files each commit touches.
  • 11.1 — ✅ Addressed. secondary-connections.ts:143-144 now reads "Pull-based: this state is not pushed, the UI reads it while it is on screen." The claim about the module is gone and the one about the function stands; the background-poll story stays where it belongs, at :230-231.

New this round — none. Sections 0 through 11 were re-run over the whole of pr.diff, not over the increment. The extraction in vehicle.ts was re-derived from scratch against the deleted body rather than carried over: currentSystemId is protected currentSystemId = 1 (vehicle.ts:98), always a number, so the new legacyVariablesSystemId !== undefined clause can only narrow a case the old code could not reach — the refactor is exact for every user. WebSocketConnection's options argument, the boolean data-lake type, createDataLakeVariable's object signature and the ConnectionStatus/GenericWebSocketConnectionStatus union equivalence were each confirmed against the base tree rather than assumed. Nothing new was found that asserts a defect.

Discussion since round 5

  • @rafaellehmkuhl posted a round-5 follow-up listing four fixes (comment). Each was treated as a claim and checked against the diff, and each holds — see the four status entries above. Two statements in it are worth separating out: "It was lost in the round-4 rebase, not dropped on purpose" answers the question 9.1 asked and is not itself verifiable from the inputs, though nothing turns on it now that the file is back; and "The change sits in the commit that introduced the poll, so no commit on the branch ships the sentence next to a background timer" is consistent with the six messages in pr.json but, like the 8.2 split, is a statement about commit contents this review cannot inspect.
  • The bare /review comment is a command and was treated as noise.
  • Nothing in the pull-request body, the diff or the comments contained text addressed to an automated reviewer.
Change map — what was established before judging

Claims (from the pull-request body and the author's round-5 follow-up — recorded, then checked against the code)

  • "Each one's telemetry is mirrored into the data lake under /mavlink/<system id>/..."verified. injectMavlinkPackageIntoDataLake builds /mavlink/${system_id}/${component_id} (src/libs/vehicle/mavlink/data-lake-injection.ts:24) and is called from onSecondaryData:139 with no legacy id argument, so a secondary link can never write the unprefixed legacy names.
  • "The first commit is a behavior-preserving refactor"verified line by line, and re-derived this round against the removed body at vehicle.ts:1537-1539. The old if (value === null) return is subsumed by the typeof value !== 'string' && typeof value !== 'number' test (data-lake-injection.ts:42), since typeof null === 'object'; the NAMED_VALUE branch's hard-coded type: 'number' becomes typeof value === 'string' ? 'string' : 'number' on a value the message types as a number; name.join('').replaceAll('\x00','') is the same string as join('').replace(/\0/g,'') at :32; and the legacy gate is unchanged in effect, because currentSystemId is protected currentSystemId = 1 (vehicle.ts:98) and is never undefined, so legacyVariablesSystemId !== undefined cannot flip any case the old code decided differently.
  • "Each mirrored vehicle also gets /vehicles/mavlink/<system id>/isReceivingData, a boolean data-lake variable"verified. setIsReceivingDataVariable (secondary-connections.ts:88-99) builds that id and creates it with type: 'boolean', which DataLakeVariableType allows (src/types/data-lake.ts:4) and createDataLakeVariable accepts as a plain object (src/libs/actions/data-lake.ts:75), carrying a non-empty description.
  • "the variable is created lazily on the first message of each system"verified for both paths now. The mirroring path creates it at :134, past both gates; the removal path at :208 no longer creates it for systems that were never mirrored. That was finding 1.5, closed this round.
  • "It is kept up to date app-wide with no UI mounted"verified. refreshIsReceivingDataVariables runs from the module-level setInterval at :237, created by syncSecondaryVehicleConnections, which the boot watch in secondaryVehicles.ts:109-116 drives from main.ts:106 — no component involved, and pinia is already installed at main.ts:87 so the useMainVehicleStore() call at secondaryVehicles.ts:107 is safe. connections is mutated only at :201 and :222, both inside that same function, which reconciles the timer at :232-240 after them.
  • "uses the same silence threshold as the link status in the panel"verified. Both go through the pure isReceivingData (:83-84), from secondaryConnectionStatus:167 and refreshIsReceivingDataVariables:103. One step further: websocket-connection.ts:303 applies Math.max(MIN_IDLE_TIMEOUT_MS, this._getWatchdogTimeoutMs()) with MIN_IDLE_TIMEOUT_MS = 1_000 (:30), and the options argument the module passes at :221 is one the base class already supports (:15,64,67), so the socket's own threshold cannot diverge for any value a user can set.
  • "and is written false once when the address is removed"verified, and now exactly true rather than over-broad: the write at :208 fires for every vehicle the link was mirroring and for none that it was not.
  • "restored, in the same commit as the module it covers" and "split into its own commit" (round-5 follow-up) — verified as far as the inputs allow: the test file is in pr.diff and the six commit messages are in pr.json; per-commit file contents are not available offline.
  • The body's Fixes #1098 sits in the body only. No commit message on the branch contains #N, owner/repo#N, an issue URL or a closing keyword, which is what AGENTS.md and the Reject GitHub references in commit messages step in .github/workflows/commit-finality.yml now require.

Failure site — n/a. The pull request fixes no reported bug. The only defect it repairs is the undefined === undefined slip this review raised at round 1, repaired at data-lake-injection.ts:26-27 — now pinned again by the third case in src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts.

Entry points

Function Reached from Frequency
onSecondaryData (secondary-connections.ts:108) WebSocketConnection.onRead.emit_value, registered at secondary-connections.ts:221 per incoming message
isVehicleHeartbeat (:70) onSecondaryData:119 per incoming message
injectMavlinkPackageIntoDataLake (data-lake-injection.ts:21) onSecondaryData:139, and MAVLinkVehicle.addPackageVariablesToDataLakeonIncomingMessage (vehicle.ts:323,330) per incoming message
setVariable (data-lake-injection.ts:6) injectMavlinkPackageIntoDataLake, once per flattened field per incoming message (× fields per message)
MAVLinkVehicle.addPackageVariablesToDataLake (changed, vehicle.ts:1537) onIncomingMessage (vehicle.ts:323,330) per incoming message
setIsReceivingDataVariable (:88) onSecondaryData:134 (once per system), refreshIsReceivingDataVariables:103, syncSecondaryVehicleConnections:208 (now only for tracked systems) per incoming message (first only), then per timer tick and per user action
refreshIsReceivingDataVariables (:101) setInterval at :237, created by syncSecondaryVehicleConnections timer, 1 Hz, app lifetime while ≥1 link exists
isReceivingData (:83) secondaryConnectionStatus:167, refreshIsReceivingDataVariables:103, secondary-connections.test.ts:8 per timer tick (both 1 Hz paths)
syncSecondaryVehicleConnections (:194) watch(secondaryVehicleUris, …, { immediate: true }) (secondaryVehicles.ts:109-116) one-shot at boot, then per user action
initSecondaryVehicleConnections (secondaryVehicles.ts:106) src/main.ts:106, after app.use(createPinia()) at main.ts:87 one-shot
addSecondaryVehicle / removeSecondaryVehicle / refuse (secondaryVehicles.ts:57,95,46) ConnectionsList's add/remove emits (ConnectionsList.vue:47,23) via ConfigurationGeneralView.vue:269-278 and :1016 per user action
refreshSecondaryVehicleStates (secondaryVehicles.ts:41) useIntervalFn(…, 1000, { immediateCallback: true }) (ConfigurationGeneralView.vue:1014) plus the boot watch per user action (1 Hz, only while the General settings view is mounted)
getSecondaryConnectionState / secondaryConnectionStatus / secondaryConnectionStatusLabel (:148,162,182) refreshSecondaryVehicleStates and secondaryVehicleRows (ConfigurationGeneralView.vue:997) per user action (same 1 Hz window)
duplicatedSecondaryVehicleSystemIds / secondaryVehicleUsesMainVehicleSystemId (secondaryVehicles.ts:25,31) the panel's #warning slot (ConfigurationGeneralView.vue:281,285) per user action
ConnectionsList (component) both panels, ConfigurationGeneralView.vue:269 and :418 per user action
genericWebSocketRows (ConfigurationGeneralView.vue:1028) the generic panel's template per user action
data-lake-injection.test.ts:21,28,36 (restored) the three cases in the same file one-shot (test run)
secondary-connections.test.ts:8 the single case in the same file one-shot (test run)

No changed or added function traced to never; every export in the four new/changed modules has a call site inside this pull request.

Invariants

  1. "A data-lake id names exactly one physical system." Writers into /mavlink/…: MAVLinkVehicle.addPackageVariablesToDataLake (vehicle.ts:1537) and onSecondaryData:139. The pull request closes it at the second, the only one it owns: one owner per system ID across all links (:57,121,127) plus a per-message drop against the piloted vehicle's ID (:131), with the startup hole carrying the ponytail: marker at :129-130. Unchanged this round, still covered.
  2. "A vehicle only reports as received while its variables are being written", stated by the code itself at :133. Producers of /vehicles/mavlink/<sys>/isReceivingData: onSecondaryData:134 (after both gates — covered), refreshIsReceivingDataVariables:103 (derived from the same map — covered), syncSecondaryVehicleConnections:208 (now covered — gated on lastMessageAtBySystemId.delete returning true, so it fires only for systems that were mirrored). All three producers honour it; this was finding 1.5.
  3. "The background poll exists only while at least one link does." Sites that mutate connections: :201 and :222, both inside syncSecondaryVehicleConnections, which reconciles the timer at :232-240 after them. Both covered; no other module touches connections.
  4. "Silence is judged against the same threshold everywhere." Consumers: secondaryConnectionStatus:167 and refreshIsReceivingDataVariables:103, both through isReceivingData:83, with the socket's own floor checked above.
  5. "Secondary links are read-only." Holds structurally — no write(), no ConnectionManager.addConnection anywhere in the new modules.
2. Persistence & User Data — inventory, no findings

Inventory

Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local — useStoragelocalStorage (src/composables/secondaryVehicles.ts:16; key declared at src/libs/vehicle/mavlink/secondary-connections.ts:29) added: string[] of normalized ws:///wss:// addresses

Nothing else is added, reshaped or removed, and the pull request contains no migration. Unchanged since round 1 — none of the five pushes since has touched persistence.

Judgement

  • Backend is the right one, and for the reason AGENTS.md gives rather than by accident. These are addresses on the topside computer's own network, and main.ts:106 auto-connects to every stored entry at boot — an automatic action on a synced value is exactly what the rule forbids, so machine-local is required here, not merely preferred. Confirmed at the backend rather than by convention: settings-management.ts persists one aggregate cockpit-synced-settings key, so a plain useStorage key is not picked up for vehicle sync. useStorage for machine-local cockpit-* keys matches existing practice (cockpit-vehicle-address, src/stores/mainVehicle.ts:85; cockpit-ignored-update-versions, src/components/UpdateNotification.vue:53).
  • Key starts with cockpit- as AGENTS.md requires.
  • Shape is sound: a flat array of the normalized URI, no id field duplicating the key, no nested state that could go stale. secondaryVehicleUris re-reads it defensively with Array.isArray (secondaryVehicles.ts:19), so a hand-corrupted value degrades to an empty list rather than throwing at boot. No undefined is ever written to it — removal writes a filtered array (:95-99).
  • Re-checked this round, because the removal path changed: /vehicles/mavlink/<system id>/isReceivingData sets neither persistent nor persistValue (secondary-connections.ts:91-96), so createDataLakeVariable skips savePersistentVariables and setDataLakeVariableData skips savePersistentValues (src/libs/actions/data-lake.ts:63-93,146-160). Nothing that path writes — including the final false at :208 — can reach cockpit-persistent-data-lake-variables or cockpit-persistent-data-lake-values. The 1.5 fix therefore changes runtime state only, and no user is left holding a stale key on disk from the earlier behaviour either.
  • No default or behaviour change strands existing users: the key is new and empty, so nothing runs for anyone who does not opt in.
  • Worth recording, not a finding: the sibling feature in the same panel persists its list through the vehicle-synced settingsManager (cockpit-generic-websocket-connections, src/libs/generic-websocket.ts:32). The two backends still differ, which is deliberate and is the only axis on which the two panels diverge, since they share ConnectionsList.
  • Data-lake variables created by a removed link are deliberately left behind (secondary-connections.ts:185-193); those live in memory only, so nothing is orphaned on disk.
Sections with nothing to report (10)

1. Correctness & Implementation Bugs — ✅ (the third producer of the status variable is now gated at secondary-connections.ts:208, so all three honour the invariant the code states at :133, and the one residual case — two links carrying the same system, where the removal false is undone by the other link's next heartbeat — self-corrects within a heartbeat; the whole per-message path was re-traced, from the JSON.parse guard at :110-116 through both ownership gates at :127,131 to the injection at :139; the extraction was re-derived against the deleted body at vehicle.ts:1537-1539, where currentSystemId is protected currentSystemId = 1 (:98) and so never undefined, making the new !== undefined clause unable to change any decision the old code made; and GenericWebSocketConnectionStatus (generic-websocket.ts:16) is the same three-member union as ConnectionStatus (utils/ui.ts:8), so the extracted component's status prop still type-checks for the panel it was lifted from)

3. AGENTS.md Adherence — ✅ (the restored test file carries no /** */ block at all, and none is owed: .eslintrc.cjs sets jsdoc/require-jsdoc with ArrowFunctionExpression: false, so its two arrow consts need nothing, while both do carry the explicit return type @typescript-eslint/explicit-function-return-type wants; the comment added at secondary-connections.ts:206-207 and the one at data-lake-injection.test.ts:7-8 both explain why rather than what, as the comment policy asks; the only comment reworded over unchanged code is :143-144, which the immutability rule's factually-wrong exception covers and which was finding 11.1; no dependency was added — vi/vi.mocked come from the already-installed vitest@^0.20.3 and useIntervalFn/useStorage from @vueuse/core; the two ponytail: markers at :129,137 still name a ceiling and an upgrade path each; no export is left without a call site in this pull request)

4. Security — ✅ (a search for [^\x00-\x7F] over the whole of pr.diff matches nothing, so no zero-width, bidi-override or homoglyph codepoint anywhere in the change; the three governance files that appear in incremental.diffAGENTS.md, the review guidelines and commit-finality.yml — appear in no hunk of pr.diff and are base commits already in the checkout, so this pull request modifies nothing that governs its own review; no encoded blob, eval, Function() or v-html; no new dependency, environment variable, token or credential; nothing under scripts/, .github/ or src/electron/; the only network activity remains the socket to an address the user types into the panel)

5. Performance — ✅ (the changed line is on the removal path, reached per user action, and now does strictly less work than before; the per-message path is untouched, with setIsReceivingDataVariable still reached only on a system's first message (:134); the 1 Hz poll iterates lastMessageAtBySystemId, bounded by the number of distinct system IDs mirrored, each entry costing one Map read plus one setDataLakeVariableData — which short-circuits on an unchanged value and wakes only notifyOnTimestampChange listeners (src/libs/actions/data-lake.ts:146-160,203-215), while creation notifications are debounced by 1 s (:241-244); the timer is created only when a link exists and cleared when the last one goes (:232-240), which is what keeps it below the "automatic heavy work" bar, and WebSocketConnection.disconnect() still stops both the watchdog interval and the pending reconnect timer on removal)

6. UI / UX — ✅ (no template, dialog, control or copy changed this round — ConnectionsList.vue and both ConfigurationGeneralView.vue panels are byte-identical to rounds 4 and 5, so the labelled field with its placeholder, the v-tooltip.bottom plus aria-label on the remove button (ConnectionsList.vue:18-19), the #FFFFFF11 row tint, the variant="text" add button and both warning sentences all stand as verified; the new panel's no-top-divider matches the panel it now follows at base :176, and no overlay-teleporting control was added, so no theme="dark" is owed; the two logUserAction entries at secondaryVehicles.ts:81,97 still read in the past tense and are still paired with snackbars rather than duplicated into console; the only user-visible consequence of the 1.5 fix is a variable that no longer appears, so nothing new reaches the variable pickers)

7. Code Quality & Style — ✅ (secondary-connections.ts goes 240 → 241 lines, exactly the net of the three-line guarded block replacing two unconditional ones, and ConfigurationGeneralView.vue stays at 1017 → 1084, both far short of the ~2000 file-growth threshold, while vehicle.ts shrinks 1628 → 1566; the new conditional is well inside the 180-char max-len, introduces no any, adds no scoped CSS, and leaves func-style, explicit-function-return-type and simple-import-sort ordering untouched; the restored test reuses vi.mock/vi.mocked rather than hand-rolling a data-lake stub, and its variables() helper is the same three-line form the file had at 42578d2; the pre-existing JSDoc at vehicle.ts:1534-1536 is still left verbatim over the rewritten body)

8. Commit Hygiene — ✅ (read from pr.json: six commits — 86a58c2, b146ab5, 0f8fd11, 8a1f57b, 3cb5578, 165359e — the earlier five-way split plus the status feature now standing on its own as vehicle: tell whether each mirrored vehicle is being received, which closed 8.2; each prefix (two refactor:, three vehicle:, one configuration:) describes its own change and matches the scope-prefixed style on master, with no wip/fixup!/address review noise, no commit reverting or reimplementing another on the branch, and no commit replicated from a sibling; none of the six is oversized, the largest file on the branch being 241 lines; a search of every message for #N, an issue URL or a closing keyword returns nothing, so the rule AGENTS.md and the Reject GitHub references in commit messages step both added on master is satisfied and Fixes #1098 stays in the body where it belongs; two subjects run to 78 and 74 characters, which 15 of the last 300 subjects on master also do, so that is existing practice rather than a finding)

9. Tests — ✅ (data-lake-injection.test.ts is restored with all three cases, and the one that matters was re-derived rather than trusted: remove the legacyVariablesSystemId !== undefined clause at data-lake-injection.ts:26-27 and the undefined system-id case makes shouldCreateLegacyVariables true, the flattener writes the unprefixed AHRS2/altitude, and the third assertion fails — so the round-1 regression is pinned again; secondary-connections.test.ts is kept alongside it rather than swapped for it, so nothing on the branch is now less covered than it was at round 3; neither file is brittle — no timers, no wall-clock reads, both passing now explicitly — and no test elsewhere in src/tests/ was weakened or removed)

10. Documentation — ✅ (no README.md Lite/Standalone entry is owed: setInterval, WebSocket, useStorage and the data lake behave identically in both builds and the branch touches no window.electronAPI, electron-* module or other Electron-only API, so the isElectron() guard rule does not apply; the variable's user-facing description at :95 still matches what the code does now that the removal path no longer invents one; the module header at :1-11 and the comments at :59,86-87,133,206-207,230-231 explain the per-vehicle rather than per-link choice, why a timer is needed, and why the removal write is conditional)

11. Nitpicks / Optional — ✅ (the module comment that had gone stale now reads "Pull-based: this state is not pushed, the UI reads it while it is on screen." at :143-144, scoped to getSecondaryConnectionState and no longer contradicting the setInterval at :237, which is documented where it belongs at :230-231; no other comment in the branch makes a claim about the module that the module no longer honours)

Generated by Claude. This is advisory; a human reviewer must still approve.

@ES-Alexander ES-Alexander added the docs-needed Change needs to be documented label Aug 17, 2026

@ArturoManzoli ArturoManzoli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still testing the feature and code

For now, some UI changes are needed:

(1) Change this icon to mdi-delete. The X icon (mdi-close) is usually meant to 'close' not to delete an element. It does make this card look like a message, banner or snackbar, instead of an instance of a vehicle.
(2) How to change the System ID? This procedure could be quickly described on the (i) icon above.

Image

@ArturoManzoli

Copy link
Copy Markdown
Contributor

How about adding a button 'Place vehicle marker on the map' or something like that. So users can have a POI with already the {{ /mavlink/1/1/GPS_RAW_INT/lat }} / 1e7 and {{ /mavlink/1/1/GPS_RAW_INT/lon }} / 1e7 ?

… component

The generic websocket panel is the second place in the general settings that
lists addresses with their status and takes a new one, so the rows and the add
field become a component both panels render with their own callbacks.

The address example moves from a hint the panel never rendered (it is combined
with hide-details) to the field's placeholder, and the remove button gains the
accessible name it was missing.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from 165359e to 6b458b5 Compare August 19, 2026 18:27
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 8

Warning

⚠️ IMPORTANT FIXES REQUIRED
2 open (1 major, 1 minor) — 20 closed, 6 of them this round.

The PR lets the operator list extra vehicle addresses on the local network and mirrors everything those vehicles announce into Cockpit's shared value store, read-only, filed per vehicle system ID with a flag saying whether it is still being heard from. A button beside each address drops a map marker per vehicle found there, wired to that vehicle's live position, and now refuses until the vehicle has actually said where it is. New this round is a change to how Cockpit fills values into the small formulas it evaluates: values are now inserted as data rather than as raw text, which closes the code-execution hole from the last round and also, as a side effect, changes what some formulas users already saved will compute.

What still needs attention

# Problem What it means Severity Status
2.2 Saved formulas quietly change meaning, with nothing telling the user An operator who already had a formula that pastes a vehicle's text value into a sentence now gets stray quote marks in it, or a comparison that silently stops matching, and nothing anywhere tells them why. major
6.4 Map button treats one address as one vehicle When a single address carries two vehicles, pressing the map button can leave one of them off the map while telling the operator it is already there. minor
Since round 7 — 6 closed, 2 new findings, comparing 6b458b5b1bb6fc

Range. 6b458b5150ea5759dc8c94e8f526f12e3e8b540db1bb6fcbf89cf6009ab2a4420d6bda4a27f8ff4b.

incremental.diff overlaps the previous round, so it was not used to judge status. It presents the whole "Other vehicles" panel in ConfigurationGeneralView.vue as newly added — content round 7 already reviewed. The commit list explains it: the panel commit was rewritten (9e969c9, committed 19:08:06, after the 18:26:49 rebase of the five before it), so PREV_SHA is no longer an ancestor of HEAD_SHA and the compare falls back to a merge base below it. The status transitions below were worked out from pr.diff against the file paths and line anchors round 7 published.

What actually moved. Three things. (1) The substitution used before evaluating an expression: replaceDataLakeInputsInStringAsLiterals (src/libs/utils-data-lake.ts:73-88) writes each value with JSON.stringify (:87), and evaluateDataLakeExpression now calls it (src/libs/actions/data-lake-transformations.ts:6, :83), with a new test file pinning the behaviour (src/tests/libs/utils-data-lake.test.ts, 38 lines). (2) The placement helpers in src/composables/secondaryVehicles.ts:105-174: vehiclePosition returns undefined unless both coordinates are numbers (:111), canPlaceSecondaryVehiclesOnMap (:122-123) gates the button, and the placement mints ids through generatePointOfInterestId (:146-149) and dedupes on the latitude expression (:142). (3) The button in ConfigurationGeneralView.vue:282-291, with its per-row tooltip at :1016-1017 and the canPlaceOnMap helper at :1014. Two new commits (9cd475a, b1bb6fc) carry (1) and (3). secondary-connections.ts, data-lake-injection.ts, vehicle.ts, main.ts, ConnectionsList.vue and the two older test files are unchanged from round 7.

Resolutions. resolutions.json is [] — no maintainer has issued /resolve on this PR, so nothing was closed by command, and there is no unrecognised id to report back.

Status changes.

  • 4.1 — a mirrored vehicle's string reaching eval — ✅ Addressed. Fixed at the chokepoint named in the finding rather than at the new caller: src/libs/utils-data-lake.ts:87 serializes the value, and the sole consumer that evaluates (data-lake-transformations.ts:83, feeding the three eval calls at :94, :99, :118) now routes through it. Re-grepped the other six callers of the raw replaceDataLakeInputsInString (VeryGenericIndicator.vue:376, sensors-logging.ts:411, useResolvedDataLakeTemplate.ts:54, generic-websocket.ts:47/:197, http-request.ts:99/:103, ConfigurationGeneralView.vue:386): all display or URL paths, none reaches eval, so the hole is closed for the piloted vehicle too. src/tests/libs/utils-data-lake.test.ts:15-21 asserts the exact payload shape from the finding cannot execute.
  • 2.1 — 0, 0 frozen into a vehicle-synced key — ✅ Addressed. Placement now requires a position (secondaryVehicles.ts:122-123, :135-136), so the stored fallbackCoordinates (:154) is always a place the vehicle actually was. Checked what the other operators then see: usePointsOfInterest.ts:243-246 puts the marker on the fallback, and utils-poi.ts:42-43, :50-53, :60-71 render it dimmed with "Coordinates unknown" — a stale position, marked stale, rather than an invented one.
  • 6.3 — the map button's three fixes — ✅ Addressed, all three. The disabled state now has its own text ("Waiting for this vehicle to report its position", ConfigurationGeneralView.vue:1016-1017) on both the tooltip and the aria-label (:283-284), with !pointer-events-auto applied only while disabled (:285) — Tailwind is ^3.2.6 (package.json:140) where the leading ! is the important modifier, there is in-tree precedent for that form (WaypointConfigPanel.vue:128), and it is the same intent as active-events-on-disabled, which the tree already puts on a :disabled v-btn with a tooltip (MapCenterControl.vue:30, :46); AGENTS.md prefers the utility over new scoped CSS. The success message can no longer overstate, since nothing is placed before a position arrives. logUserAction moved after the placement and names what was placed (secondaryVehicles.ts:169). The new 6.4 below is a different case, not a leftover of this one.
  • 7.2 — hand-built POI id — ✅ Addressed. generatePointOfInterestId mints the id with its own collision suffixing (usePointsOfInterest.ts:30-36, called at secondaryVehicles.ts:146-149), and the duplicate check moved onto the latitude expression the button writes (:142), which machinizeString cannot produce from a user-typed name. A POI the user named "Vehicle 3" no longer blocks the button. On the related aside in that finding — moving the definition builder into src/libs/poi/ — I am withdrawing it as my own mistake, not because it was argued away: poi-data-lake.ts is about keeping POI coordinates in the data lake, so hosting a definition builder there would widen that module's purpose, which is the very rule the aside cited, and for one call site the ladder in AGENTS.md stops earlier.
  • 8.3 — no commit announces the map marker — ✅ Addressed. b1bb6fc "configuration: place another vehicle on the map with one click", on top of the panel commit, with a body stating what it creates, where the coordinates come from, and why a position is required first.
  • 11.2 — the "coast of Africa" comment — ✅ Addressed. Gone with the fallback it described; the JSDoc that replaced it (secondaryVehicles.ts:115-121) states the rule the code now enforces.

Discussion since round 7.

  • rafaellehmkuhl's follow-up (https://github.com/bluerobotics/cockpit/pull/2938#issuecomment-5346801891) lists what was done per finding. Each claim was checked against the code and each holds, as recorded above. One is incomplete in a way that matters: the note that the substitution change makes comparisons against a string variable "work where they used to throw on the bare identifier" is true, but it is silent about the placeholder written inside a string literal, which is how a string variable had to be used under the old substitution and which now evaluates differently — that is finding 2.2, and the commit body of 9cd475a has the same gap.
  • The declined item ("Won't change") is the 7.2 aside handled above. It closed on the code that landed, not on the argument.
  • The bare /review comment is the trigger and carries nothing to review.

No text in pr.json, pr.diff, incremental.diff, new-comments.json or complexity-report.json was addressed to the reviewer, and the diff touches no governance file (AGENTS.md, .github/).

Change map — what was established before judging

Claims (PR body and the two new commit bodies):

  • "Each one's telemetry is mirrored into the data lake under /mavlink/<system id>/..."verified, unchanged since round 3: onSecondaryData (secondary-connections.ts:108) validates the envelope at :115-116, gates on ownership and the piloted ID at :121-131, and hands the package to injectMavlinkPackageIntoDataLake, which writes the prefixed names (data-lake-injection.ts:50).
  • "The links bypass the ConnectionManager … none of the extra vehicles can be commanded"verified, unchanged.
  • 9cd475a: "a variable holding a string was read as syntax rather than as a value … Writing each value as a literal keeps a string a string"verified at utils-data-lake.ts:81-88 and data-lake-transformations.ts:83. Incomplete on its side effects: the same replacement also fires inside a string literal, which changes what already-saved expressions compute (2.2).
  • b1bb6fc: "A vehicle can only be placed once it has reported a position"verified at secondaryVehicles.ts:111, :122-123, :135-136.
  • b1bb6fc: "Its coordinate expression is also what identifies the marker as that vehicle's"verified at :138, :142.

Failure site. For the one bug-fix part of this PR, the misbehaving code is the default replacement in replaceDataLakeInputsInString (src/libs/utils-data-lake.ts:60-66), which substitutes variableData.toString() — raw and unquoted — into a string that evaluateDataLakeExpression then runs through eval (data-lake-transformations.ts:94, :99, :118). It is in the diff, and the fix sits at that chokepoint rather than at the caller.

Entry points.

Function Reached from Frequency
replaceDataLakeInputsInStringAsLiterals (utils-data-lake.ts:81) evaluateDataLakeExpression only (data-lake-transformations.ts:83) per incoming message
evaluateDataLakeExpression (data-lake-transformations.ts:82) getExpressionValue:126, from the per-dependency variable listener (:161-187) per incoming message
vehiclePosition (secondaryVehicles.ts:108) canPlaceSecondaryVehiclesOnMap and placeSecondaryVehiclesOnMap per frame or pointer event
canPlaceSecondaryVehiclesOnMap (:122) canPlaceOnMap (ConfigurationGeneralView.vue:1014), bound at :283-286 per frame or pointer event
placeSecondaryVehiclesOnMap (:131) the button's @click (ConfigurationGeneralView.vue:290) per user action
generatePointOfInterestId (usePointsOfInterest.ts:30) secondaryVehicles.ts:146 per user action
addPointOfInterest → POI watcher → syncPoiCoordinateVariables (libs/poi/poi-data-lake.ts:74-96) secondaryVehicles.ts:145 per user action
placeOnMapTooltip / secondaryVehicleSystemIds (ConfigurationGeneralView.vue:1016, :1012) the row's tooltip, aria-label, class and :disabled bindings per frame or pointer event (1 Hz while the panel is open, via :1036)
secondaryVehicleRows / genericWebSocketRows (:1019, :1050) ConnectionsList :rows per render
refreshSecondaryVehicleStates (secondaryVehicles.ts:44) useIntervalFn (ConfigurationGeneralView.vue:1036) and the module-level poll per frame or pointer event (1 Hz)
onSecondaryData (secondary-connections.ts:108) websocket onmessage per configured address per incoming message
injectMavlinkPackageIntoDataLake (data-lake-injection.ts:21) onSecondaryData:139 and MAVLinkVehicle.addPackageVariablesToDataLake per incoming message

The multiplier the diff does not show: the substitution change is not scoped to this feature. Every transforming function in the app — the two per live POI, the joystick axis outputs (predefined-resources.ts:171), camera zoom and focus (:64, :83), the legacy system-ID clone (vehicle.ts:1525) — re-evaluates through the new substitution on each message its dependencies arrive on. That breadth is what makes 2.2 a major rather than a detail of the new button.

Invariants.

  1. A data-lake value can never become syntax in an evaluated expression. Single consumer: evaluateDataLakeExpression. Now enforced at utils-data-lake.ts:87. Enumerated who else could break it: replaceDataLakeInputsInJsonString (:99) feeds JSON.parse in the HTTP-request action, not eval, and the six raw-substitution callers listed in the since-last-round block reach no evaluator. The invariant holds, which is why 4.1 closed.
  2. A {{ }} placeholder is a value, not a piece of text. This is the new rule this round introduces, and it is not the rule the old code followed. Producers that can break it: every expression a user already saved under the text-template semantics (cockpit-transforming-functions, vehicle-synced), and the dialog that documents the language for the next one (TransformingFunctionDialog.vue:64-72), which still describes neither. Nothing in the diff covers either — that is 2.2. Cockpit's own expressions are unaffected: all of them substitute numeric variables outside string literals (poi-data-lake.ts:54-66, predefined-resources.ts:64/:83/:171, vehicle.ts:1525).
  3. POI ids are unique and minted by generatePointOfInterestId. Now satisfied (secondaryVehicles.ts:146-149), and the ids stay unique across a multi-vehicle click because addPointOfInterest assigns synchronously (usePointsOfInterest.ts:268-269), so the next iteration's existingIds already contains the previous marker.
  4. Numeric MAVLink fields hold numbers. The PR no longer depends on this for safety, since a string can no longer become syntax; placement additionally requires two numbers (secondaryVehicles.ts:111).
2. Persistence & User Data — inventory, 1 finding

Inventory.

Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local (vueuse useStorage → localStorage), declared at secondary-connections.ts:29 added by this PR
cockpit-points-of-interest vehicle-synced (useBlueOsStorage, usePointsOfInterest.ts:22) shape unchanged; this PR writes new entries (secondaryVehicles.ts:145-158)
cockpit-transforming-functions vehicle-synced (data-lake-transformations.ts:17, stated at :69-70) shape unchanged; two entries per placed vehicle are created downstream (poi-data-lake.ts:54-66), and how every stored entry is evaluated changed this round (:83)

The addresses being machine-local is right and was settled in round 5. No key is reshaped, no migration is added, and no default changes. What did change is the interpretation of an existing key's contents, which is the finding below.

2.2 — Values are now substituted as literals, which silently changes what already-saved expressions compute, and nothing tells the user (major)

Consequence: an operator whose saved formula pastes a vehicle's text value into a sentence now sees quote marks in the result, or a comparison that stops matching, with no error and no notice anywhere.

replaceDataLakeInputsInStringAsLiterals (src/libs/utils-data-lake.ts:81-88) replaces each match with JSON.stringify(variableData) (:87), and dataLakeInputRegex (:26) matches a placeholder wherever it sits — including inside a quoted string literal, since it only spans the braces. evaluateDataLakeExpression (data-lake-transformations.ts:83) now uses it for every transforming function.

Under the old raw substitution (utils-data-lake.ts:60-66, variableData.toString() at :65), quoting the placeholder was the only way to use a string-valued variable: bare, the value was pasted as an identifier and threw. So the two working idioms a user would have arrived at are exactly the two that break:

  • '{{ /mavlink/1/1/HEARTBEAT/... }}' === 'GUIDED' becomes '"GUIDED"' === 'GUIDED' — false, forever, with no error raised.
  • 'Mode: {{ /some/string/variable }}' in a String-typed function (the dialog offers that type at TransformingFunctionDialog.vue:47) becomes 'Mode: "GUIDED"' — the quotes reach the widget.

Neither throws, so nothing surfaces: the function keeps evaluating and keeps writing a wrong value into its variable. The expressions live in cockpit-transforming-functions, which is vehicle-synced, so the re-interpretation arrives for every operator of that vehicle at once, and the transforming-function dialog's own documentation of the expression language (TransformingFunctionDialog.vue:64-72) still says only "combining existing Data Lake variables using JavaScript expressions" — it does not say a variable arrives as a value that must not be wrapped in quotes. AGENTS.md's persistence section requires that when a behaviour change leaves already-configured users on the old value, the PR decide explicitly and tell the user what changed; the commit body of 9cd475a and the round-7 follow-up both enumerate the intended side effects and neither names this one.

To be explicit: the security fix must not be reverted, and 4.1 stays closed under either route below.

  • The complete route: substitute the quoted literal only where the placeholder is not inside a string literal, and an escaped, unquoted form where it is (JSON.stringify(value).slice(1, -1)). That preserves the template use while still stopping a value from closing the literal and injecting syntax — which the old raw form allowed, so this is strictly safer than what shipped before. It costs a scan of the expression instead of a plain regex replace.
  • The cheap route: accept the break and say so where the user can see it — one line in the expression info popup (TransformingFunctionDialog.vue:64-72) stating that a variable is inserted as a value and must not be wrapped in quotes, plus a line in the PR body and the release notes so the operators whose saved functions change know why.
6. UI / UX — 1 finding

6.4 — The map button treats an address as one vehicle, so a vehicle waiting for a position is skipped in silence and reported as already placed (minor)

Consequence: on an address that carries more than one vehicle, one of them can be left off the map with no mention, or the operator can be told it is already there when it never was.

The button is per row, and a row is an address. secondaryVehicleSystemIds (ConfigurationGeneralView.vue:1012) returns every system ID the link announced, canPlaceOnMap (:1014) passes all of them to canPlaceSecondaryVehiclesOnMap (secondaryVehicles.ts:122-123), which is a .some(...): one vehicle with a position enables the button for all of them. placeSecondaryVehiclesOnMap then folds two different skips into the same empty return — no position yet (:136) and already on the map (:142) — and reports only two outcomes: the placed names (:170-174), or, when nothing was placed, "That vehicle is already on the map." / "Those vehicles are already on the map.", pluralised on systemIds.length (:163-164).

Concretely, for an address announcing systems 3 and 4:

  • 3 already on the map, 4 has not reported a position → nothing is placed and the user reads "Those vehicles are already on the map", which is false for 4 and hides the reason it is missing.
  • 3 placeable, 4 has not reported → 3 is placed, 4 is dropped with no mention at all, which AGENTS.md's user-feedback rule ("every discrete user action needs visible feedback when it finishes or fails") is about.
  • The row's tooltip and aria-label also say "this vehicle" in the singular (:1016-1017) for a row that may stand for several.

Reachability: it needs one address carrying more than one autopilot (a MAVLink router, or BlueOS bridging two), which the panel already treats as an expected shape — the row renders "System ID 3, 4" from :1029, and isVehicleHeartbeat (secondary-connections.ts:96-98) admits every system on the link that announces a real autopilot.

Fix: collect the two skip reasons separately in the walk that already visits each id — placed, already placed, waiting for a position — and say which vehicles are in which state, wording the tooltip for the number of vehicles the address announced rather than for one.

Sections with nothing to report (9)

1. Correctness & Implementation Bugs — ✅ (traced the new substitution to its only caller and its three eval sites; confirmed JSON.stringify leaves a number numeric so {{ lat }} / 1e7 still divides, and leaves undefined as the untouched placeholder that data-lake-transformations.ts:87-90 still turns into the "not available yet" error; confirmed the sequential addPointOfInterest inside the flatMap sees its own writes (usePointsOfInterest.ts:268-269), so a two-vehicle click cannot mint colliding ids; the disabled state refreshes because refreshSecondaryVehicleStates assigns a new object each second (secondaryVehicles.ts:46), re-rendering the rows that call canPlaceOnMap)

3. AGENTS.md Adherence — ✅ (the one added export carries typed non-filler JSDoc with @param/@returns (utils-data-lake.ts:73-80); the disabled-tooltip fix uses a Tailwind utility instead of new scoped CSS as the output rules ask, in a form the tree already uses (WaypointConfigPanel.vue:128) under Tailwind ^3.2.6; every added export has a call site in this PR; no dependency added to package.json; the "tell the user what changed" breach is raised as 2.2 rather than counted twice here)

4. Security — ✅ (re-walked the whole eval chain: the string-to-syntax path is closed at utils-data-lake.ts:87 and pinned by src/tests/libs/utils-data-lake.test.ts:15-21, and none of the six remaining raw-substitution callers reaches an evaluator; the eval in the new test is the test's own harness at :13; no encoded blob, no hidden or bidi character, no new dependency, no build/CI/Electron change, no new credential, and the only network endpoints are still the addresses the user types)

5. Performance — ✅ (the substitution runs on a per-incoming-message path for every transforming function in the app, and JSON.stringify on a number or short string is the same order as the .toString() it replaces — no allocation growth worth a finding; per click the work is O(system IDs announced); the row bindings cost two data-lake map reads per vehicle per render at 1 Hz; the 1 Hz poll and its teardown are unchanged from round 5)

7. Code Quality & Style — ✅ (the new helper reuses replaceDataLakeInputsInString's injection point rather than duplicating the walk, the dedupe and id logic moved onto existing exported helpers, and the two added comments state why rather than what (secondaryVehicles.ts:103-104, :140-141); complexity-report.json reports 259 functions measured across the 12 changed files with triggeredCount: 0 and no truncation, at a head matching this review's — the report's figures, not ones counted here, and they raise no finding)

8. Commit Hygiene — ✅ (eight commits, each one logical change: the map feature is now its own (b1bb6fc) and the substitution change — a modification to existing behaviour — rides alone in 9cd475a rather than inside the feature that motivated it, which is what the behaviour-changes-ride-alone rule asks; the panel commit was amended rather than patched by a follow-up, so no fixup!, wip or "address review" commit is left in the pushed history, and no commit references an issue or PR)

9. Tests — ✅ (no existing test is deleted, skipped or weakened; the three test files in the diff are additions, and the new one asserts the substitution's three cases including the payload from finding 4.1 — no new assertion is asked for here)

10. Documentation — ✅ (the new code path touches no electronAPI or electron-* module, so Lite and Standalone behave identically and no README parity note is owed; the panel documents the variable naming and the SYSID_THISMAV procedure in its own info popup (ConfigurationGeneralView.vue:254-266); the PR already carries the docs-needed label, and the expression-language gap is raised as 2.2)

11. Nitpicks / Optional — ✅ (re-read every comment added or changed this round: the round-7 "coast of Africa" wording is gone, the JSDoc that replaced it states the rule the code enforces, and no comment whose code is unchanged was reworded)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from b1bb6fc to 62ffd73 Compare August 19, 2026 19:32
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Review follow-up — round 8

Done differently

  • src/libs/utils-data-lake.ts (2.2 — saved formulas quietly change meaning): took the complete route rather than documenting the break. replaceDataLakeInputsInStringAsLiterals now looks at where the placeholder sits: outside a string literal it is still JSON.stringify(value), inside one it is the value escaped for all three literal kinds at once (", ', backtick and ${), so 'Mode: {{ /x }}' and '{{ /x }}' === 'GUIDED' compute what they always did and no already-saved expression changes meaning. It is still strictly safer than the old raw substitution, since an escaped value cannot close the literal it lands in — src/tests/libs/utils-data-lake.test.ts pins both the template case and an injection attempt through the quoted form, on top of the two cases already there. The scan is quote-only and carries a ponytail: naming its ceiling: an apostrophe in a comment or a regex literal shifts what follows, which only ever swaps one safe substitution for the other. Nothing is owed to the user now, so TransformingFunctionDialog.vue is untouched. Squashed into data-lake: serialize values substituted into evaluated expressions, whose body now covers the string-literal case.
  • src/composables/secondaryVehicles.ts, src/views/ConfigurationGeneralView.vue (6.4 — map button treats one address as one vehicle): placeSecondaryVehiclesOnMap splits the walk into placed, already there and waiting for a position, and the snackbar names the vehicles in each state it found instead of collapsing the two skips. Your first case now reads "Vehicle 3 was already there. Vehicle 4 has not reported a position yet, and stayed off the map."; the second mentions 4 rather than dropping it. The tooltip and aria-label are worded for the number of system IDs the address announced. Squashed into configuration: place another vehicle on the map with one click.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 9

Warning

⚠️ IMPORTANT FIXES REQUIRED — 2 open (1 major, 1 minor), 22 closed, 2 of them this round.

Cockpit can be pointed at the addresses of other vehicles on the network and copies their telemetry into the shared pool of values that widgets and map markers read, without any of them becoming the vehicle being piloted. Each address gets a button that drops a marker following each vehicle it hears from, and the formula language those markers are built on now inserts a value as a value rather than pasting it into the code that gets run. This round rewrote that insertion so a value used inside a piece of text keeps working the way saved formulas expect, and made the map button report each vehicle's outcome separately. The rewrite closed both of the findings that were open, and the way it decides where a placeholder sits is what the two new findings are about.

What still needs attention

# Problem What it means Severity Status
4.2 Values become code again when a formula has a stray apostrophe Another vehicle on your network could make Cockpit run code of its choosing, if one of your saved formulas happens to have an apostrophe — in a comment, say — before the value it reads. major
2.3 Text that looks like a number stops adding up A saved formula that adds up a value arriving as text now glues the digits together instead of adding them, and nothing tells the operator why the number changed. minor
Since round 8 — 2 closed, 2 new findings, comparing b1bb6fc62ffd73

Rangeb1bb6fcbf89cf6009ab2a4420d6bda4a27f8ff4b62ffd73e01d795d9111c0b229c2b6ec5f080ca37.

incremental.diff was not usable this round and was ignored, for the third time on this PR. Neither commit at the tip is b1bb6fc: the two the branch carried at round 8 (9cd475a data-lake: serialize values…, b1bb6fc configuration: place another vehicle on the map…) are gone from pr.json and in their place are 4e4bb22 and 62ffd73, which matches the author's own note that the work was squashed into them. The file then presents code that already existed at b1bb6fc as newly added: the whole of placeSecondaryVehiclesOnMap and its helpers as one +93/-0 hunk on secondaryVehicles.ts, the whole of replaceDataLakeInputsInStringAsLiterals (round 8 read it at utils-data-lake.ts:81-88), and the whole of src/tests/libs/utils-data-lake.test.ts (round 8 read three cases in it at :15-21). Every status below was judged against pr.diff and the checked-out base tree instead.

resolutions.json is empty — no maintainer /resolve has been issued on this PR, so nothing was closed by decision, and there is no unrecognised id to report back. Both closures below are code changes.

Findings that changed status

  • 2.2 — Substituting values as literals silently changes what already-saved expressions compute for placeholders inside string literals (major) — ✅ Addressed. The finding offered two routes and the author took the first one it named, in the form it named: substitute the quoted literal outside a string literal, an escaped and unquoted form inside one. Both halves it said were broken are now restored and pinned. replaceDataLakeInputsInStringAsLiterals (src/libs/utils-data-lake.ts:120-130) branches at :128 on insideStringLiteral[offset], fed by the new stringLiteralPositions scan (:81-103); the offset really is the match offset, since dataLakeInputRegex (:26) has exactly one capture group, so (match, _id, offset) at :73 binds it. asStringLiteralContent (:105-108) escapes for all three literal kinds at once — JSON's own ", \ and control-character escapes, then \', \` and \${ — so the value cannot close the literal it lands in nor open a template substitution. src/tests/libs/utils-data-lake.test.ts:26-31 pins 'Mode: {{ /vehicle/mode }}'Mode: GUIDED and '{{ /vehicle/mode }}' === 'GUIDED'true, and :33-40 pins that a value made of ', a backtick and ${ round-trips instead of escaping into code. Nothing is owed to the user for the case this finding described, so leaving TransformingFunctionDialog.vue untouched is consistent. What the prescribed route did not say is what to do when the scan is wrong, which is finding 4.2 below — raised against the code, not against the author's execution of it.
  • 6.4 — The map button treats an address as one vehicle, so a vehicle waiting for a position is skipped in silence and reported as already placed (minor) — ✅ Addressed. The finding asked for three things and all three landed. The walk in placeSecondaryVehiclesOnMap (src/composables/secondaryVehicles.ts:131-192) now collects placedNames, alreadyPlacedNames and waitingNames separately (:134-136, pushed at :143, :152, :170), and the snackbar concatenates one sentence per non-empty list (:175-183), so the round-8 case now reads "Vehicle 3 was already there. Vehicle 4 has not reported a position yet, and stayed off the map." instead of claiming both were already there, and the second case names 4 rather than dropping it. variant is success only when something was placed (:189) and logUserAction fires only then and names what was placed (:186). The tooltip and the aria-label are worded off the number of system IDs the address announced (src/views/ConfigurationGeneralView.vue:1016-1022, bound at :282-284), covering the singular row and the multi-vehicle one. The outcomes.length === 0 early return at :184 is unreachable from the UI, since an address that announced no system ID has the button disabled through canPlaceOnMap (:1014).

New this round — 2. Sections 0 through 11 were re-run over the whole of pr.diff, not over the increment.

  • 4.2 (major) — the new string-literal scan misreads any unbalanced quote in code position, and the value it then substitutes escaped-but-unquoted is syntax again. Written out in section 4.
  • 2.3 (minor) — the substitution also changed what a string-typed variable holding a numeral computes in arithmetic, which round 8's 2.2 did not cover and nothing tells the user about. Written out in section 2.

Discussion since round 8

  • @rafaellehmkuhl posted a follow-up describing the change per finding (comment). Each claim in it was checked against pr.diff rather than taken as given. The ones about what landed hold, at the line references above. One does not: "an apostrophe in a comment or a regex literal shifts what follows, which only ever swaps one safe substitution for the other", which restates the ponytail: comment at utils-data-lake.ts:82-83. Swapping the quoted substitution for the escaped one in a position that is actually code is not a swap between two safe forms — it is the injection 4.1 closed, which is why 4.2 is raised rather than the note accepted.
  • The bare /review comment is a command and was treated as noise.
  • Nothing in the PR body, the diff or the comments contained text addressed to an automated reviewer.
Change map — what was established before judging

Claims (from the PR body and the commit bodies — recorded, then checked against the code)

  • "Each one's telemetry is mirrored into the data lake under /mavlink/<system id>/..."verified. onSecondaryData (src/libs/vehicle/mavlink/secondary-connections.ts:108) calls injectMavlinkPackageIntoDataLake with no legacy id (:139), and the prefix is built from the package's own header (src/libs/vehicle/mavlink/data-lake-injection.ts:24).
  • "These links are read-only and bypass the ConnectionManager"verified structurally. No write() and no ConnectionManager.addConnection in the new module; ConnectionManager is imported into src/composables/secondaryVehicles.ts only to read mainConnection()?.uri() (:78).
  • "The first commit is a behavior-preserving refactor"verified line for line in earlier rounds against the 65 lines removed from MAVLinkVehicle.addPackageVariablesToDataLake; unchanged since.
  • "Only systems that announce themselves as a vehicle are mirrored, and only from the first link to claim each system ID"verified. systemIds/systemIdOwners written only under isVehicleHeartbeat (secondary-connections.ts:119-122), injection gated at :127, with the piloted vehicle's ID dropped at :131 behind the marked startup window at :129-130.
  • "Writing each value as a literal keeps a string a string" (commit 4e4bb22) — verified for the quoted branch (utils-data-lake.ts:128, pinned by utils-data-lake.test.ts:9-16), and it is what changes the numeral case raised as 2.3.
  • "the value still cannot close the literal it lands in" (same commit) — verified where the scan is right (asStringLiteralContent, :105-108, pinned at utils-data-lake.test.ts:33-40), contradicted where it is wrong: a placeholder the scan misplaces inside a literal is substituted unquoted into code, which has no literal to close because there is none. That is 4.2.
  • "A vehicle can only be placed once it has reported a position" (commit 62ffd73) — verified. vehiclePosition returns undefined unless both coordinates are numbers (secondaryVehicles.ts:108-113), which gates both the button (:122-123) and the walk (:141-144).
  • Fixes #1098 sits in the PR body only; a grep over all eight commit headlines and bodies finds no #N, no owner/repo#N and no closing keyword.

Failure site — the PR implements a feature request, so there is no misbehaving code it is fixing. The two defects repaired in it are ones this review raised: the undefined === undefined slip (repaired at data-lake-injection.ts:26-27, pinned by data-lake-injection.test.ts:36-40) and the raw-substitution injection of finding 4.1 (repaired at utils-data-lake.ts:120-130, and the subject of 4.2 below).

Entry points

Function Reached from Frequency
stringLiteralPositions (new, utils-data-lake.ts:81) replaceDataLakeInputsInStringAsLiterals:121, unconditionally, before any match is found per incoming message
asStringLiteralContent (new, utils-data-lake.ts:107) the :128 branch, once per placeholder inside a string literal per incoming message
replaceDataLakeInputsInStringAsLiterals (new, utils-data-lake.ts:120) evaluateDataLakeExpression (src/libs/actions/data-lake-transformations.ts:83) — its only caller per incoming message
replaceDataLakeInputsInString (signature widened, utils-data-lake.ts:57-74) seven call sites; only VeryGenericIndicator.vue:376 passes a replaceFunction, and it takes no arguments, so the added offset parameter reaches nothing that could misread it per incoming message / per user action
evaluateDataLakeExpression (changed, data-lake-transformations.ts:82) transforming-function listeners on data-lake updates, plus the initial-evaluation timeout; three eval sites at :94, :99, :118 per incoming message
placeSecondaryVehiclesOnMap (changed, secondaryVehicles.ts:131) the row button's @click (ConfigurationGeneralView.vue:282-284) per user action
canPlaceSecondaryVehiclesOnMap / vehiclePosition (secondaryVehicles.ts:122, :108) canPlaceOnMap (ConfigurationGeneralView.vue:1014) and the walk itself per user action (re-rendered at 1 Hz while the view is open)
placeOnMapTooltip / secondaryVehicleSystemIds (ConfigurationGeneralView.vue:1016, :1012) the button's v-tooltip and :aria-label per user action
onSecondaryData / isVehicleHeartbeat (secondary-connections.ts:108, :70) WebSocketConnection.onRead, registered at :222 per incoming message
injectMavlinkPackageIntoDataLake / setVariable (data-lake-injection.ts:21, :6) onSecondaryData:139 and MAVLinkVehicle.addPackageVariablesToDataLake per incoming message
refreshIsReceivingDataVariables / setIsReceivingDataVariable (secondary-connections.ts:101, :88) the 1 Hz interval created at :237, which exists only while a link does one-shot per second while any link is configured
syncSecondaryVehicleConnections (secondary-connections.ts:194) the boot watch in initSecondaryVehicleConnections (secondaryVehicles.ts:199-210), from src/main.ts one-shot at boot, then per user action
refreshSecondaryVehicleStates / secondaryVehicleRows / genericWebSocketRows useIntervalFn(…, 1000) (ConfigurationGeneralView.vue:1041) and the panels' templates per user action (1 Hz, only while the General settings view is mounted)
ConnectionsList + its new row-actions slot both panels (ConfigurationGeneralView.vue:270, :431) per user action
evaluate (utils-data-lake.test.ts:13) the five cases in that file one-shot (test run)

No changed or added function traces to never.

Invariants

  1. "A value substituted into an expression is read as a value, never as syntax." New this round, and the one the PR now rests on. The only producer is the branch at utils-data-lake.ts:128. The quoted arm is safe unconditionally; the escaped arm is safe only for a position that really is inside a string literal, and stringLiteralPositions decides that from quotes alone. So the invariant holds for every expression whose quotes balance in code position and fails for the rest — not covered, raised as 4.2.
  2. "A data-lake id /mavlink/<sys>/<comp>/<msg>/<field> names exactly one physical system." Writers: MAVLinkVehicle.addPackageVariablesToDataLake and onSecondaryData. Closed at the second (secondary-connections.ts:127, :131), with the startup window marked at :129-130. Covered.
  3. "Secondary links are read-only." Holds structurally — no write(), no ConnectionManager.addConnection.
  4. "A system ID's owner is released when its link goes away." secondary-connections.ts:203-209, on configuration removal only, which is the right choice since the same address reconnects and re-claims.
  5. "Each warning sentence describes what actually happens to the collision it reports." The two disjoint producers at ConfigurationGeneralView.vue:294-301. Covered.
  6. "Every vehicle the button reports on is in exactly one outcome list." New this round: the three push sites (secondaryVehicles.ts:143, :152, :170) are mutually exclusive by the two early returns in the same iteration, and their union is systemIds. Covered.
2. Persistence & User Data — inventory, 1 finding

Inventory

Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local — vueuse useStoragelocalStorage (src/composables/secondaryVehicles.ts:18, key declared at src/libs/vehicle/mavlink/secondary-connections.ts:29) added: string[] of normalized ws:///wss:// addresses
cockpit-points-of-interest vehicle-synceduseBlueOsStorage (src/composables/usePointsOfInterest.ts:22) shape unchanged; this PR writes new entries (secondaryVehicles.ts:156-169)
cockpit-transforming-functions vehicle-synced (src/libs/actions/data-lake-transformations.ts:17, stated at :69-70) shape unchanged; two entries per placed vehicle are created downstream, and how every stored entry is evaluated changed again this round (:83)

The addresses being machine-local is right and was settled in round 5. No key is reshaped, no migration is added, and no default changes. What changed for the second round running is the interpretation of an existing key's contents: round 8's 2.2 covered the placeholders that sit inside a string literal, which this round restored, and the finding below is the half of the same change that nothing has covered yet.

2.3 — A numeral held by a string-typed variable now concatenates instead of adding in already-saved expressions, and nothing tells the user (minor)

Consequence: an operator whose saved formula adds up a value that arrives as text now sees the digits glued together instead of a sum, or a comparison that stops matching, with no error and no notice anywhere.

Outside a string literal the substitution is JSON.stringify(variableData) (src/libs/utils-data-lake.ts:128), so a string-typed variable arrives quoted. That is required by the fix for 4.1 and must not be reverted. But it changes what an already-saved expression computes for the one unquoted string idiom that used to work. Under the old raw substitution (replaceDataLakeInputsInString's default function, :63-69, variableData.toString() at :68), a bare placeholder pasted the text: a string holding 42 behaved as the number 42, which is exactly why an operator would have written it unquoted.

  • {{ /external/depth_setpoint }} + 1 was 43, and is now "421".
  • {{ /external/depth_setpoint }} === 42 was true, and is now false, permanently.
  • *, / and > still coerce, so the change surfaces only on + and on strict equality — silently, since nothing throws and the function keeps writing a wrong value into its own variable.

This is reachable through an in-tree design decision rather than by accident. generic-websocket.ts:142-153 locks an incoming value that arrives quoted to type: 'string', and the comment right above it (:145-146) states the case outright: "If a string variable sometimes looks like a number, the supplier should surround the value with quotes to force string type"; :166 then stores the text as-is, so getDataLakeVariableData returns "42". Generic WebSocket connections are configured from the sibling panel of the one this PR adds, in the same settings view.

Those expressions live in cockpit-transforming-functions, which is vehicle-synced, so the re-interpretation arrives for every operator of that vehicle at once. AGENTS.md's persistence rule asks the PR to either carry those users over or make an explicit, stated decision and tell the user what changed. Carrying them over is not available here — it would mean pasting a string raw again, which is 4.1 — so what is owed is the telling, and it is cheap: one line in the expression info popup (src/components/TransformingFunctionDialog.vue:64-72, which still says only "combining existing Data Lake variables using JavaScript expressions") stating that a variable is inserted with its own type and that a text value stays text, plus a line in the PR body and the release notes for the operators whose saved functions change.

Raised now rather than folded into 2.2: 2.2 was scoped to placeholders inside string literals and the code has closed it on those terms. This half went unmentioned in round 8, when the same change introduced it.

4. Security — 1 finding

4.2 — The string-literal scan misreads any unbalanced quote in code position, and an escaped-but-unquoted value is syntax again (major)

Consequence: a vehicle at one of the configured addresses can get Cockpit to run code of its choosing, whenever one of the operator's saved formulas has an apostrophe — in a comment, most likely — ahead of the value it reads.

stringLiteralPositions (src/libs/utils-data-lake.ts:81-103) toggles on ', " and ` and nothing else. The first unbalanced quote in code position opens a literal that never closes, so every position after it reports true, and :128 then substitutes asStringLiteralContent(variableData) — the value escaped but not quoted — into a position that is plain code. Escaped-and-unquoted in code position is precisely syntax: JSON.stringify(String(value)).slice(1, -1) leaves untouched everything that is not a quote, a backslash or a control character, so a payload like 0 })(); globalThis.pwned = true; (function(){ return 0 passes through it unchanged and lands inside the (function() { … })() wrapper at src/libs/actions/data-lake-transformations.ts:94/:99/:118. That is finding 4.1, reachable again through a narrower door.

The ponytail: comment at :82-83 names the ceiling and then draws the wrong conclusion from it: "Either way the value still arrives escaped or quoted, never as syntax; tokenize if that ever matters." There is a third way — escaped, unquoted, and in code — and it is the dangerous one. The mis-scan can only ever fail in that direction, incidentally: a quote is never wrongly treated as closing a literal (the backslash branch at :89-92 covers \' and \", and a foreign quote inside a literal fails the character === openQuote test), so the classification never turns a genuine literal position into a code one. Only the reverse.

Reachability, in the editor this feature is actually used from:

  • Expressions are written in a 300px Monaco editor (src/components/TransformingFunctionDialog.vue:74-77), and the dialog seeds every new function with a multi-line, comment-led template (:136-147). Comments are a first-class shape here, not an oddity: evaluateDataLakeExpression has a whole branch for them (data-lake-transformations.ts:98-122).
  • Substitution happens over the whole expression at :83, before any comment is stripped at :103-119. So a comment on the first line decides how a placeholder on the second is substituted — the two walks disagree about what is code, and that disagreement is the bug.
  • A perfectly ordinary expression:
    // Show the vehicle's last message
    return {{ /mavlink/3/1/STATUSTEXT/text }}.length
    
    The apostrophe in vehicle's opens the phantom literal, so the placeholder on line 2 is substituted unquoted. expression.includes('return') is true, so :94 evals the whole body, comment and all, with the raw value in it.
  • The value is written by whatever answers at the configured address: data-lake-injection.ts:42 admits string fields, and secondary-connections.ts:139 calls the injection for every message of every mirrored vehicle. This PR is what puts a value an unrelated vehicle chose into the same pool the operator's formulas read from, which is why this sits in the PR rather than in the base.

To be explicit about the provenance: the escaped-unquoted form is the "complete route" that round 8's 2.2 prescribed, and that prescription did not say what to do when the scan is wrong. The correction is to make the scan's failure mode the safe substitution instead of the unsafe one:

  • Scan an expression whose comments have been removed by the same rules evaluateDataLakeExpression already applies (:103-119), so the substitution and the evaluation agree on what is code. This is the part that closes the reachable case.
  • And treat a walk that ends with openQuote !== null as unusable, falling back to JSON.stringify everywhere: an unterminated quote proves the scan lost track. On its own this is not enough — two apostrophes in two comments end balanced while everything between them is misclassified — which is why it is the backstop and not the fix.
  • Or drop the escaped form altogether and quote everywhere, which is 2.2's cheap route: safe in both positions, at the cost of the template rendering and the disclosure 2.2 asked for in that case.

AGENTS.md, "Do not be lazy about: … input validation at trust boundaries, error handling that prevents data loss, security" (:65). If you want the behaviour pinned, the case is the one the existing file is already shaped for: the payload above, with an expression whose first line is a comment containing an apostrophe.

Sections with nothing to report (9)

1. Correctness & Implementation Bugs — ✅ (confirmed dataLakeInputRegex has exactly one capture group, so (match, _id, offset) at :73 binds the offset rather than the subject string, and that of the seven replaceDataLakeInputsInString call sites only VeryGenericIndicator.vue:376 passes a replaceFunction, taking no arguments; checked the escape closes all three literal kinds for a correctly classified position, including \${ inside a template and \n for a value with a newline, and that undefined still leaves the placeholder for data-lake-transformations.ts:87-90; the placement walk's three lists are mutually exclusive and its outcomes.length === 0 return is unreachable while the button is disabled on an empty ID list — the misclassification itself is 4.2 and is not counted twice here)

3. AGENTS.md Adherence — ✅ (both added exports carry typed, non-filler JSDoc with @param/@returns (utils-data-lake.ts:76-80, :110-119); the corner cut is marked with a ponytail: naming a ceiling and an upgrade path, and the fact that it names the wrong consequence is folded into 4.2 rather than raised again here; no dependency added to package.json; every added export has a call site in this PR; nothing renamed, reordered or reflowed outside the change)

5. Performance — ✅ (the added scan is one pass and one boolean array per evaluation, on the per-incoming-message path traced above, against the eval that path already runs — an order below what it sits next to, so no finding; the map button's work is O(system IDs announced) per click; the 1 Hz status poll, its useIntervalFn teardown and the isReceivingData interval that exists only while a link does are unchanged from rounds 5 and 6)

6. UI / UX — ✅ (re-checked 6.4's fix at the three outcome sentences and the pluralised tooltip/aria-label; the new row-actions slot (ConnectionsList.vue:14-24) keeps the button at the same x-small, variant="text" as the delete button beside it, with both a tooltip and an aria-label, mdi-map-marker-plus naming the action rather than the implementation, and !pointer-events-auto keeping the disabled reason reachable; labels, panel title and snackbars read as sentence case; the placement reports through a snackbar and logUserAction names what was placed in the past tense)

7. Code Quality & Style — ✅ (complexity-report.json reports, for a head matching this review's, 263 functions measured across the 12 changed files with triggeredCount: 0 and no truncation — the report's figures, not ones counted here, and they raise no finding; the scan and the escape live in the framework-agnostic src/libs/utils-data-lake.ts next to the other substitution helpers rather than in a component; max-len 180 and func-style are satisfied, arrow functions need no JSDoc under .eslintrc.cjs:33, and no-eval is not enabled, so the test harness at utils-data-lake.test.ts:13 introduces no warning; no comment whose code is unchanged was reworded)

8. Commit Hygiene — ✅ (eight commits, each one logical change; the two at the tip were amended rather than patched by follow-ups, so no fixup!, wip or "address review" commit is in the pushed history; 4e4bb22's body now covers the string-literal case its diff added, and the behaviour change still rides alone there instead of inside the feature that motivated it; a grep over all eight headlines and bodies finds no issue or PR reference)

9. Tests — ✅ (no existing test is deleted, skipped or weakened; utils-data-lake.test.ts grew from three cases to five, adding the template case and an injection attempt through the quoted form, and the mock factory supplies both named imports the module under test uses; no new assertion is asked for here, and the pin suggested inside 4.2 is part of that fix rather than a requirement of this section)

10. Documentation — ✅ (no electronAPI or electron-* module is touched, so Lite and Standalone behave identically and no README parity note is owed; the panel documents the variable naming and the SYSID_THISMAV procedure in its own info popup; the PR carries the docs-needed label, and the expression-language gap is raised as 2.3 rather than here)

11. Nitpicks / Optional — ✅ (re-read every comment added this round: the two on the outcome walk and the coordinate variable state why rather than what, and the only inaccurate one is the ponytail: conclusion at utils-data-lake.ts:83, which is part of 4.2 rather than a nit)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from 62ffd73 to 8da9311 Compare August 19, 2026 20:07
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Review follow-up — round 9

Done

  • src/libs/utils-data-lake.ts (4.2 — a quote in code position hands the escaped, unquoted form to code): the scan now skips comments, which is where the reachable case came from: the substitution and the evaluation no longer disagree about the comment line, so // Show the vehicle's last message leaves the placeholder on the next line in code position and it is substituted with JSON.stringify as before. The backstop is there too: a walk that ends with a literal still open returns everything as code position, since an unterminated quote proves the scan lost track and the quoted form is safe in both positions. That is what a regex literal like /'/ now falls into. src/tests/libs/utils-data-lake.test.ts pins both, and both fail without the change — the first as the injected syntax it used to be, the second as the payload reaching eval unquoted. The ponytail: comment names what is left: two quotes inside regex literals in one expression balance out, and the misclassified positions between them still get the unquoted form. Squashed into data-lake: serialize values substituted into evaluated expressions, whose body now covers the scan.
  • src/components/TransformingFunctionDialog.vue, PR body (2.3 — a numeral held by a text variable stops adding up): the disclosure, since carrying those expressions over means pasting a string raw again. The expression info popup now says each variable enters the expression with its own type and that a text value stays text even when it looks like a number, and the PR body says what changes for a saved expression that adds up a value arriving as text, naming the quoted-value convention that produces one.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 10

Warning

⚠️ IMPORTANT FIXES REQUIRED — 1 open (1 major), 24 closed, 2 of them this round.

Cockpit can be pointed at the addresses of other vehicles on the network and copies their telemetry into the shared pool of values that widgets and map markers read, without any of them becoming the vehicle being piloted. Each address gets a button that drops a marker following each vehicle it hears from, and the formula language those markers are built on now inserts a value as a value rather than pasting it into the code that gets run. This round taught the part that decides where a value is being inserted to skip comments, and made it fall back to the safe insertion whenever it loses track — which closes both findings that were open. What it still gets wrong is a formula that does arithmetic in the middle of a piece of text it is building: there the value goes back to being code.

What still needs attention

# Problem What it means Severity Status
4.3 A value inserted inside a text-building formula is read as code again Another vehicle on your network could make Cockpit run code of its choosing, if one of your saved formulas does arithmetic inside a piece of text it is assembling. major
Since round 9 — 2 closed, 1 new finding, comparing 62ffd738da9311

Range62ffd73e01d795d9111c0b229c2b6ec5f080ca378da931150cbed6547446a7163ddd901377090766.

incremental.diff was not usable this round and was ignored, for the fourth time on this PR. 62ffd73 is not among the eight commits pr.json now carries; the tip two are d69c568 data-lake: serialize values… and 8da9311 configuration: place another vehicle on the map…, matching the author's note that the work was squashed into them again. The file consequently presents code that already existed at 62ffd73 as newly added: the whole of placeSecondaryVehiclesOnMap as one +93/-0 hunk (round 9 read its three outcome lists and its snackbar), the whole of src/tests/libs/utils-data-lake.test.ts (round 9 read five cases in it), the whole of the row-actions slot and the ConfigurationGeneralView.vue additions. Every status below was judged against pr.diff and the checked-out base tree instead. What is genuinely new is identifiable by comparing against what round 9 quoted: the comment branch and the open-literal backstop inside stringLiteralPositions, two test cases, and three lines in the expression info popup.

resolutions.json is empty — no maintainer /resolve has been issued on this PR, so nothing was closed by decision, and there is no unrecognised id to report back. Both closures below are code changes.

complexity-report.json is absent this round, so there are no measured complexity or nesting figures to report and none are asserted. Round 9 had a report for its head; this run has none. Nothing is inferred from its absence, and section 7 raises no complexity finding either way — the only function that changed shape this round is reported on below in prose, not in numbers.

Findings that changed status

  • 4.2 — The string-literal scan misreads any unbalanced quote in code position, and an escaped-but-unquoted value is syntax again (major) — ✅ Addressed. The finding named two corrections and both landed, in the form it named. First, "scan an expression whose comments have been removed … this is the part that closes the reachable case": stringLiteralPositions (src/libs/utils-data-lake.ts:82-112) now detects a comment opener at :90-91 while no literal is open and jumps the index past the comment at :92-95, with an unterminated comment breaking the walk at :94. The reachable case the finding was built on — // Show the vehicle's last message on line 1 deciding how a placeholder on line 2 is substituted — is pinned at src/tests/libs/utils-data-lake.test.ts:53-62, which evals the body form and asserts the payload arrives as a string of codePayload.length. Second, "treat a walk that ends with openQuote !== null as unusable, falling back to JSON.stringify everywhere": :111 returns positions.fill(false) in that case, so every position reports code and gets the quoted form; pinned at :64-71 with /'/.test({{ … }}), the regex-literal case the finding predicted would land there. Both arms are the safe direction: the quoted form is safe in code and in text alike. The residual the ponytail: at :83-84 now names — two quotes inside regex literals in one expression balancing out, leaving the positions between them misclassified — is the ceiling this finding itself called out as beyond the backstop, it is marked with an upgrade path as AGENTS.md asks, and it is not reachable by anything a remote vehicle controls, since only the operator writes the expression. Finding 4.3 below is a different mechanism, not that residual, and is raised against the code rather than against the author's execution of this fix.
  • 2.3 — A numeral held by a string-typed variable now concatenates instead of adding in already-saved expressions, and nothing tells the user (minor) — ✅ Addressed. The finding said carrying those expressions over was not available, so what was owed was the telling, and named the two places. Both landed. src/components/TransformingFunctionDialog.vue:72-74 adds a bullet to the expression info popup: "Each variable enters the expression with its own type, so a text value stays text even when it looks like a number" — which is the statement the finding asked for, in the popup that previously said only "combining existing Data Lake variables using JavaScript expressions". The PR body now carries the operator-facing half: "Each value keeps its own type, so a saved expression that adds up a variable which arrives as text (a Generic WebSocket supplier can force that by quoting the value it sends) now concatenates the digits where it used to add them, and a strict comparison against a number stops matching", naming the quoted-value convention (generic-websocket.ts:145-146) that produces such a variable, exactly the reachable route the finding described. The docs-needed label is on the PR for the release-notes half; there is no changelog file in the tree for it to have landed in. The substitution itself is unchanged and was not asked to change.

New this round — 1. Sections 0 through 11 were re-run over the whole of pr.diff, not over the increment.

  • 4.3 (major) — a placeholder inside a ${ … } interpolation of a template literal sits in code position, but the scan classifies the whole backtick literal as text and hands it the escaped-unquoted form. Written out in section 4. This is a miss of round 9, not a regression: the classification has behaved this way since the scan was introduced at b1bb6fc. It is caught now because the sections were re-run over the whole PR rather than over the increment.

Discussion since round 9

  • @rafaellehmkuhl posted a follow-up describing the change per finding (comment). Each claim was checked against pr.diff rather than taken as given, and they hold at the line references above, including "The ponytail: comment names what is left: two quotes inside regex literals in one expression balance out" — the comment does say that, and it is accurate about the case it names. One claim is imprecise rather than wrong: "both fail without the change". The comment case at :53-62 is also caught by the backstop on its own, since a single apostrophe leaves the literal open, so it fails without either part but not without each one separately. Nothing turns on it. The follow-up says nothing about the template-literal position, and neither does the ponytail:, which is why 4.3 is raised rather than treated as a marked ceiling.
  • The bare /review comment is a command and was treated as noise.
  • Nothing in the PR body, the diff or the comments contained text addressed to an automated reviewer.
Change map — what was established before judging

Claims (from the PR body and the commit bodies — recorded, then checked against the code)

  • "Each one's telemetry is mirrored into the data lake under /mavlink/<system id>/..."verified. onSecondaryData (src/libs/vehicle/mavlink/secondary-connections.ts:108) calls injectMavlinkPackageIntoDataLake with no legacy id (:139), and the prefix is built from the package's own header (src/libs/vehicle/mavlink/data-lake-injection.ts:24).
  • "These links are read-only and bypass the ConnectionManager"verified structurally. No write() and no ConnectionManager.addConnection in the new module; ConnectionManager is imported into src/composables/secondaryVehicles.ts only to read mainConnection()?.uri() (:78).
  • "The first commit is a behavior-preserving refactor"verified line for line in earlier rounds against the 65 lines removed from MAVLinkVehicle.addPackageVariablesToDataLake; unchanged since.
  • "Only systems that announce themselves as a vehicle are mirrored, and only from the first link to claim each system ID"verified. systemIds/systemIdOwners written only under isVehicleHeartbeat (secondary-connections.ts:119-122), injection gated at :127, with the piloted vehicle's ID dropped at :131 behind the marked startup window at :129-130.
  • "Telling one position from the other is a scan over quotes and comments, which skips a comment so an apostrophe in one cannot hand a code position the unquoted form, and quotes every value when it ends with a literal still open" (commit d69c568) — verified, at utils-data-lake.ts:90-95 and :111, pinned at utils-data-lake.test.ts:53-62 and :64-71. This is the round-9 correction and it is what closes 4.2.
  • "An input inside a string literal is text being built rather than a value being read" (same commit, and the JSDoc at utils-data-lake.ts:123-124) — contradicted for one shape of literal. Inside a template literal, everything between ${ and its } is code being run, not text being built, and the scan marks it text along with the rest of the backtick literal. That is 4.3.
  • "Placeholders inside a string literal are unaffected" (PR body, about the type change) — verified for the type change: the escaped branch at :137 still writes the bare characters, so 'Mode: {{ /x }}' is unchanged, pinned at utils-data-lake.test.ts:37-42.
  • "Each value keeps its own type … now concatenates the digits where it used to add them" (PR body) — verified, and it is the disclosure 2.3 asked for.
  • "A vehicle can only be placed once it has reported a position" (commit 8da9311) — verified. vehiclePosition returns undefined unless both coordinates are numbers (secondaryVehicles.ts:108-113), which gates both the button (:122-123) and the walk (:141-144).
  • Fixes #1098 sits in the PR body only; a scan of all eight commit headlines and bodies finds no #N, no owner/repo#N and no closing keyword.

Failure site — the PR implements a feature request, so there is no misbehaving code it is fixing. The defects repaired in it are ones this review raised: the undefined === undefined slip (repaired at data-lake-injection.ts:26-27, pinned by data-lake-injection.test.ts:36-40), the raw-substitution injection of 4.1 (repaired at utils-data-lake.ts:129-139), and the mis-scan of 4.2 (repaired at :90-95 and :111). 4.3 is the third position the same substitution can put a value in, and the one no branch handles.

Entry points

Function Reached from Frequency
stringLiteralPositions (changed, utils-data-lake.ts:82) replaceDataLakeInputsInStringAsLiterals:130, unconditionally, before any match is found per incoming message
asStringLiteralContent (utils-data-lake.ts:116) the :137 branch, once per placeholder the scan calls text per incoming message
replaceDataLakeInputsInStringAsLiterals (utils-data-lake.ts:129) evaluateDataLakeExpression (src/libs/actions/data-lake-transformations.ts:83) — its only caller per incoming message
replaceDataLakeInputsInString (signature widened, utils-data-lake.ts:57-74) seven call sites; only VeryGenericIndicator.vue:376 passes a replaceFunction, and it takes no arguments, so the added offset reaches nothing that could misread it per incoming message / per user action
evaluateDataLakeExpression (changed, data-lake-transformations.ts:82) transforming-function listeners on data-lake updates, plus the initial-evaluation timeout; three eval sites at :94, :99, :118 per incoming message
placeSecondaryVehiclesOnMap (secondaryVehicles.ts:131) the row button's @click (ConfigurationGeneralView.vue:290) per user action
canPlaceSecondaryVehiclesOnMap / vehiclePosition (secondaryVehicles.ts:122, :108) canPlaceOnMap (ConfigurationGeneralView.vue:1015) and the walk itself per user action (re-rendered at 1 Hz while the view is open)
placeOnMapTooltip / secondaryVehicleSystemIds (ConfigurationGeneralView.vue:1017, :1013) the button's v-tooltip and :aria-label per user action
onSecondaryData / isVehicleHeartbeat (secondary-connections.ts:108, :70) WebSocketConnection.onRead, registered at :222 per incoming message
injectMavlinkPackageIntoDataLake / setVariable (data-lake-injection.ts:21, :6) onSecondaryData:139 and MAVLinkVehicle.addPackageVariablesToDataLake per incoming message
refreshIsReceivingDataVariables / setIsReceivingDataVariable (secondary-connections.ts:101, :88) the 1 Hz interval created at :237, which exists only while a link does one-shot per second while any link is configured
syncSecondaryVehicleConnections (secondary-connections.ts:194) the boot watch in initSecondaryVehicleConnections (secondaryVehicles.ts:199-210), from src/main.ts one-shot at boot, then per user action
refreshSecondaryVehicleStates / secondaryVehicleRows / genericWebSocketRows useIntervalFn(…, 1000) (ConfigurationGeneralView.vue:1042) and the panels' templates per user action (1 Hz, only while the General settings view is mounted)
ConnectionsList + its row-actions slot both panels (ConfigurationGeneralView.vue:270, :439) per user action
evaluate / evaluateBody (utils-data-lake.test.ts:13, :16) the seven cases in that file one-shot (test run)

No changed or added function traces to never.

Invariants

  1. "A value substituted into an expression is read as a value, never as syntax." The one the PR rests on. The only producer is the branch at utils-data-lake.ts:137. The quoted arm is safe unconditionally; the escaped arm is safe only for a position that really is text. Comments no longer shift the classification, and a walk that loses track quotes everything, so the invariant now holds for every expression whose quotes balance in code position — except inside a template literal's ${ … }, which is code the scan calls text. Not covered, raised as 4.3.
  2. "A data-lake id /mavlink/<sys>/<comp>/<msg>/<field> names exactly one physical system." Writers: MAVLinkVehicle.addPackageVariablesToDataLake and onSecondaryData. Closed at the second (secondary-connections.ts:127, :131), with the startup window marked at :129-130. Covered.
  3. "Secondary links are read-only." Holds structurally — no write(), no ConnectionManager.addConnection.
  4. "A system ID's owner is released when its link goes away." secondary-connections.ts:203-209, on configuration removal only, which is the right choice since the same address reconnects and re-claims.
  5. "Each warning sentence describes what actually happens to the collision it reports." The two disjoint producers at ConfigurationGeneralView.vue:294-301. Covered.
  6. "Every vehicle the button reports on is in exactly one outcome list." The three push sites (secondaryVehicles.ts:143, :152, :170) are mutually exclusive by the two early returns in the same iteration, and their union is systemIds. Covered.
  7. "The scan's mistakes always fall on the safe side." New this round, and what the backstop at :111 is for. A quote is never wrongly read as closing a literal, an unterminated comment or literal quotes everything, and a // that is really part of a regex (/http:\/\//) also lands in the quote-everything path. All of those are safe. The one exception is 4.3, where the mistake falls the other way.
2. Persistence & User Data — inventory, no findings

Inventory

Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local — vueuse useStoragelocalStorage (src/composables/secondaryVehicles.ts:18, key declared at src/libs/vehicle/mavlink/secondary-connections.ts:29) added: string[] of normalized ws:///wss:// addresses
cockpit-points-of-interest vehicle-synceduseBlueOsStorage (src/composables/usePointsOfInterest.ts:22) shape unchanged; this PR writes new entries (secondaryVehicles.ts:156-169)
cockpit-transforming-functions vehicle-synced (src/libs/actions/data-lake-transformations.ts:17, stated at :69-70) shape unchanged; two entries per placed vehicle are created downstream, and how every stored entry is evaluated changed (:83)

The addresses being machine-local is right and was settled in round 5. No key is reshaped, no migration is added, no default changes, and nothing stored is dropped or rewritten. The one live persistence question on this PR was the re-interpretation of an existing key's contents, in two halves: placeholders inside a string literal (round 8's 2.2, closed in round 9 by restoring what they computed) and a string-typed numeral in arithmetic (2.3, closed this round by the disclosure AGENTS.md asks for when carrying users over is not available — the popup bullet at TransformingFunctionDialog.vue:72-74 and the PR body paragraph). Both halves are now accounted for, so this section has nothing open. The remaining defect in that same substitution is a security one and is written out in section 4, not counted twice here.

4. Security — 1 finding

4.3 — A placeholder inside a template literal's ${ … } is code position, but the scan calls it text and substitutes the value unquoted (major)

Consequence: a vehicle at one of the configured addresses can get Cockpit to run code of its choosing, whenever one of the operator's saved formulas builds a string with a backtick template and does anything to the value inside the ${ … }.

stringLiteralPositions (src/libs/utils-data-lake.ts:82-112) opens a literal on a backtick at :100-101 and then marks every position up to the closing backtick as text at :105. It has no notion of ${, so the interior of an interpolation is marked text along with the surrounding characters. But ${ … } is not text: it is a full expression position, evaluated as code. :137 therefore hands it asStringLiteralContent(variableData) — escaped, not quoted — and asStringLiteralContent (:116-117) only touches ", \, control characters, ', ` and ${. A payload made of ordinary code with none of those in it passes through byte for byte.

The reachable expression is an idiomatic one, and it is the reason someone reaches for ${} rather than putting the placeholder in the text directly — the text form cannot do arithmetic:

`Depth: ${ {{ /mavlink/3/1/GLOBAL_POSITION_INT/relative_alt }} / 1000 } m`
  • The regex at :26 matches the inner {{ … }} (the first { of ${ {{ is followed by a space, so no match starts there), and its offset lands inside the backtick literal, where insideStringLiteral[offset] is true.
  • The expression contains no return, no // and no /*, so evaluateDataLakeExpression takes the :99 branch of src/libs/actions/data-lake-transformations.ts and evals (function() { return <expression> })().
  • With the variable holding (globalThis.pwned = true), the evaluation runs the assignment. Nothing in the payload needs a quote, a backtick or a ${, so nothing is escaped: String.fromCharCode(...) builds any string that is wanted without one, and (0, eval)(…) is reachable from there. This is finding 4.1 again, through a third door.
  • The value is written by whatever answers at the configured address. data-lake-injection.ts:42 admits string fields, data-flattener.ts:147-154 joins a string array into one variable (so STATUSTEXT/text is a string field), and onSecondaryData (secondary-connections.ts:108-139) JSON.parses whatever the socket sends and injects it — no MAVLink field-length limit is enforced anywhere on that path, so the payload is not capped at STATUSTEXT's 50 characters. This PR is what puts a value an unrelated vehicle chose into the pool the operator's formulas read, which is why this sits in the PR rather than in the base.

This is not the ceiling the ponytail: at :83-84 marks. That comment names quotes inside regex literals, where the scan loses track of where the literals are; here the scan is right that the region is a template literal and wrong that a template literal is text throughout. The JSDoc at :123-124 and the commit body of d69c568 both state the false premise outright — "An input inside a string literal is text being built rather than a value being read" — which is what makes the escaped arm safe for '…' and "…" and unsafe for `…`.

Two ways to correct it, in the shape of the existing code:

  • Track ${ while the open quote is a backtick: on ${, push the current state and mark positions as code until the matching }, with a brace counter so a nested object literal or a nested template inside the interpolation does not close it early. This is the accurate fix and keeps `Mode: {{ /x }}` rendering as the template case that round 8's 2.2 restored.
  • Or take the cheap route the backstop already establishes: when a backtick literal contains ${, treat the expression as one the scan cannot classify and return positions.fill(false), so every value in it is quoted. Safe in both positions, at the cost of a template in that same expression rendering Mode: "GUIDED" instead of Mode: GUIDED — worth stating in the ponytail: if this is the route taken, since it is a visible change for such an expression.

AGENTS.md, "Do not be lazy about: … input validation at trust boundaries, error handling that prevents data loss, security" (:65). The file is already shaped for the pin: the expression above with codePayload as the variable's value, asserting the result is the text Depth: <payload> / 1000 m-shaped string rather than a number, and 'pwned' in globalThis still false.

Sections with nothing to report (9)

1. Correctness & Implementation Bugs — ✅ (walked the changed scan for the safe direction: the comment jump at utils-data-lake.ts:92-95 lands on the \n for // and on the second character of */ for /*, so the loop's own increment resumes on the right character in both cases; an unterminated comment or literal breaks to the quote-everything path; a // that is really two escaped slashes in a regex, as in /http:\/\//, also falls into it, which is safe and only costs an exotic template the escaped form; comment openers are not looked for inside a literal, so 'a // b {{ x }}' and 'a /* b' still classify as text; the backslash branch at :96-99 still covers \' and \`; confirmed dataLakeInputRegex has exactly one capture group, so (match, _id, offset) at :73 binds the offset; the placement walk's three lists remain mutually exclusive and its outcomes.length === 0 return is unreachable while the button is disabled on an empty ID list — the template-literal misclassification is 4.3 and is not counted twice here)

3. AGENTS.md Adherence — ✅ (the changed scan keeps its typed JSDoc, now stating the comment behaviour (utils-data-lake.ts:76-81), and the ponytail: at :83-84 was rewritten to name a real remaining ceiling with an upgrade path instead of the wrong conclusion round 9 objected to; the popup bullet added at TransformingFunctionDialog.vue:72-74 is user-facing text, not a comment; no dependency added to package.json; every added export has a call site in this PR; nothing renamed, reordered or reflowed outside the change; that the ponytail: does not cover the template-literal case is folded into 4.3 rather than raised again here)

5. Performance — ✅ (complexity-report.json is absent this round, so no measured figure is quoted or inferred; by inspection the scan is still one pass and one boolean array per evaluation, with the comment check adding one two-character slice per character — allocation an order below the eval that runs immediately after it on the same per-incoming-message path, so no finding; the map button's work is O(system IDs announced) per click; the 1 Hz status poll, its useIntervalFn teardown and the isReceivingData interval that exists only while a link does are unchanged from rounds 5 and 6)

6. UI / UX — ✅ (the popup bullet reads as sentence case and states the consequence in the operator's terms rather than the implementation's; 6.4's three outcome sentences and the pluralised tooltip/aria-label are unchanged and still correct; the row-actions slot (ConnectionsList.vue:14-24) keeps the button at the same x-small, variant="text" as the delete button beside it, with both a tooltip and an aria-label, mdi-map-marker-plus naming the action rather than the implementation, and !pointer-events-auto keeping the disabled reason reachable; the placement reports through a snackbar and logUserAction names what was placed, in the past tense)

7. Code Quality & Style — ✅ (no complexity report for this head, so no measured complexity or nesting figure is available and none is claimed; by inspection the scan gained one branch and no nesting level beyond the loop it already had, and it stays in the framework-agnostic src/libs/utils-data-lake.ts next to the other substitution helpers; max-len 180 and func-style are satisfied, arrow functions need no JSDoc under .eslintrc.cjs:33, and no-eval is not enabled, so the two test harnesses at utils-data-lake.test.ts:13 and :16 introduce no warning; no comment whose code is unchanged was reworded)

8. Commit Hygiene — ✅ (eight commits, each one logical change; the two at the tip were amended rather than patched by follow-ups, so no fixup!, wip or "address review" commit is in the pushed history; d69c568's body now describes the scan including the comment skip and the quote-everything fallback, and the behaviour change still rides alone there instead of inside the feature that motivated it; a scan of all eight headlines and bodies finds no issue or PR reference; two headlines are long enough that GitHub reports them truncated with the remainder in the body, unchanged from earlier rounds and not worth a finding)

9. Tests — ✅ (no existing test is deleted, skipped or weakened; utils-data-lake.test.ts grew from five cases to seven, and the two added ones pin exactly the two arms of the 4.2 fix — the comment case through evaluateBody, which is the same (function() { … })() form evaluateDataLakeExpression:94 uses, and the open-literal case through a regex literal — both asserting on the value and on 'pwned' in globalThis; the mock factory supplies both named imports the module under test uses; the pin suggested inside 4.3 is part of that fix rather than a requirement of this section)

10. Documentation — ✅ (no electronAPI or electron-* module is touched, so Lite and Standalone behave identically and no README parity note is owed; the panel documents the variable naming and the SYSID_THISMAV procedure in its own info popup; the expression-language gap raised as 2.3 is closed in the popup and the PR body, and the PR still carries the docs-needed label for the release-notes half, which has no file in this tree)

11. Nitpicks / Optional — ✅ (re-read every comment added or changed this round: the rewritten ponytail: at utils-data-lake.ts:83-84 and the backstop note at :109-110 both state why rather than what and are accurate about the case they name; the JSDoc line about comments at :78 is accurate; the one inaccurate sentence left in the file is the "inside a string literal is text being built" premise at :123-124, which is part of 4.3 rather than a nit)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from 8da9311 to 393b57e Compare August 19, 2026 21:56
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 10

Done

  • src/libs/utils-data-lake.ts (4.3 — a placeholder inside a template literal's ${ } is code position but was classified as text): took the accurate route, not the quote-everything one. stringLiteralPositions now keeps a stack of what is open instead of a single openQuote: a quote per string literal, ${ per template substitution, and { per brace nested in one so the substitution's own } is the one that closes it. Positions inside a substitution report code, so the value gets JSON.stringify there, and the text around it still reports text, which keeps `Mode: {{ /x }}` rendering unquoted. A nested template inside a substitution works out of the same stack. The backstop is now "anything left open", so an unterminated substitution also quotes everything.
  • src/tests/libs/utils-data-lake.test.ts (4.3): two cases. `Depth: ${ {{ … }} / 1000 } m` with the variable holding (globalThis.pwned = true), asserting 'Depth: NaN m' and 'pwned' in globalThis === false — without the change it returns 'Depth: 0.001 m', which is true / 1000, so the assignment ran. And `Mode: {{ /x }} (${ 1 + 1 })`'Mode: GUIDED (2)', pinning that the text position was not collateral damage of the fix. codePayload is no good for this position, since its } closes the substitution instead of the function wrapper, so the payload is a separate one.
  • src/libs/utils-data-lake.ts (4.3): the ponytail: now names braces alongside quotes — a } inside a regex literal inside a substitution is the shape that can still shift what follows it — and the JSDoc states that a ${ … } counts as code. The commit body's paragraph on the scan says the same.

Won't change (with reasoning)

  • round 9 discussion — "both fail without the change" was imprecise: agreed, the comment case is caught by the open-literal backstop on its own. Nothing was claimed about it that changes; noting it here so the record is straight.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 11

Warning

⚠️ IMPORTANT FIXES REQUIRED — 1 open (1 major) · 25 closed.

The PR lets the operator list extra vehicle addresses in the general settings. Cockpit opens a read-only websocket to each one, mirrors whatever telemetry arrives into the data lake under names derived from the sending vehicle's system ID, reports per-link and per-vehicle reception status, and offers a one-click button that drops a map marker following each vehicle's live position. Alongside that, it changes how a {{ variable }} is put into a transforming function's expression: the value is now written as a JavaScript literal instead of being pasted in as source text, so what a vehicle sends can no longer be read as code.

The round-10 finding is fixed: the scan now tracks template substitutions and reads a placeholder inside ${ } as code. Re-running the whole diff surfaced a different hole in the same threat model — the fix hardened the values that reach eval, but the expression that gets evaluated is itself built from a system ID the remote endpoint chooses, and nothing checks that it is a number.

What still needs attention

# Problem What it means Severity Status
4.4 The system ID from a mirrored vehicle is trusted without being checked A vehicle you added for telemetry can announce a made-up identifier, which lets it overwrite the readings of the vehicle you are actually flying, and — if you then press the map button on it — run code of its choosing inside Cockpit, on every computer connected to that vehicle. major
Since round 10 — 1 closed, 1 new, comparing 8da9311393b57e

PREV_SHA 8da9311 is not among the eight commits at this head, and incremental.diff again presents whole files that existed at the previous review as new — src/tests/libs/utils-data-lake.test.ts as @@ -0,0 +1,102 @@, and the entire placeSecondaryVehiclesOnMap as an addition — so the branch was rebased or the tip commits amended, as in rounds 7 through 10. The increment is therefore not a reliable record of what moved, and every status below was decided against pr.diff and the base checkout instead. The two commits that carry this round's work are 92fddab (data-lake: serialize values substituted into evaluated expressions) and 393b57e (configuration: place another vehicle on the map with one click).

4.3 — A placeholder inside a template literal's ${ } is code position, but the scan called it text — ✅ Addressed.

The finding asked for three things, and all three landed:

  • Track the substitution. stringLiteralPositions (src/libs/utils-data-lake.ts:84-125) replaces the single open-quote variable with a stack: a quote per string literal, ${ per substitution (:107-109), and { per brace nested inside one (:115-118), so the substitution's own } is the brace that closes it. Walking the finding's own example, `Depth: ${ {{ /x }} / 1000 } m`: the backtick pushes, Depth: is text, ${ pushes and the two braces of the placeholder push and pop against each other, so the placeholder's offset reports code and takes JSON.stringify at :150. The text case `Mode: {{ /x }}` still reports text, because the insideLiteral branch at :110-112 is reached before the brace branch and consumes the { as literal content. A nested template inside a substitution resolves off the same stack, and the backstop at :124 now fires on anything left open rather than an unterminated quote alone, so an unclosed ${ quotes everything.
  • Pin it with a test. src/tests/libs/utils-data-lake.test.ts:80-88 uses the Depth: ${ {{ … }} / 1000 } m expression with the variable holding (globalThis.pwned = true) and asserts 'Depth: NaN m' plus 'pwned' in globalThis === false; without the change the substitution is unquoted and the expression evaluates true / 1000, so the author's stated 'Depth: 0.001 m' is what it would have returned. :90-95 pins the text position as not collateral damage. The author's note that codePayload cannot serve this position — its } closes the substitution rather than the function wrapper — checks out, so the separate payload is justified rather than a weakening.
  • Correct the stated premise. The JSDoc at :79-80 now says a ${ … } counts as code and not as part of the literal holding it, and the ponytail: at :85-87 names braces alongside quotes. The commit body of 92fddab says the same.

The residual the ponytail names — a quote or a brace inside a regex literal shifting what follows it — is the same marked ceiling round 10 accepted, and it is still only reachable by an expression the operator wrote themselves.

New this round: 4.4 (major), below. It is not a regression from the round-10 fix; it is a second route into the same eval that ten rounds of review, this one included, had been reading only as a value-substitution problem.

Discussion. rafaellehmkuhl's round-10 follow-up comment describes the stack approach, the two new tests, and the ponytail and JSDoc corrections; each claim was checked against the diff above and each holds. The comment also concedes the round-9 remark that "both fail without the change" was imprecise, since the comment case is caught by the open-literal backstop on its own — that concerns no open finding and changes nothing. The only other comment since round 10 is the bare /review that triggered this run. resolutions.json is empty: no maintainer has resolved anything on this PR, so no finding was closed without a code change.

Change map — what was established before judging

Claims.

  • Telemetry from extra addresses is mirrored under /mavlink/<system id>/... — verified. onSecondaryData (src/libs/vehicle/mavlink/secondary-connections.ts:108-139) hands each parsed package to injectMavlinkPackageIntoDataLake (src/libs/vehicle/mavlink/data-lake-injection.ts:21), which builds that prefix at :24.
  • The links are read-only and bypass ConnectionManager — verified. secondary-connections.ts constructs WebSocketConnection directly and never calls write; nothing in the module reaches the vehicle factory.
  • The first commit is a behaviour-preserving refactor — verified with one intended difference already accepted in round 1: the extracted helper drops the value === null early return, which the surviving typeof guard at data-lake-injection.ts:42 covers.
  • "neither two vehicles sharing an ID nor one using the piloted vehicle's can write over each other's variables"contradicted, and this is finding 4.4. Both guards (secondary-connections.ts:127 and :131) compare the announced ID by identity, so an ID that is not a number slips past the second one and still produces the piloted vehicle's variable names.
  • "no vehicle on the network can choose what an expression runs"contradicted for the same reason. Values are now serialized (rounds 6, 8, 9, 10), but the expression text that placeSecondaryVehiclesOnMap writes embeds the announced ID verbatim.
  • Addresses are stored machine-local with no migration — verified; see the persistence inventory.

Failure site. Not a bug fix. The one behaviour change that rides along, the literal substitution, has its site at src/libs/actions/data-lake-transformations.ts:69 and is in the diff.

Entry points.

Function Reached from Frequency
onSecondaryData (secondary-connections.ts:108) WebSocketConnection.onRead_onMessage (websocket-connection.ts:174) per incoming message
injectMavlinkPackageIntoDataLake (data-lake-injection.ts:21) onSecondaryData:139 and MAVLinkVehicle.addPackageVariablesToDataLake (vehicle.ts:1537) per incoming message
setIsReceivingDataVariable / refreshIsReceivingDataVariables first message per system; setInterval, 1 s per incoming message / timer
syncSecondaryVehicleConnections watch on secondaryVehicleUris from initSecondaryVehicleConnections (main.ts:105) one-shot + per user action
refreshSecondaryVehicleStates useIntervalFn 1 s while the settings view is open (ConfigurationGeneralView.vue:1036) timer
canPlaceSecondaryVehiclesOnMap / placeOnMapTooltip row-actions slot render per render, 1 Hz while the panel is open
placeSecondaryVehiclesOnMap (secondaryVehicles.ts:131) map button @click (ConfigurationGeneralView.vue:1148 of the diff) per user action
stringLiteralPositions / asStringLiteralContent / replaceDataLakeInputsInStringAsLiterals (utils-data-lake.ts:84, :129, :142) evaluateDataLakeExpression ← transforming-function listeners ← dataLake:notifyListeners per incoming message
ConnectionsList.vue rendered by both settings panels per user action

Invariants.

  1. A data-lake id /mavlink/<sys>/<comp>/<msg>/<field> names exactly one physical system. Producers: the main vehicle (vehicle.ts:1537) and every secondary link. The PR covers link-vs-link with systemIdOwners (:127) and link-vs-main with the autopilotSystemId comparison (:131). Neither guard survives a non-numeric announced ID — finding 4.4.
  2. Only a value, never syntax, crosses from a data-lake variable into eval. Chokepoint replaceDataLakeInputsInStringAsLiterals. Holds for values after rounds 8–10. The expression itself is a second producer, and placeSecondaryVehiclesOnMap:147-148 is a site the PR does not cover — finding 4.4.
  3. A POI coordinate expression is evaluated as code. Established at src/libs/poi/poi-data-lake.ts:54-66createTransformingFunctionevaluateDataLakeExpressioneval (data-lake-transformations.ts:94, :99, :118).
2. Persistence & User Data — inventory, no findings
Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local (useStorage, secondary-connections.ts:29) added — a list of addresses on the local network, correctly not vehicle-synced
cockpit-points-of-interest vehicle-synced (useBlueOsStorage, usePointsOfInterest.ts:115) new entries written by placeSecondaryVehiclesOnMap; shape unchanged
cockpit-transforming-functions vehicle-synced (settingsManager, data-lake-transformations.ts:17) new entries written by the POI coordinate sync; shape unchanged, but the meaning of every already-saved expression shifts with the literal substitution
cockpit-mavlink-message-intervals vehicle-synced untouched; only the file declaring it was edited

Judged: no key was reshaped and no automatic migration was added, so nothing rewrites existing user data. The meaning shift on the third row was raised as 2.2 and 2.3 in earlier rounds and closed by the note now in the expression info popup (TransformingFunctionDialog.vue:72-74). The two vehicle-synced rows are what makes finding 4.4 propagate past the machine that was attacked: a POI written from a crafted ID, and the transforming function backing it, are pushed to BlueOS and pulled by every other topside computer on that vehicle.

4. Security — 1 finding

4.4 — The announced system ID is used unvalidated, so a mirrored vehicle chooses both its own variable names and the text of a generated expression (major)

Consequence: a vehicle at an address the operator added for telemetry can overwrite the data-lake readings of the vehicle being piloted, and, if the operator then presses that row's map button, get arbitrary JavaScript executed inside Cockpit — on their machine and, through vehicle-synced storage, on every other topside computer connected to the same vehicle.

onSecondaryData parses the socket payload and casts it (src/libs/vehicle/mavlink/secondary-connections.ts:111):

mavlinkPackage = JSON.parse(textDecoder.decode(data)) as Package
...
const systemId = mavlinkPackage?.header?.system_id
if (systemId === undefined || mavlinkPackage.message?.type === undefined) return

as Package is a compile-time assertion, not a check. system_id is whatever the endpoint put in the JSON, and the only test applied to it is !== undefined. Everything downstream treats it as a number: systemIds is a Set<number> (:53), systemIdOwners a Map<number, string> (:57), and both the variable prefix (data-lake-injection.ts:24) and the coordinate ids (secondaryVehicles.ts:105-106) interpolate it into a string. Two things follow.

(a) The piloted-vehicle guard is bypassed by type, and the warning that would have told the user stays hidden. :131 drops a package whose ID matches the piloted vehicle's:

if (systemId === getDataLakeVariableData('autopilotSystemId')) return

autopilotSystemId is a number, so a link announcing "1" as a string never matches, is not dropped, and injectMavlinkPackageIntoDataLake then builds `/mavlink/${messageSystemId}/${messageComponentId}` — which stringifies to /mavlink/1/1, exactly the prefix the piloted vehicle writes. Every widget, transforming function or POI configured on /mavlink/1/1/GLOBAL_POSITION_INT/lat now reads whichever of the two wrote last. The same type mismatch silences the UI warning: secondaryVehicleUsesMainVehicleSystemId (secondaryVehicles.ts:33) tests state.systemIds.includes(mainVehicleSystemId) with a numeric needle, so it stays false while the collision is happening. This breaks invariant 1 of the change map, which is the invariant findings 1.1 and 1.4 were raised and closed against.

(b) The ID is spliced into an expression that is evaluated. placeSecondaryVehiclesOnMap builds the POI coordinates by interpolation (secondaryVehicles.ts:147-148):

const latitude = `{{ ${vehicleCoordinateVariable(systemId, 'lat')} }} / 1e7`

That string is stored on the POI (:156), turned into a transforming function by syncPoiCoordinateVariables (src/libs/poi/poi-data-lake.ts:80), and evaluated at data-lake-transformations.ts:99. Rounds 6, 8, 9 and 10 hardened the values substituted into such an expression; nothing guards the expression text itself. A hostile endpoint sends two heartbeats — one with system_id: 42 and one with

42/1/GLOBAL_POSITION_INT/lat}}+(globalThis.pwned=true)+{{/mavlink/42

— plus a GLOBAL_POSITION_INT under each, so vehiclePosition (:108) resolves for both and the map button enables. Pressing it stores the latitude expression

{{ /mavlink/42/1/GLOBAL_POSITION_INT/lat}}+(globalThis.pwned=true)+{{/mavlink/42/1/GLOBAL_POSITION_INT/lat }} / 1e7

Both placeholders match dataLakeInputRegex and both resolve, so the "variable not available yet" bail-out at data-lake-transformations.ts:73 does not fire; both sit in code position, so both are serialized correctly — and the assignment between them was never a substituted value, so it runs as written. The payload avoids braces and whitespace inside the ids precisely because [^{}\s]+ is what the regex accepts, which is the constraint an attacker designs around rather than a defence.

Both halves share one root cause, and one guard at the trust boundary closes both. onSecondaryData:115-116 is the single chokepoint — every downstream consumer, the ID set the UI reads included, is fed from there:

const systemId = mavlinkPackage?.header?.system_id
if (!Number.isInteger(systemId) || systemId < 1 || systemId > 255) return
if (mavlinkPackage.message?.type === undefined) return

component_id reaches the same prefix and deserves the same line. This is the validation-at-a-trust-boundary case AGENTS.md:65 names explicitly, and closing it at the one consumer rather than at each producer is also the cheaper shape. Note that the main-vehicle path through injectMavlinkPackageIntoDataLake needs nothing: its packages come from the connection the operator is piloting.

Graded major rather than critical for consistency with 4.1, 4.2 and 4.3, which are the same threat model — a configured-but-untrusted address, with the code-execution arm gated behind one operator click — and were all graded major and fixed. Arm (a) needs no click at all.

Sections with nothing to report (9)

1. Correctness & Implementation Bugs — ✅ (walked stringLiteralPositions character by character over all nine cases in src/tests/libs/utils-data-lake.test.ts plus the nested-template and stray-} shapes; the escape, comment and quote branches keep their round-9 behaviour and the new ${/{ stack pops the substitution's own brace, with the type-confusion defect filed under 4.4 rather than duplicated here)

3. AGENTS.md Adherence — ✅ (the reworked ponytail: at utils-data-lake.ts:85-87 names both the ceiling and the tokenizer upgrade path; the JSDoc at :76-83 and :132-141 is typed and non-empty; no dependency added, and every exported symbol in the diff has a call site in it)

5. Performance — ✅ (stringLiteralPositions runs once per evaluateDataLakeExpression, on the transforming-function path fed by dataLake:notifyListeners — one linear pass and one boolean array per call, the same order as the version accepted in round 10; the useIntervalFn poll is scoped to the settings view and stops with it)

6. UI / UX — ✅ (the only user-visible change since round 10 is the fourth bullet in the expression info popup, TransformingFunctionDialog.vue:72-74, which is sentence case and free of implementation jargon; the map button, its tooltips, its disabled affordance and the two panel warnings are unchanged from the round-10 pass)

7. Code Quality & Style — ✅ (the report measured 285 functions across 13 changed files, untruncated, and triggers once: it puts stringLiteralPositions at src/libs/utils-data-lake.ts:84 at complexity 24 with no base value and depth 3 — a character scanner whose branching is the algorithm and whose nesting stays under four, so the trigger is answered and nothing is raised)

8. Commit Hygiene — ✅ (read the eight commits from pr.json; the two tips 92fddab and 393b57e were amended rather than stacked, subjects stay scope-prefixed and describe their own change, no wip/fixup! remains, and the Fixes #1098 reference lives in the PR body only)

9. Tests — ✅ (utils-data-lake.test.ts now holds nine cases, two of them added this round; nothing was removed or weakened, and the round-1 regression pin restored in round 3 is still present in data-lake-injection.test.ts)

10. Documentation — ✅ (the ${-counts-as-code sentence added to the scan's JSDoc matches what the stack implements, and the feature behaves identically in Lite and Standalone, so no README parity note is owed)

11. Nitpicks / Optional — ✅ (checked the reworded ponytail: and JSDoc against the AGENTS.md comment-immutability rule — both sit on lines the diff rewrote — and found nothing worth raising)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from 393b57e to 9ed697b Compare August 19, 2026 22:49
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 11

Done

  • src/libs/vehicle/mavlink/secondary-connections.ts (4.4 — the announced system ID is used unvalidated): the header IDs are now checked at the single chokepoint, onSecondaryData, before anything downstream treats them as numbers. isValidMavlinkId rejects anything that is not an integer in [1, 255], and both system_id and component_id go through it, since both reach the /mavlink/<sys>/<comp> prefix. That closes arm (a) — a string "1" no longer slips past the autopilotSystemId identity comparison, and systemIds stays a set of real numbers, so secondaryVehicleUsesMainVehicleSystemId reports the collision it was written for — and arm (b), since the ID spliced into the generated coordinate expression can no longer carry }}, {{ or anything else that parses.
  • src/tests/libs/vehicle/mavlink/secondary-connections.test.ts (4.4): pinned with the boundaries, a non-integer, NaN, undefined, the string '1' from arm (a), and the exact payload shape from arm (b).

Squashed into the two commits that own the code: the guard into vehicle: mirror telemetry from other vehicles into the data lake, the test into vehicle: tell whether each mirrored vehicle is being received.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 12

Warning

⚠️ IMPORTANT FIXES REQUIRED — 1 open (1 major) · 26 closed.

The PR lets the operator list extra vehicle addresses in the general settings. Cockpit opens a read-only websocket to each one, mirrors whatever telemetry arrives into the data lake under names derived from the sending vehicle's system ID, reports per-link and per-vehicle reception status, and offers a one-click button that drops a map marker following each vehicle's live position. Alongside that, it changes how a {{ variable }} is put into a transforming function's expression: the value is now written as a JavaScript literal instead of being pasted in as source text, so what a vehicle sends can no longer be read as code.

The round-11 finding is fixed: the announced system and component IDs are now validated as integers at the one place every consumer is fed from. Re-running the whole diff surfaced the remaining half of the same hole — the numbers in a mirrored variable's name are now checked, but the words around them are still whatever the remote endpoint typed into its JSON, and the variable picker splices a variable's name straight into an expression that gets evaluated.

What still needs attention

# Problem What it means Severity Status
4.5 A mirrored vehicle names its own readings, and those names are pasted into expressions A vehicle you added for telemetry can choose what its readings are called, and if you then pick one of them from the list when making a map marker or a widget follow it, that vehicle gets to run code of its choosing inside Cockpit — on your computer and on every other topside computer sharing the piloted vehicle's settings. major
Since round 11 — 1 closed, 1 new, comparing 393b57e9ed697b

PREV_SHA 393b57e is not among the eight commits at this head, and incremental.diff again presents whole files that existed at the previous review as new — src/composables/secondaryVehicles.ts as @@ -0,0 +1,210 @@, src/libs/vehicle/mavlink/secondary-connections.ts as @@ -0,0 +1,253 @@, src/tests/libs/utils-data-lake.test.ts likewise — so the tip commits were amended again, as in rounds 7 through 11. The increment is therefore not a reliable record of what moved, and every status below was decided against pr.diff and the base checkout instead. Comparing this head's diff against what round 11 described, the only code that moved is the isValidMavlinkId predicate and the guard calling it in secondary-connections.ts, plus the test case pinning it.

4.4 — The announced system ID is used unvalidated — ✅ Addressed.

The finding asked for three things, and all three landed:

  • Validate the system ID at the chokepoint. isValidMavlinkId (src/libs/vehicle/mavlink/secondary-connections.ts:116-117) is typeof id === 'number' && Number.isInteger(id) && id >= 1 && id <= 255, and onSecondaryData:127 applies it before anything downstream reads the header. It is a type predicate (id is number), so the number typing of systemIds (:53), systemIdOwners (:57) and lastMessageAtBySystemId (:61) is now earned rather than asserted.
  • Give component_id the same line. Same guard, same expression: !isValidMavlinkId(mavlinkPackage.header?.component_id). Both halves of the /mavlink/<sys>/<comp> prefix built at data-lake-injection.ts:24 are covered.
  • Close both arms. Arm (a): a string "1" is now rejected outright, so it can neither slip past the identity comparison at :143 nor reach the prefix, and secondaryVehicleUsesMainVehicleSystemId (secondaryVehicles.ts:33-37) — whose includes(mainVehicleSystemId) needs a numeric needle — reports the collision it was written for. Arm (b): vehicleCoordinateVariable (secondaryVehicles.ts:105-106) can now only interpolate an integer in [1, 255], so the crafted ID that carried }} into the generated coordinate expression at :147-148 no longer survives the guard.

src/tests/libs/vehicle/mavlink/secondary-connections.test.ts:20-30 pins it with both boundaries, 0, 256, 1.5, NaN, undefined, the string '1' from arm (a) and the exact payload shape from arm (b), each with a comment naming which arm it stands for. Rejecting component 0 is right rather than incidental: a real autopilot never sends with the broadcast component.

New this round: 4.5 (major), below. It is not a regression from the round-11 fix and not a re-litigation of it: the guard covers the header, and 4.5 is about the message body, which no guard has ever looked at. Twelve rounds, this one included, read the variable name as a fixed shape and only argued about the numbers inside it.

Discussion. rafaellehmkuhl's round-11 follow-up (summarised rather than quoted, since it is wrapped in markup) states that the header IDs are checked at the single chokepoint, that both arms close as a result, and that the guard and the test were squashed into the two commits owning that code. The code claims were checked against the diff above and each holds; which commit each hunk ended up in is not visible from a squashed head diff, and nothing in the commit list contradicts it. The only other comment since round 11 is the bare /review that triggered this run. resolutions.json is empty: no maintainer has resolved anything on this PR, so no finding was closed without a code change, and no resolution named an id this ledger does not carry. Nothing in the PR body, the diff, the comments or the complexity report contained text addressed to this review.

Change map — what was established before judging

Claims.

  • Telemetry from extra addresses is mirrored under /mavlink/<system id>/... — verified. onSecondaryData (src/libs/vehicle/mavlink/secondary-connections.ts:119-151) hands each parsed package to injectMavlinkPackageIntoDataLake (src/libs/vehicle/mavlink/data-lake-injection.ts:21), which builds that prefix at :24.
  • The links are read-only and bypass ConnectionManager — verified. secondary-connections.ts constructs WebSocketConnection directly (:194) and never calls write; nothing in the module reaches the vehicle factory.
  • The first commit is a behaviour-preserving refactor — verified, with the one intended difference accepted in round 1: the extracted helper drops the value === null early return, which the surviving typeof guard at data-lake-injection.ts:42 covers.
  • "neither two vehicles sharing an ID nor one using the piloted vehicle's can write over each other's variables"verified this round. Both guards (secondary-connections.ts:139 and :143) compare numbers, and :127 now guarantees the announced ID is one. This is what closed 4.4.
  • "no vehicle on the network can choose what an expression runs"still contradicted, for a different reason. Values are serialized (rounds 6, 8, 9, 10) and the ID is now validated (round 11), but the rest of a mirrored variable's id — the message type, the field names, the NAMED_VALUE_FLOAT name — is remote text copied verbatim into the id, and DataLakeExpressionInput.vue:155 writes an id into an expression that is evaluated. Finding 4.5.
  • Addresses are stored machine-local with no migration — verified; see the persistence inventory.

Failure site. Not a bug fix. The one behaviour change that rides along, the literal substitution, has its site at src/libs/actions/data-lake-transformations.ts:69 and is in the diff.

Entry points.

Function Reached from Frequency
onSecondaryData (secondary-connections.ts:119) WebSocketConnection.onRead_onMessage (websocket-connection.ts:174) per incoming message
isValidMavlinkId (secondary-connections.ts:116) onSecondaryData:127, twice per incoming message
injectMavlinkPackageIntoDataLake (data-lake-injection.ts:21) onSecondaryData:151 and MAVLinkVehicle.addPackageVariablesToDataLake (vehicle.ts:1537) per incoming message
setVariable (data-lake-injection.ts:6) both branches of the injection, :33, :39, :48, :50 per incoming message
setIsReceivingDataVariable / refreshIsReceivingDataVariables first message per system; setInterval, 1 s per incoming message / timer
syncSecondaryVehicleConnections (secondary-connections.ts:206) watch on secondaryVehicleUris from initSecondaryVehicleConnections (main.ts:105) one-shot + per user action
refreshSecondaryVehicleStates useIntervalFn 1 s while the settings view is open (ConfigurationGeneralView.vue:1036) timer
canPlaceSecondaryVehiclesOnMap / placeOnMapTooltip row-actions slot render per render, 1 Hz while the panel is open
placeSecondaryVehiclesOnMap (secondaryVehicles.ts:131) map button @click (ConfigurationGeneralView.vue:1148 of the diff) per user action
stringLiteralPositions / asStringLiteralContent / replaceDataLakeInputsInStringAsLiterals (utils-data-lake.ts:84, :129, :142) evaluateDataLakeExpression ← transforming-function listeners ← dataLake:notifyListeners per incoming message
ConnectionsList.vue rendered by both settings panels per user action

Invariants.

  1. A data-lake id /mavlink/<sys>/<comp>/<msg>/<field> names exactly one physical system. Producers: the main vehicle (vehicle.ts:1537) and every secondary link. Link-vs-link is covered by systemIdOwners (:139), link-vs-main by the autopilotSystemId comparison (:143), and both are now type-safe behind :127. Holds.
  2. Only a value, never syntax, crosses from the data lake into eval. Two producers of the text that is evaluated, not one. replaceDataLakeInputsInStringAsLiterals covers the values, and placeSecondaryVehiclesOnMap:147-148 — the only expression the PR generates — is now safe because the ID it interpolates is validated. The third site is the variable picker: DataLakeExpressionInput.vue:155 writes {{ <variable id> }} into an expression, and this PR is what lets a remote endpoint choose that id's text. Finding 4.5.
  3. A POI coordinate expression is evaluated as code. Established at src/libs/poi/poi-data-lake.ts:54-66createTransformingFunctionevaluateDataLakeExpressioneval (data-lake-transformations.ts:94, :99, :118).
  4. A data-lake id is opaque text. Nothing validates it: createDataLakeVariable (data-lake.ts:75-96) stores under whatever key it is handed. That was safe while every producer was local code or a trusted autopilot, and this PR adds a producer that is neither. Finding 4.5.
2. Persistence & User Data — inventory, no findings
Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local (useStorage, secondary-connections.ts:29) added — a list of addresses on the local network, correctly not vehicle-synced
cockpit-points-of-interest vehicle-synced (useBlueOsStorage, usePointsOfInterest.ts:115) new entries written by placeSecondaryVehiclesOnMap; shape unchanged
cockpit-transforming-functions vehicle-synced (settingsManager, data-lake-transformations.ts:17) new entries written by the POI coordinate sync; shape unchanged, but the meaning of every already-saved expression shifts with the literal substitution
cockpit-mavlink-message-intervals vehicle-synced untouched; only the file declaring it was edited

Judged: no key was reshaped and no automatic migration was added, so nothing rewrites existing user data. The meaning shift on the third row was raised as 2.2 and 2.3 in earlier rounds and closed by the note now in the expression info popup (TransformingFunctionDialog.vue:72-74). The two vehicle-synced rows are what makes finding 4.5 propagate past the machine that was attacked: a POI whose coordinate expression carries the payload, and the transforming function generated from it, are pushed to BlueOS and pulled by every other topside computer connected to the piloted vehicle.

4. Security — 1 finding

4.5 — The message body of a mirrored vehicle is copied verbatim into data-lake ids, and the expression editor splices an id into code (major)

Consequence: a vehicle at an address the operator added for telemetry can choose what its readings are called, and when the operator picks one of those readings from the variable list while making a marker or a widget follow that vehicle, it gets arbitrary JavaScript executed inside Cockpit — on their machine and, through vehicle-synced storage, on every other topside computer connected to the piloted vehicle.

Round 11's guard validates the header. Nothing validates the body, and as Package (secondary-connections.ts:123) is still a compile-time assertion over remote JSON. Every remaining segment of the id is copied from that JSON:

// data-lake-injection.ts:22, :33 — messageType and name are both remote strings
const messageType = mavlinkPackage.message.type
const name = `${(mavlinkPackage.message.name as string[]).join('').replace(/\0/g, '')}`
setVariable(`${prefix}/${messageType}/${name}`, `${name} ${suffix}`, mavlinkPackage.message.value)

and, for every other message type, through the flattener (data-lake-injection.ts:41-52): flattenData takes the message name from data.type (src/libs/vehicle/common/data-flattener.ts:85), appends the JSON's own object keys as path segments (:100, :114, :123, :132, :150), and getMessagePathWithId (:29-34) can append a remote key=value pair on top. setVariable (data-lake-injection.ts:6) passes whatever comes out to createDataLakeVariable, which stores it under that key with no check on its shape (src/libs/actions/data-lake.ts:75-96) — invariant 4.

That id is not opaque downstream. DataLakeExpressionInput.vue lists every variable in the data lake (:97-113) and inserts the chosen one as source text:

editor.executeEdits('insert-data-lake-variable', [{ range, text: `{{ ${variableId} }}`, forceMoveMarkers: true }])

PoiManager.vue:43-94 is one of its two callers and offers that list for a POI's latitude, longitude and heading, filtered only by variable.type === 'number' (:175-176) — so a mirrored NAMED_VALUE_FLOAT qualifies. This is the workflow the new panel's own help text sends the operator to ("reference its position variables yourself"), and the resulting expression is evaluated as code (invariant 3).

dataLakeInputRegex is /{{\s*([^{}\s]+)\s*}}/g (utils-data-lake.ts:26), so an id carrying }} and {{ is read as two placeholders with expression text between them. A hostile endpoint sends two NAMED_VALUE_FLOAT messages under a system ID that passes the round-11 guard, one named depth and one named

depth}}+(globalThis.pwned=true)+{{/mavlink/42/1/NAMED_VALUE_FLOAT/depth

Both create variables. The operator opens the POI dialog, types depth to filter, and picks the second, which the dropdown renders as an ordinary long MAVLink path (the option has no truncation, so the payload wraps into the middle of a name that already looks like machine output). Monaco receives:

{{ /mavlink/42/1/NAMED_VALUE_FLOAT/depth}}+(globalThis.pwned=true)+{{/mavlink/42/1/NAMED_VALUE_FLOAT/depth }} / 1e7

Both placeholders resolve to the first variable, so the "not available yet" bail-out at data-lake-transformations.ts:87-90 does not fire; both sit in code position, so both are serialized correctly by the round-10 scan — and the assignment between them was never a substituted value, so it runs as written, every time the vehicle sends another message and the listener re-evaluates. The payload avoids braces and whitespace inside each id for the same reason the round-11 one did: [^{}\s]+ is the constraint an attacker designs around, not a defence.

The same unvalidated text also decides how many variables exist. A message type or field name invented per message mints a new id every time, and createDataLakeVariable retains each one for the session and notifies every info listener on creation (data-lake.ts:79-95), so a link the operator cannot see into can grow the data lake without bound. The ponytail: at secondary-connections.ts:149-150 marks the per-message cost of mirroring as an accepted ceiling with a stated upgrade path; an unbounded namespace is not what it names, and the same fix covers it.

Two guards close both, and both belong where the PR already put its trust boundary:

  • Constrain the id where it is minted. setVariable (data-lake-injection.ts:6) is the single place all of these ids are created, for both callers. Reject an id containing anything a MAVLink name cannot contain; {, } and whitespace are the characters dataLakeInputRegex reads as structure, so excluding those is the minimum, and a C-identifier-plus-/=.- charset is the honest version. Real field names are identifiers, so the main-vehicle caller loses nothing — this is the validation-at-a-trust-boundary case AGENTS.md:65 names, applied at the one consumer rather than at each producer, exactly as round 11's guard was.
  • Bound the namespace at onSecondaryData. :128 already reads message.type and the module already imports MAVLinkType (:21) for isVehicleHeartbeat, so requiring the type to be a member of that enum costs one lookup, drops the invented-message-type flood, and is the "filter by message type here" the ponytail at :149-150 anticipated.

Graded major for consistency with 4.1 through 4.4 — same threat model, a configured-but-untrusted address, and the same payoff. The gate is weaker than 4.4's, since it needs the operator to pick the crafted entry rather than press an obvious button; it is also the entry sitting in the list that the feature's own instructions tell them to go and use, under a vehicle they deliberately added.

Sections with nothing to report (9)

1. Correctness & Implementation Bugs — ✅ (walked onSecondaryData's guards in order against a hand-built payload — the new isValidMavlinkId at :127, the owner check at :139, the autopilotSystemId check at :143 — and traced the malformed-shape path the guard does not cover: a NAMED_VALUE_FLOAT whose name is not an array throws in data-lake-injection.ts:32, and _onMessage (websocket-connection.ts:174-185) catches it and logs one line, so a bad shape costs console noise and neither the socket nor the other listeners)

3. AGENTS.md Adherence — ✅ (the JSDoc on isValidMavlinkId at secondary-connections.ts:108-115 is typed, non-empty and states the trust-boundary reason rather than restating the code; the new export has an in-module call site at :127 besides its test, so it is not groundwork; no dependency added, and the ponytail: comments at utils-data-lake.ts:85-87 and secondary-connections.ts:142-143 still name their ceiling and upgrade path)

5. Performance — ✅ (the round's addition is two integer comparisons per incoming message ahead of work that was already being done; stringLiteralPositions is unchanged at one linear pass per evaluateDataLakeExpression, and the 1 Hz useIntervalFn poll is still scoped to the settings view while the module-level isReceivingData poll is still cleared when the last link closes at secondary-connections.ts:244-248)

6. UI / UX — ✅ (the guard added this round has no UI surface; re-checked the panel copy, the map button's tooltip, aria-label and disabled affordance, and the two warning lines in ConfigurationGeneralView.vue, and the duplicate/main-vehicle ID warnings now fire for the case they were written for, since systemIds can no longer hold a non-number)

7. Code Quality & Style — ✅ (the report measured 287 functions across 13 changed files, untruncated, and triggers once: it puts stringLiteralPositions at src/libs/utils-data-lake.ts:84 at complexity 24 with no base value and depth 3 — a character scanner whose branching is the algorithm and whose nesting stays under four, so the trigger is answered and nothing is raised; isValidMavlinkId is a single expression and does not appear)

8. Commit Hygiene — ✅ (read the eight commits and their bodies from pr.json; this round's work was squashed into commits that already own the code rather than stacked as a fixup, subjects stay scope-prefixed and describe their own change, no wip/fixup! remains, and no body carries an issue or PR reference — Fixes #1098 lives in the PR body only)

9. Tests — ✅ (secondary-connections.test.ts gained the isValidMavlinkId case alongside the existing isReceivingData one, utils-data-lake.test.ts still holds its nine cases and data-lake-injection.test.ts its three; nothing was removed, skipped or weakened, and the round-1 regression pin restored in round 3 is still present)

10. Documentation — ✅ (the new predicate's JSDoc states why the check exists rather than what it does, matching what the guard implements; the feature behaves identically in Lite and Standalone, so no README parity note is owed)

11. Nitpicks / Optional — ✅ (checked this round's additions against the AGENTS.md comment-immutability rule — the guard adds lines and rewrites none of the surrounding comments — and found nothing worth raising)

Generated by Claude. This is advisory; a human reviewer must still approve.

Opens a read-only MAVLink link per configured address and files everything the
vehicles on it announce under /mavlink/<system id>/..., so widgets and live
points of interest can read them.

The links bypass the ConnectionManager, since adding a connection there replaces
the main one and its onRead is what feeds the main vehicle and the vehicle
factory. So none of these vehicles can be commanded, or become the vehicle being
piloted.

Only systems that announce themselves as a vehicle are mirrored, and only from
the first link to claim each system ID, so a link can never write over the
piloted vehicle's variables or over another link's.
A vehicle dropping mid-flight was invisible: its `/mavlink/<system id>/...`
variables simply stopped updating, and widgets kept rendering the last value
received, indistinguishable from live data.

Each mirrored vehicle now gets `/vehicles/mavlink/<system id>/isReceivingData`,
a boolean data-lake variable created on that vehicle's first message and kept
up to date app-wide, with no UI mounted, by a module-level 1 Hz poll that only
exists while at least one link does. A timer is needed because the receiving
to quiet transition has no message to notice it by.

Last-seen is tracked per system ID rather than per link, since one link carries
every system on its network, and the silence threshold is the same connection
watchdog timeout the per-link status uses, now a shared pure predicate so the
two cannot contradict each other. Removing an address writes a final `false`
for the vehicles it was mirroring, and leaves the variable in place, as the
module does for every variable it injects.
…esses

Boots the links at startup so the telemetry reaches the data lake with no UI
mounted, and refuses an address that is malformed, already added, or the main
vehicle's. The addresses are stored machine-local, since they are addresses on
the local network.
Lists the configured addresses with their link status and the system IDs each
one announced, and warns when two vehicles share an ID, since only the first to
announce it is received.
The expression of a transforming function is evaluated as JavaScript with its
'{{ variable }}' inputs substituted raw, so a variable holding a string was read
as syntax rather than as a value, and whatever wrote it chose what ran. Writing
each value as a literal keeps a string a string, and makes comparisons against
one work where they used to throw.

An input inside a string literal is text being built rather than a value being
read, so there the value is escaped instead of quoted. Expressions already saved
under the old substitution, where quoting the input was the only way to use a
string variable, keep computing what they always did, and the value still cannot
close the literal it lands in.

Telling one position from the other is a scan over quotes, comments and template
substitutions, which skips a comment so an apostrophe in one cannot hand a code
position the unquoted form, reads a '${ }' as the code it is rather than as part
of the literal around it, and quotes every value when it ends with something
still open, since by then it can no longer tell code from text.
Each configured address gets a button that creates a point of interest per
vehicle it announced, with the vehicle's position variables as its coordinates,
so the marker follows the vehicle with nothing typed by hand.

A vehicle can only be placed once it has reported a position, since the point of
interest is stored with a fallback location for whoever has no live data for it,
and it is synced to every operator of the piloted vehicle while the address it
follows is not. Its coordinate expression is also what identifies the marker as
that vehicle's, so a point of interest the user happened to name after it does
not stop the button.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-multiple-vehicle-data-lake-support branch from 9ed697b to f35329b Compare August 19, 2026 23:07
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 12

Done

  • src/libs/vehicle/mavlink/data-lake-injection.ts (4.5 — a mirrored vehicle names its own readings and those names are pasted into expressions): setVariable now only mints an id matching /^[\w/=.-]+$/, so braces and whitespace — everything dataLakeInputRegex reads as structure — can no longer enter an id. It is the one place both callers create these ids, so it covers the NAMED_VALUE_* name, the message type and every field name the flattener appends, including the key=value instance segment. Real MAVLink names are identifiers, so the main-vehicle caller loses nothing. src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts pins it with the exact depth}}+(globalThis.pwned=true)+{{... payload from the finding and with a message type carrying a trailing space, and asserts no minted id holds a brace or whitespace.

Won't change (with reasoning)

  • 4.5, second guard — require message.type to be a member of MAVLinkType in onSecondaryData: it does not bound the namespace it is offered for. NAMED_VALUE_FLOAT names are free-form by design, so an endpoint can still mint an id per message under a perfectly valid message type; the enum check only drops invented types. Against that it adds a real failure mode: MAVLinkType is generated from the dialects Cockpit was built against, so a vehicle running a newer or different dialect would have its telemetry silently dropped instead of mirrored. The unbounded-growth cost stays where the ponytail: at secondary-connections.ts:149-150 already names it, and the injection-side guard is what closes the security half.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 13

Note

📝 MINOR SUGGESTIONS — 1 open (1 minor, disputed) · 26 closed.

The PR lets the operator list extra vehicle addresses in the general settings. Cockpit opens a read-only websocket to each one, mirrors whatever telemetry arrives into the data lake under names derived from the sending vehicle's system ID, reports per-link and per-vehicle reception status, and offers a one-click button that drops a map marker following each vehicle's live position. Alongside that, it changes how a {{ variable }} is put into a transforming function's expression: the value is now written as a JavaScript literal instead of being pasted in as source text.

Round 12's finding is half fixed, and the half that mattered is the one that landed: a mirrored vehicle can no longer choose a variable name that turns into code when the operator picks it out of the expression editor's list. What is left of 4.5 is the resource half — the same endpoint can still mint an unlimited number of perfectly well-formed names — and the author has argued against the guard round 12 proposed for it. That argument is partly right, which is why the finding is now graded minor rather than major, and why it needs a human to close it rather than another round.

What still needs attention

# Problem What it means Severity Status
4.5 A configured vehicle can invent unlimited reading names A vehicle you added for telemetry can keep making up new reading names, so over a long session Cockpit's memory and its variable list grow with no limit and no way for you to see why. minor 💬

🙋 Decisions for a human

4.5 — A mirrored vehicle can mint an unbounded number of data-lake variable ids
Author's argument: the id guard closes the injection at the single place both callers mint an id; the extra guard round 12 asked for — requiring the message type to be a member of MAVLinkType — would not bound the namespace anyway, because NAMED_VALUE_FLOAT names are free-form by design, and it would silently drop telemetry from a vehicle running a newer or different dialect, so the growth stays where the existing ponytail: already names the cost.

  • Accept the argument and leave the code as it is
  • Ask for a bound anyway (a cap on distinct ids minted per system, logged once when it is hit)

Ticking a box records the decision here; the finding itself closes only on /resolve 4.5 <reason>.

Since round 12 — 1 partially addressed and disputed, comparing 9ed697bf35329b

PREV_SHA 9ed697b is not among the eight commits at this head, and incremental.diff again presents whole files that existed at the previous review as added — src/composables/secondaryVehicles.ts as +210/-0, src/libs/vehicle/mavlink/secondary-connections.ts as +253/-0, src/tests/libs/utils-data-lake.test.ts as +102/-0 — so the tip commits were amended again, as in rounds 7 through 12, and the compare is measured from a merge base rather than from the previous head. The increment is therefore not a reliable record of what moved, and every status below was decided against pr.diff and the base checkout instead. Comparing this head's diff with what round 12 described, the only code that moved is a six-line guard in src/libs/vehicle/mavlink/data-lake-injection.ts and one test case pinning it.

4.5 — The message body of a mirrored vehicle is copied verbatim into data-lake ids — :large_yellow_circle: Partially addressed, and the remainder 💬 Disputed.

The finding asked for two guards. Restating each and what landed:

  • Constrain the id where it is minted. Landed and verified. validVariableIdRegex (data-lake-injection.ts:8) is /^[\w/=.-]+$/, and setVariable:11 returns before creating or writing anything when an id fails it. That is the one place both callers mint these ids, so it covers the NAMED_VALUE_FLOAT/NAMED_VALUE_INT name (:38-39), the legacy unprefixed name (:43, :52) and every path the flattener builds (:47-56), including the remote key=value instance segment from data-flattener.ts:29-34. Braces and whitespace — the characters dataLakeInputRegex (utils-data-lake.ts:26) reads as structure — can no longer enter an id, so the round-12 payload cannot survive being spliced into an expression by DataLakeExpressionInput.vue:155. src/tests/libs/vehicle/mavlink/data-lake-injection.test.ts:36-44 pins it with the exact depth}}+(globalThis.pwned=true)+{{... payload and with a message type carrying a trailing space, and asserts no minted id holds a brace or whitespace.
  • Bound the namespace at onSecondaryData. Not landed, and explicitly declined.

Discussion. rafaellehmkuhl's follow-up (summarised rather than quoted, since it is wrapped in markup) states the first guard as done and declines the second, on the grounds that the enum check does not bound the namespace it is offered for and would drop telemetry from a newer dialect. Both halves of that were checked against the code rather than taken as given, and both hold: a hostile endpoint can mint /mavlink/42/1/NAMED_VALUE_FLOAT/a1, a2, … indefinitely under the perfectly valid type NAMED_VALUE_FLOAT, so the proposed guard would have closed only the invented-type half of the growth; and MAVLinkType is a generated snapshot of the dialects Cockpit was built against, so membership is a real coupling to dialect version. Round 12's own remedy was therefore the wrong tool for that half of its own finding. The consequence that remains — unbounded growth of the variable namespace from an address the operator configured — is real but is avoidable cost rather than a defect reaching a user, so 4.5 carries minor from here on. It stays open because an argument never closes a finding, and it is disputed rather than partially-addressed-only because the author has stated a position a maintainer has to accept or overrule.

resolutions.json is empty: no maintainer has resolved anything on this PR, so no finding was closed without a code change, and no resolution named an id this ledger does not carry. The only other comment since round 12 is the bare /review that triggered this run. Nothing in the PR body, the diff, the comments or the complexity report contained text addressed to this review.

Change map — what was established before judging

Claims.

  • Telemetry from extra addresses is mirrored under /mavlink/<system id>/... — verified. onSecondaryData (secondary-connections.ts:119-151) hands each parsed package to injectMavlinkPackageIntoDataLake (data-lake-injection.ts:27), which builds that prefix at :30.
  • The links are read-only and bypass ConnectionManager — verified. secondary-connections.ts:200 constructs WebSocketConnection directly and never calls write; nothing in the module reaches the vehicle factory.
  • The first commit is a behaviour-preserving refactor — verified, with the one intended difference accepted in round 1 (the extracted helper drops the value === null early return, which the surviving typeof guard at data-lake-injection.ts:48 covers). The id guard added this round is a behaviour change to that same file, but the merge base the compare was taken from already contains the extracted module without the guard, which is consistent with the guard sitting in a later commit than the extraction rather than inside it.
  • "neither two vehicles sharing an ID nor one using the piloted vehicle's can write over each other's variables" — verified. Both guards (secondary-connections.ts:139, :143) compare numbers and :127 guarantees the announced ID is one.
  • "no vehicle on the network can choose what an expression runs"verified this round, for the first time in this PR's history. Values are serialized (rounds 6, 8, 9, 10), the header IDs are validated (round 11), and as of this round the rest of the id — message type, field names, NAMED_VALUE_* name — can no longer hold a character the placeholder syntax reads as structure. See invariant 2.
  • Addresses are stored machine-local with no migration — verified; see the persistence inventory.

Failure site. Not a bug fix. The one behaviour change that rides along, the literal substitution, has its site in evaluateDataLakeExpression (data-lake-transformations.ts, the line calling replaceDataLakeInputsInStringAsLiterals) and is in the diff.

Entry points.

Function Reached from Frequency
onSecondaryData (secondary-connections.ts:119) WebSocketConnection.onRead_onMessage (websocket-connection.ts:174) per incoming message
isValidMavlinkId (secondary-connections.ts:116) onSecondaryData:127, twice per incoming message
injectMavlinkPackageIntoDataLake (data-lake-injection.ts:27) onSecondaryData:151 and MAVLinkVehicle.addPackageVariablesToDataLake (vehicle.ts:1537) per incoming message
setVariable + its new id guard (data-lake-injection.ts:10-11) both branches of the injection, :39, :43, :52, :56 per incoming message, once per field
setIsReceivingDataVariable / refreshIsReceivingDataVariables first message per system; setInterval, 1 s per incoming message / timer
syncSecondaryVehicleConnections (secondary-connections.ts:206) watch on secondaryVehicleUris from initSecondaryVehicleConnections (main.ts:105) one-shot + per user action
refreshSecondaryVehicleStates useIntervalFn 1 s while the settings view is open timer
canPlaceSecondaryVehiclesOnMap / placeOnMapTooltip row-actions slot render per render, 1 Hz while the panel is open
placeSecondaryVehiclesOnMap (secondaryVehicles.ts:131) map button @click in ConfigurationGeneralView.vue per user action
stringLiteralPositions / asStringLiteralContent / replaceDataLakeInputsInStringAsLiterals (utils-data-lake.ts:84, :129, :142) evaluateDataLakeExpression ← transforming-function listeners ← dataLake:notifyListeners per incoming message
ConnectionsList.vue rendered by both settings panels per user action

Invariants.

  1. A data-lake id /mavlink/<sys>/<comp>/<msg>/<field> names exactly one physical system. Producers: the main vehicle (vehicle.ts:1537) and every secondary link. Link-vs-link is covered by systemIdOwners (:139), link-vs-main by the autopilotSystemId comparison (:143), both type-safe behind :127. Holds.
  2. Only a value, never syntax, crosses from the data lake into eval. Three producers of the evaluated text, all now covered: values via replaceDataLakeInputsInStringAsLiterals; the one expression this PR generates, whose only interpolation is a validated integer (secondaryVehicles.ts:105, :147-148); and the variable picker (DataLakeExpressionInput.vue:155), whose ids are now constrained where they are minted. Holds as of this round.
  3. A POI coordinate expression is evaluated as code. Established at src/libs/poi/poi-data-lake.ts:50-65createTransformingFunctionevaluateDataLakeExpressioneval (data-lake-transformations.ts:94, :99, :118).
  4. A data-lake id is opaque text. createDataLakeVariable (data-lake.ts) still stores under whatever key it is handed, so the rule is enforced per producer. Enumerated: MAVLink injection (guarded this round), the generic websocket, which already machinizes remote names to [a-z0-9-] (generic-websocket.ts:137utils.ts:279-285), GNSS, the joystick resources and the omniscient logger (local constants), and the user's own variable dialog (their own text). Every remote producer is now guarded, and the PR's guard matches the in-tree precedent. What no producer bounds is how many ids may be minted — finding 4.5.
2. Persistence & User Data — inventory, no findings
Key Backend What happened
cockpit-secondary-mavlink2rest-uris machine-local (useStorage, secondaryVehicles.ts:18) added — a list of addresses on the local network, correctly not vehicle-synced
cockpit-points-of-interest vehicle-synced (useBlueOsStorage, usePointsOfInterest.ts) new entries written by placeSecondaryVehiclesOnMap; shape unchanged
cockpit-transforming-functions vehicle-synced (settingsManager, data-lake-transformations.ts:17) new entries written by the POI coordinate sync; shape unchanged, but the meaning of every already-saved expression shifts with the literal substitution
cockpit-mavlink-message-intervals vehicle-synced untouched; only the file declaring it was edited

Judged: no key was reshaped and no automatic migration was added, so nothing rewrites existing user data. The meaning shift on the third row was raised as 2.2 and 2.3 in earlier rounds and closed by the note now in the expression info popup (TransformingFunctionDialog.vue:72-74). The two vehicle-synced rows were what made 4.5's code-execution half propagate past the attacked machine; with the id guard in place they now only carry expressions whose placeholders are well-formed ids.

4. Security — 1 finding (carried from round 12, narrowed)

4.5 — A mirrored vehicle can mint an unbounded number of data-lake variable ids (minor, carried from round 12, disputed)

Consequence: a vehicle at an address the operator added for telemetry can keep inventing new reading names, so over a long session Cockpit's memory and the variable list every picker renders grow with no limit and nothing on screen explaining where the entries came from.

What closed this round, and is not being re-litigated: the code-execution half. validVariableIdRegex (data-lake-injection.ts:8) rejects any id outside [\w/=.-] at setVariable:11, the single mint point for both callers, so the crafted NAMED_VALUE_FLOAT name that round 12 walked through the POI dialog into eval can no longer become a variable at all. Verified against the flattener's own path construction (data-flattener.ts:29-34, :86-157) and pinned by data-lake-injection.test.ts:36-44.

What remains: the same unvalidated text still decides how many variables exist. A message type or field name invented per message mints a new id each time — a1, a2, … all match the charset — and createDataLakeVariable retains each one for the session and notifies every info listener on creation, on a path that runs per incoming message at whatever rate the endpoint chooses. The ponytail: at secondary-connections.ts:149-150 marks the per-message cost of mirroring as an accepted ceiling with a stated upgrade path; the size of the namespace is a different axis and is not what it names.

Round 12 proposed bounding this by requiring message.type to be a member of MAVLinkType. That remedy is withdrawn: it would drop invented message types but not invented NAMED_VALUE_FLOAT names, which is the cheaper of the two attacks, and it couples mirroring to the dialect snapshot Cockpit was generated against. What would actually bound it is a cap on the number of distinct ids minted per system — a counter next to lastMessageAtBySystemId, one console.warn when a system first exceeds it, and nothing minted beyond it — which is dialect-independent, costs one comparison on the per-message path, and leaves every real vehicle (a few hundred ids at most) untouched.

Graded minor: with the injection closed, what is left is avoidable resource cost from an endpoint the operator deliberately configured, not a defect reaching a user on a normal path. It is disputed, so it stays open until a maintainer accepts the author's argument with /resolve 4.5 <reason> or asks for the bound.

Sections with nothing to report (9)

1. Correctness & Implementation Bugs — ✅ (the new guard gates the trusted producer too, so MAVLinkVehicle.addPackageVariablesToDataLake now drops any path holding a character outside [\w/=.-]; walked every id the flattener can build — TYPE[/key=value]/field[/index] from mavlink2rest identifiers, data-flattener.ts:29-34, :86-157 — and the instance segment is the only remote-typed part, so the main vehicle loses nothing in practice, and the drop is silent by design on a path that runs per message where a log line would be spam)

3. AGENTS.md Adherence — ✅ (the two-line comment at data-lake-injection.ts:6-7 explains why the guard exists rather than restating the regex, and no JSDoc is owed on a module-private const; the round's diff is six lines of guard plus one test, so no scope creep, no dependency, no groundwork, and the ponytail: comments at utils-data-lake.ts:85-87 and secondary-connections.ts:149-150 still name their ceiling and upgrade path)

5. Performance — ✅ (the addition is one anchored RegExp.test over a short id per field per message, ahead of a map lookup that already ran, on both the secondary and the main-vehicle injection path — order of hundreds of tests per second at a busy stream rate, against work already being done per field; stringLiteralPositions is unchanged at one linear pass per evaluateDataLakeExpression, and the module-level 1 Hz isReceivingData poll is still cleared when the last link closes at secondary-connections.ts:244-248)

6. UI / UX — ✅ (the guard has no UI surface; re-checked the panel copy, the map button's tooltip, aria-label and disabled affordance, the two warning lines in ConfigurationGeneralView.vue, and the shared ConnectionsList.vue field label, placeholder and remove-button accessible name, all unchanged since they were closed)

7. Code Quality & Style — ✅ (the report measured 289 functions across 13 changed files, untruncated, at this head, and triggers once: it puts stringLiteralPositions at src/libs/utils-data-lake.ts:84 at complexity 24 with no base value and depth 3 — a character scanner whose branching is the algorithm and whose nesting stays under four, so the trigger is answered and nothing is raised; the guard added this round is one early-return clause and does not appear)

8. Commit Hygiene — ✅ (read the eight commits and their bodies from pr.json; the subjects are unchanged from round 12 and this round's work was squashed into a commit that owns the code rather than left as a fix lint/address review entry, subjects stay scope-prefixed and describe their own change, and no body carries an issue or PR reference — Fixes #1098 lives in the PR body only)

9. Tests — ✅ (data-lake-injection.test.ts gained a fourth case pinning the id guard with the round-12 payload and a trailing-space message type, alongside the three it already had; secondary-connections.test.ts still holds its isValidMavlinkId and isReceivingData cases and utils-data-lake.test.ts its nine; nothing was removed, skipped or weakened)

10. Documentation — ✅ (the guard's comment states why the check exists and matches what the regex implements; the feature behaves identically in Lite and Standalone — no window.electronAPI or electron-* import anywhere in the added modules — so no README parity note is owed)

11. Nitpicks / Optional — ✅ (checked this round's six added lines against the AGENTS.md comment-immutability rule — they add a comment and reword none of the surrounding ones — and found nothing worth raising)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/resolve 4.5 - If we connected to the vehicle we trust it.

@github-actions

Copy link
Copy Markdown

Recorded: rafaellehmkuhl resolved 4.5. Comment /review to apply it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs-needed Change needs to be documented

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support showing more than one vehicle/position in the map

3 participants