fix(web): UniFi collector-agent pickers — real device names, drift + truncation surfaced (#3121) - #3184
fix(web): UniFi collector-agent pickers — real device names, drift + truncation surfaced (#3121)#3184ToddHebebrand wants to merge 4 commits into
Conversation
Both "Collector agent" pickers in the UniFi integration settings — the self-hosted controller registration form and the per-console collector rows under Site mapping — listed agents by raw device UUID instead of a human-readable name, making it impossible to tell which agent you were selecting. Root cause: the local `AgentDevice` interface declared `name: string | null`, but `GET /devices` (apps/api/src/routes/devices/core.ts) returns `hostname` and `displayName` and has no `name` field at all. Because the interface was a structural assertion over an untyped JSON body, the mismatch typechecked silently and `a.name ?? a.id` fell through to the UUID for every row. - Correct `AgentDevice` to mirror the API (`hostname`, `displayName`). - Add an `agentLabel()` helper used by both selects, following the repo-wide device-picker convention `displayName ?? hostname ?? id` (CreateTicketPage, AlertsPage, DRPlanGroupCard). The id remains a last-resort fallback for a malformed row rather than the normal case. Option `value` is unchanged (still the device id), so saved collector assignments are unaffected — this is a label-only fix. Tests: three cases added to UnifiIntegration.test.tsx covering both dropdowns (displayName preferred, hostname fallback, no UUID rendered, values still the device id) plus the both-fields-null id fallback. The two label assertions fail against the previous `a.name ?? a.id` behavior. The pre-existing self-hosted mock was also corrected to the real API device shape, which is what let the bug hide from the suite in the first place. Closes #3121 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
04ac6cf
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e938197b.breeze-9te.pages.dev |
| Branch Preview URL: | https://fix-3121-unifi-agent-dropdow.breeze-9te.pages.dev |
) Addresses review findings on #3184. All three reviewers independently flagged the same defect in the initial fix. `agentLabel` used `??`, which only guards null/undefined — so a device with `displayName: ""` rendered a BLANK <option>. That is strictly worse than the UUID this PR set out to remove: a UUID is ugly but unique and selectable, whereas a blank option is indistinguishable from the "— Select agent —" placeholder and from every other blank row. Empty string is reachable: neither `updateDeviceSchema` nor `provisionDeviceSchema` applies `.min(1)` or a trim (apps/api/src/routes/devices/schemas.ts:101,119), and both write it through verbatim. The web rename UI normalizes "" → null, so it arrives via API/MCP clients. The primary device surfaces (DeviceList, DeviceDetails, NetworkChangesPanel) all use `||`, so the same device would have read "edge-01" there and blank here. - `agentLabel` now uses `displayName?.trim() || hostname?.trim() || id`. - The `?? id` last resort is no longer silent. `devices.hostname` is NOT NULL and always selected by GET /devices, so that branch is unreachable for a well-formed row — reaching it means the response shape drifted, which is exactly the #3121 failure mode. It now warns instead of quietly showing a UUID for every row again. - Corrected the interface comment: it claimed `displayName ?? hostname` was "the repo-wide device-picker convention". The repo is mixed and the primary device surfaces lean `||` — an overstated comment being the wrong thing to ship in a PR about an interface asserting something untrue. Tests: the two label cases previously asserted against a select the user cannot reach — no site was ever chosen, so the control stayed `disabled` and the `a.siteId === siteId` filter branch never evaluated. Both now pick the site first (the real user journey from the issue), assert the select is enabled, and assert an agent at another site is filtered out. Added an empty-string-displayName fixture and a warning assertion. Verified the two label tests fail against BOTH the original `name ?? id` and the interim `displayName ?? hostname` behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review run: Findings: 3 raised → all addressed in
Regression pinning verified by revert: the two label tests fail against the original Tests: Deliberately out of scope (pre-existing, flagged for follow-up rather than widening a label fix):
Status: review-clean, awaiting maintainer merge. Not merged and #3121 left open per handoff rules. |
…3121) Folds in the three follow-ups flagged during review of this PR. All three are the same failure family as #3121 itself — the collector-agent picker showing the operator something other than the truth — so they belong with it rather than in a separate issue. 1. Response-shape drift was swallowed. The devices body was unwrapped with an inline `data ?? devices ?? (Array.isArray ? ...)` chain that collapsed any unrecognized shape to `[]` while `res.ok` was true, pushing nothing to `failed`. The result was an empty dropdown with zero diagnostics — strictly worse than the UUIDs this PR started with, because there was nothing to notice at all. `parseAgentDevices()` now returns a discriminated union and distinguishes three cases: an unrecognized envelope, a list whose rows carry neither `hostname` nor `displayName` (literally the #3121 shape, arriving as data instead of as a type error), and a legitimately empty fleet. The first two reach the error banner and a console warning; the third is silent, on purpose — a guard that cries drift over a partner with no devices is noise and gets ignored. 2. `?limit=500` truncated with no notice, producing the same user-visible symptom as #3121 ("my agent isn't in the list"). Cursor pagination is the default for GET /devices, so a non-null `pagination.nextCursor` means the ceiling cut the list short; that now renders a notice. The limit is a named constant so the query and the copy cannot drift apart. Truncation is not a failure and deliberately does not raise the error banner. 3. `AgentDevice.status` was declared but never consulted, so an offline agent was selectable as a collector with no indication — and a collector polls the controller *from* the agent, so that silently never polls. Non-online agents are now annotated ("Closet (offline)"). Annotate rather than filter: an agent that is down right now is still a legitimate thing to configure, the operator just has to be able to see it. i18n: `unifiIntegration.agentListTruncated` added to all 7 locales with real translations (es-419, it-IT and pt-BR have zero headroom against the per-namespace English-duplicate baselines, so an English copy would have gone red). Tests: 7 cases added, each pinned by revert — restoring the old inline unwrap fails exactly the two drift tests and the truncation test, and flattening `agentOptionLabel` fails the status test. The empty-fleet and legacy `{ devices: [] }` cases keep passing under the revert, which is what proves they guard against false positives rather than just re-asserting the fix. Verification: vitest src/components/integrations/ + src/lib/ → 72 files / 1068 passed (includes localeParity, translationCoverage, keyUsage, no-envelope-fallthrough). tsc --noEmit clean, eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ist (#3121) Addresses the second review round. The substantive finding: the previous commit's truncation notice was a label on a broken control, not a fix. `apps/web/src/lib/devicesFetch.ts` already exports `fetchAllDevices()` — the reviewed keyset-cursor walker (#742/#778) that accumulates every accessible row. This picker was instead taking one capped page and adding copy explaining the result might be wrong. Since it filters by site CLIENT-side, a partner past the ceiling could not reach their agent at all, and CLAUDE.md targets 10,000+ agents — so the notice would have been permanently lit on exactly the fleets where the picker was useless. Now the walk runs to completion and "truncated" means only the walker's 40,000-row safety ceiling, which is why the notice can stay quiet in normal operation. Consequences, all improvements: - `AGENT_LIST_LIMIT` is gone. The reviewer noted it could not prevent the copy drift it was introduced to prevent (the server clamps at DEVICES_LIST_HARD_MAX=1000, so raising it would have made the copy lie). - The `{ devices: [...] }` envelope branch is gone, and the test that pinned it as valid now pins it as drift. `devicesFetch.ts:139-141` records that no deployed shape returns that key — dropped per #778 review. Accepting it meant a genuine future drift *to* that key was the one shape the guard waved through. - `includeDecommissioned: false` preserves the route's own default (`core.ts:557`); a decommissioned box is not a collector candidate. Also from review: - **Partial drift no longer passes silently.** The row check was all-or-nothing, so a body with some recognized and some drifted rows dropped the remainder from the dropdown with no signal — the same "my agent isn't in the list" symptom, and harder to spot than a total failure. Any unusable row is drift. - **`id` is now validated.** The type predicate asserted `AgentDevice` on label fields alone, but `id` is what the collector save actually submits — a row without one rendered an option with an undefined value. - **No throw on a malformed row.** `Object.keys(rows[0])` raised a TypeError on `{data:[null]}`, which the enclosing bare `catch` discarded — losing the diagnostic and aborting unrelated sections of the load. - **`agentsTruncated` can no longer go stale**, so the notice cannot assert "cut short" about a reload that loaded nothing. - **Device status is translated** from the `devices` namespace rather than rendering the raw pgEnum, so a German operator no longer gets "(offline)" inside otherwise-localized copy. Carries the `/* i18n-dynamic */ ns:key` marker the keyUsage gate requires. Tests: 23 in the file, up from 19. New coverage for the multi-page walk, the safety ceiling, partial drift, missing id, the decommissioned filter, and the stale-notice case. Pinned by revert: flattening the row validator fails 6 tests, reverting to a single capped page fails 2, flattening the status label fails 1 — while the empty-fleet case keeps passing under all three, which is what proves it guards against false positives. Verification: vitest src/components/integrations/ + src/lib/ → 72 files / 1072 passed. tsc --noEmit clean, eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Scope widened (approved): the three follow-ups I'd flagged as out-of-scope are now folded into this PR rather than filed separately. It is no longer a label-only change — see the rewritten description. Review run: Findings: 8 raised → all addressed in The one that changed the design: the truncation notice I'd added was a label on a broken control, not a fix. That cascaded usefully: Others addressed:
Not taken (deliberate): routing the drift diagnostic to Sentry as Tests: 23 in the file (from 9 originally). Every fix pinned by revert — flattening the row validator fails 6, reverting to a single capped page fails 2, flattening the status label fails 1, and the two original label reverts fail 2 each. The empty-fleet case keeps passing under all of them, which is what proves it guards against false positives rather than re-asserting the fix.
Status: review-clean, awaiting maintainer merge. Not merged; #3121 left open. |
Problem
Both Collector agent dropdowns in the UniFi integration settings — the self-hosted controller registration form (
unifi-controller-agent) and the per-console collector rows under Site mapping (unifi-collector-agent) — listed agents by raw device UUID (e.g.6eae0f70-8da9-49ff-9e18-c241698975f3) instead of a human-readable name. There was no way to tell which agent you were picking.Root cause
UnifiIntegration.tsxdeclared a localAgentDeviceinterface withname: string | nulland rendered{a.name ?? a.id}. ButGET /devicesreturnshostnameanddisplayName— there is nonamefield in the response. Because that interface is a structural assertion over an untyped JSON body rather than a checked contract, the mismatch typechecked silently and the?? a.idfallback fired for every row.Scope
This started as a label-only fix. Two review rounds surfaced further defects on the same code path — all the same failure family, the picker showing the operator something other than the truth — so they were folded in rather than filed separately.
1. Labels (the reported bug)
AgentDevicecorrected to mirror the API.agentLabel()rendersdisplayName?.trim() || hostname?.trim() || id, matching the primary device surfaces (DeviceList,DeviceDetails,NetworkChangesPanel).||with a trim, not??:displayName: ""survives both device write schemas (no.min(1), no trim), and a blank<option>is worse than the UUID it replaced — indistinguishable from the— Select agent —placeholder and from every other blank row.Option
valueis unchanged (still the device id), so existing saved collector assignments are unaffected.2. The list was capped at one page — now it walks the cursor
apps/web/src/lib/devicesFetch.tsalready exportsfetchAllDevices(), the reviewed keyset-cursor walker from #742/#778. This picker was instead taking a single?limit=500page.That matters because the picker filters by site client-side: a partner past the ceiling could not reach their agent at all. An earlier revision of this PR added a "list may be incomplete" notice — but CLAUDE.md targets 10,000+ agents, so that notice would have been permanently lit on exactly the fleets where the picker was useless. A label on a broken control is not a fix. The walk now runs to completion; "truncated" means only the walker's 40,000-row safety ceiling.
Fallout, all improvements:
AGENT_LIST_LIMITdeleted — it could not prevent the copy drift it was introduced to prevent (the server clamps atDEVICES_LIST_HARD_MAX=1000).{ devices: [...] }envelope branch is gone.devicesFetch.ts:139-141records that no deployed shape returns that key (dropped per feat(web): DevicesPage walks /devices cursor (#742 PR 3b — web) #778 review); accepting it meant a genuine future drift to that key was the one shape the guard waved through. The test that pinned it as valid now pins it as drift.includeDecommissioned: falsepreserves the route's own default — a decommissioned box is not a collector candidate.3. Response-shape drift was swallowed
The body was unwrapped with an inline
??chain that collapsed any unrecognized shape to[]whileres.okwas true, pushing nothing tofailed— an empty dropdown with zero diagnostics, strictly worse than the UUIDs.validateAgentRows()now returns a discriminated union:id/hostname/displayName— the #3121 shape, arriving as data instead of as a type errortotal: 0)Two things review corrected here: the check was originally all-or-nothing, so partial drift dropped the bad rows silently (a shorter list and no signal — harder to spot than a total failure); and
idwas never validated even though it is what the collector save actually submits.4.
AgentDevice.statuswas declared but never consultedAn offline agent was selectable as a collector with no indication — and a collector polls the controller from the agent, so that silently never polls. Non-online agents are now annotated (
Closet (Offline)), translated out of thedevicesnamespace so a German operator doesn't get an English(offline)inside otherwise-localized copy.Annotate rather than filter: an agent that is down right now is still a legitimate thing to configure, the operator just has to be able to see it.
Tests
23 cases in the file, each pinned by revert:
a.name ?? a.id(original)displayName ?? hostname(no trim/||)agentOptionLabelThe empty-fleet case keeps passing under all of those reverts — which is what proves it guards against false positives rather than just re-asserting the fix.
The label tests also select a site first, so the select is actually enabled and the
a.siteId === siteIdfilter branch is exercised. A pre-existing mock used a fictional{ id, name, siteId }device shape — corrected to the real API shape; that fake mock is why the suite never caught this.Verification:
vitest run src/components/integrations/ src/lib/→ 72 files / 1072 tests passed (includeslocaleParity,translationCoverage,keyUsage,no-envelope-fallthrough).tsc --noEmitclean,eslintclean. No.astrofiles touched.Closes #3121
🤖 Generated with Claude Code