Skip to content

fix(web): stop dynamic device groups blanking the /devices/groups page - #3179

Merged
ToddHebebrand merged 1 commit into
mainfrom
fix/device-groups-blank-page
Aug 6, 2026
Merged

fix(web): stop dynamic device groups blanking the /devices/groups page#3179
ToddHebebrand merged 1 commit into
mainfrom
fix/device-groups-blank-page

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

Symptom

Creating a dynamic device group via the Auto-membership Filter builder white-screens the entire /devices/groups page — persistently, across reloads — until the offending group row is deleted from the database. Reproduced live in a browser.

TypeError: Cannot read properties of undefined (reading 'toLowerCase')
    at matchesRule

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.tsx last 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 rules stub.
Clicking the "Dynamic" type toggle seeded groupForm.rules with [{ field: "os", operator: "is", value: "windows" }], and handleSubmitGroup sent it alongside the filterConditions the user actually configured. The modal has no legacy rule editor — the only membership UI is the FilterBuilder — so anything the form sent in rules was 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.
matchesRule evaluated device.os.toLowerCase(), but /devices returns osType (apps/api/src/routes/devices/core.ts) and has never returned osos only ever existed in this page's hand-written local type. Every device threw. The same mismatch affected osLabels[device.os] in the group device chips and the assignment picker (rendered undefined rather than throwing), and siteName is likewise absent from the list payload.

Which field is authoritative: rules or filterConditions?

Swept both names across apps/api/src/**, apps/web/src/** and packages/shared/src/**:

  • filterConditions is the only representation the server evaluates — routes/groups.ts (validate / preview / membership recompute), services/groupMembership.ts, and events/deviceEvents.ts all key off it and ignore rules.
  • rules is inert storage: the API accepts it as z.any(), writes it, and echoes it back in mapGroupRow. Nothing reads it for evaluation.

So rules is back-compat storage for pre-FilterBuilder rows, not a live input. The fix does both halves accordingly:

  • Write path: the web app never sends rules again (create, edit, and the drag-and-drop updateGroup). Omitting the key leaves whatever a legacy row already had — nothing is destroyed.
  • Read path: filterConditions wins 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

  • Extracted the legacy matcher into deviceGroupMatching.ts and made it total: no assumption that any device field, any rule field, or rule.value exists. Reads osType with an os fallback.
  • getGroupDeviceIds no longer runs the legacy matcher when a group has filterConditions — that matcher understands 4 of the ~40 filter fields, so guessing would report a membership the server disagrees with.
  • Group cards describe the real filter via a new describeFilterConditions, reusing the FilterBuilder's own field and operator labels. The pre-existing filterConditionsToLegacyRules was not reused for display: round-tripping a real filter through the four-field legacy vocabulary silently relabels most conditions as "Hostname contains …".
  • Fixed the two remaining device.os reads, and fell back to siteNameById for the picker's site label since siteName isn't in the list payload.
  • Removed the now-dead buildRule / ruleOperatorOptions / createId and the rules field on the form state.

Error boundary

Skipped — no precedent to follow. The repo has exactly one error boundary, ExtensionElementErrorBoundary in apps/web/src/components/extensions/ExtensionElementHost.tsx. It is bespoke to extension elements (renders ExtensionUnavailable, tags Sentry with extension.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 via osType; shows the filter instead of a stale stub; asserts the create POST body has no rules key.
  • deviceGroupMatching.test.ts (15) — the pure matcher: osType/os/absent, bare devices for all four rule fields, missing/blank values, non-array tags, 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') at DeviceGroupsPage.tsx:470, plus a reading 'trim' variant), and the stub test with expected { name: 'Web Servers', …(6) } to not have property "rules". After the fix, all pass.

vitest run src/components/devices/{DeviceGroupsPage.dynamicGroups,deviceGroupMatching,DeviceGroupsPage}.test.* \
           src/components/filters src/lib/__tests__/no-silent-mutations.test.ts
  Test Files  4 passed (4)      Tests  116 passed (116)

vitest run src/lib/i18n
  Test Files  6 passed (6)      Tests   96 passed (96)

tsc --noEmit clean on @breeze/web; eslint clean on all five touched files.

🤖 Generated with Claude Code

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>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

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

View logs

@ToddHebebrand
ToddHebebrand merged commit 38150ac into main Aug 6, 2026
55 checks passed
@ToddHebebrand
ToddHebebrand deleted the fix/device-groups-blank-page branch August 6, 2026 16:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant