fix(web): stop dynamic device groups blanking the /devices/groups page - #3179
Merged
Conversation
Creating a dynamic group from the Auto-membership Filter builder made
/devices/groups render blank, permanently, across reloads — until the
offending row was deleted from the database.
Two causes, both required to reproduce:
1. The group form persisted a phantom legacy `rules` stub alongside the
real `filterConditions`. Switching the type toggle to "dynamic" seeded
`groupForm.rules` with `[{field:"os",operator:"is",value:"windows"}]`,
and the submit handler sent it. The form has no legacy rule editor, so
that stub was pure fabrication — and the list page then rendered it as
the group's membership while the server evaluated the real filter.
2. The legacy client-side matcher read `device.os.toLowerCase()`. The
devices list endpoint returns `osType` and has never returned `os`, so
the read threw for every device. The throw escaped render, React
unmounted the Astro island, and the page went blank with no error
boundary to catch it.
`rules` is inert server-side: membership evaluation in routes/groups.ts,
services/groupMembership.ts and events/deviceEvents.ts reads only
`filterConditions`. The column is storage for pre-FilterBuilder rows.
So the web app now never writes `rules`, and prefers `filterConditions`
on read — which also un-breaks rows already carrying a bad stub.
- extract the legacy matcher into deviceGroupMatching.ts, defensive on
every device field, every rule field, and the rule value; read `osType`
with an `os` fallback
- skip the legacy matcher entirely when a group has `filterConditions`
(it understands 4 of ~40 filter fields; the server owns that answer)
- describe the real filter on the group card via describeFilterConditions
instead of relabelling it through the four-field legacy vocabulary
- fix the two other `device.os` reads in the device chips and picker
Predates the current release: DeviceGroupsPage.tsx last changed in #2944,
shipped in v0.103.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
b23fcfc
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://aea828d9.breeze-9te.pages.dev |
| Branch Preview URL: | https://fix-device-groups-blank-page.breeze-9te.pages.dev |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptom
Creating a dynamic device group via the Auto-membership Filter builder white-screens the entire
/devices/groupspage — persistently, across reloads — until the offending group row is deleted from the database. Reproduced live in a browser.There is no error boundary on this island, so the throw unmounts the whole React tree and the page renders blank.
This is not a regression from the current release.
DeviceGroupsPage.tsxlast changed in #2944, which shipped in v0.103.0 — the bug is already in production.Root causes
Both are required to reproduce; either one alone leaves a sharp edge.
1. The create form persisted a phantom legacy
rulesstub.Clicking the "Dynamic" type toggle seeded
groupForm.ruleswith[{ field: "os", operator: "is", value: "windows" }], andhandleSubmitGroupsent it alongside thefilterConditionsthe user actually configured. The modal has no legacy rule editor — the only membership UI is the FilterBuilder — so anything the form sent inruleswas fabricated. The list page then rendered that stub as the group's membership while the server evaluated the real filter.2. The legacy client-side matcher read a field that does not exist on the wire.
matchesRuleevaluateddevice.os.toLowerCase(), but/devicesreturnsosType(apps/api/src/routes/devices/core.ts) and has never returnedos—osonly ever existed in this page's hand-written local type. Every device threw. The same mismatch affectedosLabels[device.os]in the group device chips and the assignment picker (renderedundefinedrather than throwing), andsiteNameis likewise absent from the list payload.Which field is authoritative:
rulesorfilterConditions?Swept both names across
apps/api/src/**,apps/web/src/**andpackages/shared/src/**:filterConditionsis the only representation the server evaluates —routes/groups.ts(validate / preview / membership recompute),services/groupMembership.ts, andevents/deviceEvents.tsall key off it and ignorerules.rulesis inert storage: the API accepts it asz.any(), writes it, and echoes it back inmapGroupRow. Nothing reads it for evaluation.So
rulesis back-compat storage for pre-FilterBuilder rows, not a live input. The fix does both halves accordingly:rulesagain (create, edit, and the drag-and-dropupdateGroup). Omitting the key leaves whatever a legacy row already had — nothing is destroyed.filterConditionswins when present, for both the rendered chips and client-side membership. That matters operationally: it un-breaks rows that already carry a bad stub, without a database delete.Fixes
deviceGroupMatching.tsand made it total: no assumption that any device field, any rule field, orrule.valueexists. ReadsosTypewith anosfallback.getGroupDeviceIdsno longer runs the legacy matcher when a group hasfilterConditions— that matcher understands 4 of the ~40 filter fields, so guessing would report a membership the server disagrees with.describeFilterConditions, reusing the FilterBuilder's own field and operator labels. The pre-existingfilterConditionsToLegacyRuleswas not reused for display: round-tripping a real filter through the four-field legacy vocabulary silently relabels most conditions as "Hostname contains …".device.osreads, and fell back tositeNameByIdfor the picker's site label sincesiteNameisn't in the list payload.buildRule/ruleOperatorOptions/createIdand therulesfield on the form state.Error boundary
Skipped — no precedent to follow. The repo has exactly one error boundary,
ExtensionElementErrorBoundaryinapps/web/src/components/extensions/ExtensionElementHost.tsx. It is bespoke to extension elements (rendersExtensionUnavailable, tags Sentry withextension.name/extension.element) and is not a reusable island wrapper. There is no generic Astro-island boundary pattern, so adding one here would be inventing a pattern rather than following one. Worth doing as its own change if we want islands to fail soft app-wide.Tests
New, co-located, Vitest + jsdom:
DeviceGroupsPage.dynamicGroups.test.tsx(5) — renders with rules naming an absent field; survives a rule with no value; matches viaosType; shows the filter instead of a stale stub; asserts the create POST body has noruleskey.deviceGroupMatching.test.ts(15) — the pure matcher:osType/os/absent, bare devices for all four rule fields, missing/blank values, non-arraytags, invalid regex fallback, malformed rule lists.Verified in both directions. Against the pre-fix source all 5 component tests fail — 3 with the exact production
TypeError(Cannot read properties of undefined (reading 'toLowerCase')atDeviceGroupsPage.tsx:470, plus areading 'trim'variant), and the stub test withexpected { name: 'Web Servers', …(6) } to not have property "rules". After the fix, all pass.tsc --noEmitclean on@breeze/web;eslintclean on all five touched files.🤖 Generated with Claude Code