diff --git a/apps/web/src/components/devices/DeviceGroupsPage.dynamicGroups.test.tsx b/apps/web/src/components/devices/DeviceGroupsPage.dynamicGroups.test.tsx
new file mode 100644
index 000000000..90460238a
--- /dev/null
+++ b/apps/web/src/components/devices/DeviceGroupsPage.dynamicGroups.test.tsx
@@ -0,0 +1,183 @@
+import '@/lib/i18n';
+
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+import DeviceGroupsPage from './DeviceGroupsPage';
+import { fetchWithAuth } from '../../stores/auth';
+
+vi.mock('../../stores/auth', () => ({
+ fetchWithAuth: vi.fn(),
+}));
+
+vi.mock('../../hooks/useFilterPreview', () => ({
+ useFilterPreview: () => ({
+ preview: null,
+ loading: false,
+ error: undefined,
+ refresh: vi.fn(),
+ }),
+}));
+
+const mockFetch = vi.mocked(fetchWithAuth);
+
+const jsonResponse = (body: unknown) =>
+ ({ ok: true, status: 200, json: async () => body }) as unknown as Response;
+
+/**
+ * The devices list endpoint returns `osType` — there has never been an `os`
+ * key on the wire (apps/api/src/routes/devices/core.ts). This page's local
+ * `Device` type claimed `os`, which is what made the legacy matcher throw.
+ */
+const DEVICES = [
+ { id: 'device-1', hostname: 'web-01', osType: 'windows', siteId: 'site-1' },
+ { id: 'device-2', hostname: 'db-01', osType: 'linux', siteId: 'site-1' },
+];
+
+const SUPPORTING_RESPONSES: Record = {
+ '/devices': { data: DEVICES, pagination: { page: 1, limit: 50, total: DEVICES.length } },
+ '/orgs/sites': { data: [{ id: 'site-1', name: 'HQ' }], pagination: { page: 1, limit: 50, total: 1 } },
+ '/policies': { data: [], pagination: { page: 1, limit: 50, total: 0 } },
+ '/scripts': { data: [], pagination: { page: 1, limit: 50, total: 0 } },
+};
+
+/** Serve the given groups plus the fixed supporting endpoints. */
+const serveGroups = (groups: unknown[]) => {
+ mockFetch.mockImplementation(async (url: string, init?: RequestInit) => {
+ const path = String(url).split('?')[0];
+ if (path === '/device-groups' && (init?.method ?? 'GET') === 'GET') {
+ return jsonResponse({ data: groups, total: groups.length });
+ }
+ if (path === '/device-groups') return jsonResponse({ data: { id: 'new-group' } });
+ if (path in SUPPORTING_RESPONSES) return jsonResponse(SUPPORTING_RESPONSES[path]);
+ return { ok: false, status: 404, json: async () => ({}) } as unknown as Response;
+ });
+};
+
+/**
+ * Matches the whose whitespace-collapsed text is exactly `text`. The
+ * tag check keeps a wrapping with the same textContent from making the
+ * query ambiguous.
+ */
+const exactText = (text: string) => (_content: string, element: Element | null) =>
+ element?.tagName === 'SPAN' && element.textContent?.replace(/\s+/g, ' ').trim() === text;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('DeviceGroupsPage dynamic groups', () => {
+ it('renders the page when a dynamic group\'s legacy rules name a field the device payload lacks', async () => {
+ // Reproduces the live crash: `matchesRule` read `device.os.toLowerCase()`
+ // on a payload that only carries `osType`, throwing during render and
+ // unmounting the whole island (blank page, persistent across reloads).
+ serveGroups([
+ {
+ id: 'group-1',
+ name: 'Windows Fleet',
+ type: 'dynamic',
+ rules: [{ id: 'rule-1', field: 'os', operator: 'is', value: 'windows' }],
+ },
+ ]);
+
+ render();
+
+ expect(await screen.findByText('Windows Fleet')).toBeInTheDocument();
+ });
+
+ it('survives rules whose value is missing entirely', async () => {
+ serveGroups([
+ {
+ id: 'group-1',
+ name: 'Broken Rule Group',
+ type: 'dynamic',
+ rules: [{ id: 'rule-1', field: 'hostname', operator: 'contains' }],
+ },
+ ]);
+
+ render();
+
+ expect(await screen.findByText('Broken Rule Group')).toBeInTheDocument();
+ });
+
+ it('matches devices by the osType field the devices API actually returns', async () => {
+ serveGroups([
+ {
+ id: 'group-1',
+ name: 'Windows Fleet',
+ type: 'dynamic',
+ rules: [{ id: 'rule-1', field: 'os', operator: 'is', value: 'windows' }],
+ },
+ ]);
+
+ render();
+
+ await screen.findByText('Windows Fleet');
+ // Only device-1 is Windows — a count of 0 would mean the osType read is
+ // still wrong, a throw would mean the crash is back.
+ expect(screen.getByText(exactText('Matches 1 device'))).toBeInTheDocument();
+ });
+
+ it('shows the configured filter conditions instead of a stale legacy rules stub', async () => {
+ serveGroups([
+ {
+ id: 'group-1',
+ name: 'Web Servers',
+ type: 'dynamic',
+ // The phantom stub the create form used to persist alongside the real
+ // filter. It must never be what the card describes.
+ rules: [{ id: 'rule-1', field: 'os', operator: 'is', value: 'windows' }],
+ filterConditions: {
+ operator: 'AND',
+ conditions: [{ field: 'hostname', operator: 'contains', value: 'web' }],
+ },
+ },
+ ]);
+
+ render();
+
+ await screen.findByText('Web Servers');
+ expect(screen.getByText(exactText('Hostname contains web'))).toBeInTheDocument();
+ expect(screen.queryByText(exactText('OS is windows'))).not.toBeInTheDocument();
+ });
+
+ it('does not send a legacy rules stub when creating a dynamic group from the filter builder', async () => {
+ const user = userEvent.setup();
+ serveGroups([
+ { id: 'group-1', name: 'Existing', type: 'static', deviceIds: [], deviceCount: 0 },
+ ]);
+
+ render();
+ await screen.findByText('Existing');
+
+ await user.click(screen.getByRole('button', { name: 'Create Group' }));
+ await user.type(screen.getByPlaceholderText('e.g. Production Linux'), 'Web Servers');
+ await user.click(screen.getByRole('button', { name: 'Dynamic' }));
+ await user.type(await screen.findByTestId('value-text-input'), 'web');
+ await user.click(screen.getByRole('button', { name: 'Create group' }));
+
+ await waitFor(() => {
+ expect(
+ mockFetch.mock.calls.some(
+ ([url, init]) =>
+ String(url) === '/device-groups' &&
+ (init as RequestInit | undefined)?.method === 'POST',
+ ),
+ ).toBe(true);
+ });
+
+ const post = mockFetch.mock.calls.find(
+ ([url, init]) =>
+ String(url) === '/device-groups' &&
+ (init as RequestInit | undefined)?.method === 'POST',
+ );
+ const body = JSON.parse(String((post?.[1] as RequestInit).body));
+
+ expect(body.filterConditions).toEqual({
+ operator: 'AND',
+ conditions: [{ field: 'hostname', operator: 'contains', value: 'web' }],
+ });
+ expect(body).not.toHaveProperty('rules');
+ });
+});
diff --git a/apps/web/src/components/devices/DeviceGroupsPage.tsx b/apps/web/src/components/devices/DeviceGroupsPage.tsx
index 67e634843..5bc73b9d3 100644
--- a/apps/web/src/components/devices/DeviceGroupsPage.tsx
+++ b/apps/web/src/components/devices/DeviceGroupsPage.tsx
@@ -13,37 +13,37 @@ import type { FilterConditionGroup } from "@breeze/shared";
import { FilterBuilder, DEFAULT_FILTER_FIELDS } from "../filters/FilterBuilder";
import { FilterPreview } from "../filters/FilterPreview";
import { useFilterPreview } from "../../hooks/useFilterPreview";
-import { legacyRulesToFilterConditions } from "./filterMigration";
+import {
+ describeFilterConditions,
+ legacyRulesToFilterConditions,
+} from "./filterMigration";
+import {
+ deviceOsType,
+ matchDeviceIds,
+ type DeviceGroupRule,
+} from "./deviceGroupMatching";
import { useTranslation } from "react-i18next";
import "../../lib/i18n";
type OSType = "windows" | "macos" | "linux";
+/**
+ * Whatever `/devices` returned. The list endpoint reports `osType` (never
+ * `os`) and omits `siteName` entirely, so every field beyond `id` is optional
+ * and read through a helper rather than dereferenced.
+ */
type Device = {
id: string;
- hostname: string;
- os: OSType;
+ hostname?: string;
+ osType?: OSType;
+ /** Legacy alias this page used to assume; tolerated on read only. */
+ os?: OSType;
siteId?: string;
siteName?: string;
tags?: string[];
};
type GroupType = "static" | "dynamic";
-type RuleField = "os" | "site" | "tag" | "hostname";
-type RuleOperator =
- | "is"
- | "is_not"
- | "contains"
- | "not_contains"
- | "matches"
- | "not_matches";
-
-type DeviceGroupRule = {
- id: string;
- field: RuleField;
- operator: RuleOperator;
- value: string;
-};
type DeviceGroup = {
id: string;
@@ -53,7 +53,12 @@ type DeviceGroup = {
deviceCount?: number;
deviceIds?: string[];
devices?: Device[];
+ /**
+ * Legacy, read-only. The web app has not authored these since the
+ * FilterBuilder landed — see `handleSubmitGroup`.
+ */
rules?: DeviceGroupRule[];
+ filterConditions?: FilterConditionGroup | null;
policyId?: string;
policyName?: string;
policy?: { id: string; name: string };
@@ -87,7 +92,6 @@ type GroupFormState = {
description: string;
type: GroupType;
policyId: string;
- rules: DeviceGroupRule[];
deviceIds: string[];
filterConditions: FilterConditionGroup;
};
@@ -103,40 +107,18 @@ const osLabels: Record = {
linux: "Linux",
};
-const ruleOperatorOptions: Record<
- RuleField,
- Array<{ value: RuleOperator; label: string }>
-> = {
- os: [
- { value: "is", label: "is" },
- { value: "is_not", label: "is not" },
- ],
- site: [
- { value: "is", label: "is" },
- { value: "is_not", label: "is not" },
- ],
- tag: [
- { value: "contains", label: "contains" },
- { value: "not_contains", label: "does not contain" },
- ],
- hostname: [
- { value: "contains", label: "contains" },
- { value: "not_contains", label: "does not contain" },
- { value: "matches", label: "matches regex" },
- { value: "not_matches", label: "does not match regex" },
- ],
-};
-
-let idCounter = 0;
-const createId = (prefix: string = "id") => {
- idCounter += 1;
- return `${prefix}-${idCounter}`;
+/** OS label for a device, tolerating the missing/renamed OS field. */
+const osLabel = (device: Device): string => {
+ const os = deviceOsType(device);
+ return osLabels[os as OSType] ?? os;
};
const normalizeGroup = (group: DeviceGroup): DeviceGroup => {
const inferredType: GroupType =
group.type ??
- (group.rules && group.rules.length > 0 ? "dynamic" : "static");
+ (group.filterConditions || (group.rules && group.rules.length > 0)
+ ? "dynamic"
+ : "static");
const policyId = group.policyId ?? group.policy?.id ?? "";
const policyName = group.policyName ?? group.policy?.name ?? "";
const deviceIds =
@@ -221,7 +203,6 @@ export default function DeviceGroupsPage() {
description: "",
type: "static",
policyId: "",
- rules: [],
deviceIds: [],
filterConditions: EMPTY_FILTER,
});
@@ -283,9 +264,13 @@ export default function DeviceGroupsPage() {
const query = assignmentQuery.trim().toLowerCase();
if (!query) return devices;
return devices.filter((device) => {
- const matchesHostname = device.hostname.toLowerCase().includes(query);
+ const matchesHostname = String(device.hostname ?? "")
+ .toLowerCase()
+ .includes(query);
const matchesTag = device.tags?.some((tag: string) =>
- tag.toLowerCase().includes(query),
+ String(tag ?? "")
+ .toLowerCase()
+ .includes(query),
);
return matchesHostname || matchesTag;
});
@@ -383,38 +368,21 @@ export default function DeviceGroupsPage() {
});
}, [groups]);
- const buildRule = (field: RuleField = "os"): DeviceGroupRule => {
- const defaultOperator = ruleOperatorOptions[field][0]?.value ?? "is";
- const defaultValue =
- field === "os"
- ? t("deviceGroupsPage.windows")
- : field === "site"
- ? (siteOptions[0]?.id ?? "")
- : field === "tag"
- ? (tagOptions[0] ?? "")
- : "";
- return {
- id: createId(),
- field,
- operator: defaultOperator,
- value: defaultValue,
- };
- };
-
const resetForm = (group?: DeviceGroup) => {
if (group) {
- // Migrate legacy rules to filter conditions if needed
+ // The filter is the authored representation. Legacy `rules` are only a
+ // seed for groups that predate the FilterBuilder and have no filter yet.
const filterConditions =
- group.rules && group.rules.length > 0
+ group.filterConditions ??
+ (group.rules && group.rules.length > 0
? legacyRulesToFilterConditions(group.rules)
- : EMPTY_FILTER;
+ : EMPTY_FILTER);
setGroupForm({
name: group.name ?? "",
description: group.description ?? "",
type: group.type ?? "static",
policyId: group.policyId ?? "",
- rules: group.rules ? [...group.rules] : [],
deviceIds: group.deviceIds
? [...group.deviceIds]
: (group.devices?.map((device) => device.id) ?? []),
@@ -426,7 +394,6 @@ export default function DeviceGroupsPage() {
description: "",
type: "static",
policyId: "",
- rules: [],
deviceIds: [],
filterConditions: EMPTY_FILTER,
});
@@ -462,59 +429,16 @@ export default function DeviceGroupsPage() {
setDeleteReassignGroupId("");
};
- const matchesRule = (device: Device, rule: DeviceGroupRule): boolean => {
- const normalizedValue = rule.value.trim().toLowerCase();
- if (!normalizedValue) return false;
-
- if (rule.field === "os") {
- const match = device.os.toLowerCase() === normalizedValue;
- return rule.operator === "is" ? match : !match;
- }
-
- if (rule.field === "site") {
- const siteIdMatch = device.siteId?.toLowerCase() === normalizedValue;
- const siteNameMatch = device.siteName?.toLowerCase() === normalizedValue;
- const match = siteIdMatch || siteNameMatch;
- return rule.operator === "is" ? match : !match;
- }
-
- if (rule.field === "tag") {
- const hasTag =
- device.tags?.some(
- (tag: string) => tag.toLowerCase() === normalizedValue,
- ) ?? false;
- return rule.operator === "contains" ? hasTag : !hasTag;
- }
-
- const hostname = device.hostname.toLowerCase();
- if (rule.operator === "contains" || rule.operator === "not_contains") {
- const match = hostname.includes(normalizedValue);
- return rule.operator === "contains" ? match : !match;
- }
-
- const regexMatch = (() => {
- try {
- return new RegExp(rule.value, "i").test(device.hostname);
- } catch {
- return hostname.includes(normalizedValue);
- }
- })();
- return rule.operator === "matches" ? regexMatch : !regexMatch;
- };
-
- const getDynamicDeviceIds = (rules: DeviceGroupRule[] = []): string[] => {
- if (rules.length === 0) return [];
- return devices
- .filter((device) => rules.every((rule) => matchesRule(device, rule)))
- .map((device) => device.id);
- };
-
const getGroupDeviceIds = (group: DeviceGroup): string[] => {
if (group.type === "dynamic") {
if (group.deviceIds && group.deviceIds.length > 0) {
return group.deviceIds;
}
- return getDynamicDeviceIds(group.rules ?? []);
+ // A filter-authored group is the server's to evaluate — the legacy
+ // matcher only understands four of the forty filter fields, so guessing
+ // here would report a membership the server disagrees with.
+ if (group.filterConditions) return [];
+ return matchDeviceIds(devices, group.rules);
}
return group.deviceIds ?? group.devices?.map((device) => device.id) ?? [];
};
@@ -531,11 +455,12 @@ export default function DeviceGroupsPage() {
overrides: Partial = {},
) => {
const nextGroup = { ...group, ...overrides };
+ // No `rules` key: this is a device-assignment write, and omitting the
+ // legacy column leaves whatever a pre-FilterBuilder group already had.
const payload = {
name: nextGroup.name,
description: nextGroup.description ?? "",
type: nextGroup.type,
- rules: nextGroup.type === "dynamic" ? (nextGroup.rules ?? []) : [],
deviceIds: nextGroup.type === "static" ? (nextGroup.deviceIds ?? []) : [],
policyId: nextGroup.policyId || null,
};
@@ -574,11 +499,17 @@ export default function DeviceGroupsPage() {
setFormError(undefined);
try {
+ // `filterConditions` is the only membership representation this form
+ // authors. It deliberately does NOT send `rules`: the form has no legacy
+ // rule editor, so anything it sent would be a fabricated stub — and the
+ // page then rendered that stub as the group's membership while the
+ // server evaluated the real filter. The server evaluates only
+ // `filterConditions` (routes/groups.ts, services/groupMembership.ts);
+ // `rules` is inert storage kept for pre-FilterBuilder rows.
const payload = {
name: trimmedName,
description: groupForm.description.trim(),
type: groupForm.type,
- rules: groupForm.type === "dynamic" ? groupForm.rules : [],
filterConditions:
groupForm.type === "dynamic" ? groupForm.filterConditions : null,
deviceIds: groupForm.type === "static" ? groupForm.deviceIds : [],
@@ -906,6 +837,14 @@ export default function DeviceGroupsPage() {
.map((id) => deviceById.get(id))
.filter((device): device is Device => Boolean(device));
const deviceCount = getGroupDeviceCount(group);
+ // Describe what the server actually evaluates. Legacy `rules`
+ // are only shown for rows that have no filter — otherwise a
+ // stale stub would misreport the group's membership.
+ const membershipChips = group.filterConditions
+ ? describeFilterConditions(group.filterConditions)
+ : (group.rules ?? []).map((rule) =>
+ buildRuleLabel(rule, siteNameById),
+ );
const isSelected = selectedGroupIds.has(group.id);
const isDragOver = dragOverGroupId === group.id;
@@ -984,14 +923,14 @@ export default function DeviceGroupsPage() {
{deviceCount === 1 ? "" : t("deviceGroupsPage.s")}
- {group.rules && group.rules.length > 0 ? (
+ {membershipChips.length > 0 ? (
- {group.rules.map((rule) => (
+ {membershipChips.map((chip, index) => (
- {buildRuleLabel(rule, siteNameById)}
+ {chip}
))}
@@ -1056,7 +995,7 @@ export default function DeviceGroupsPage() {
{device.hostname}
- {osLabels[device.os]}
+ {osLabel(device)}
))
)}
@@ -1188,8 +1127,6 @@ export default function DeviceGroupsPage() {
setGroupForm((prev) => ({
...prev,
type: "dynamic",
- rules:
- prev.rules.length > 0 ? prev.rules : [buildRule()],
filterConditions:
prev.filterConditions.conditions.length > 0
? prev.filterConditions
@@ -1306,7 +1243,11 @@ export default function DeviceGroupsPage() {
{device.hostname}
- {osLabels[device.os]} · {device.siteName}
+ {osLabel(device)} ·{" "}
+ {device.siteName ??
+ (device.siteId
+ ? (siteNameById.get(device.siteId) ?? "")
+ : "")}
diff --git a/apps/web/src/components/devices/deviceGroupMatching.test.ts b/apps/web/src/components/devices/deviceGroupMatching.test.ts
new file mode 100644
index 000000000..744e6a418
--- /dev/null
+++ b/apps/web/src/components/devices/deviceGroupMatching.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from 'vitest';
+
+import { deviceOsType, matchDeviceIds, matchesRule } from './deviceGroupMatching';
+
+describe('deviceOsType', () => {
+ it('reads osType, the field the devices API returns', () => {
+ expect(deviceOsType({ id: 'd1', osType: 'windows' })).toBe('windows');
+ });
+
+ it('falls back to the historical os key and to empty', () => {
+ expect(deviceOsType({ id: 'd1', os: 'linux' })).toBe('linux');
+ expect(deviceOsType({ id: 'd1' })).toBe('');
+ expect(deviceOsType(undefined)).toBe('');
+ });
+});
+
+describe('matchesRule', () => {
+ const windows = { id: 'd1', hostname: 'web-01', osType: 'windows', siteId: 'site-1' };
+
+ it('matches an os rule against osType', () => {
+ expect(matchesRule(windows, { field: 'os', operator: 'is', value: 'windows' })).toBe(true);
+ expect(matchesRule(windows, { field: 'os', operator: 'is', value: 'linux' })).toBe(false);
+ expect(matchesRule(windows, { field: 'os', operator: 'is_not', value: 'linux' })).toBe(true);
+ });
+
+ it('does not throw when the device lacks every field the rule names', () => {
+ const bare = { id: 'd1' };
+ for (const field of ['os', 'site', 'tag', 'hostname'] as const) {
+ expect(() =>
+ matchesRule(bare, { field, operator: 'is', value: 'anything' }),
+ ).not.toThrow();
+ }
+ expect(matchesRule(bare, { field: 'os', operator: 'is', value: 'windows' })).toBe(false);
+ expect(matchesRule(bare, { field: 'tag', operator: 'contains', value: 'prod' })).toBe(false);
+ });
+
+ it('does not throw on a rule with no value, and matches nothing', () => {
+ expect(matchesRule(windows, { field: 'os', operator: 'is' })).toBe(false);
+ expect(matchesRule(windows, { field: 'hostname', operator: 'contains', value: ' ' })).toBe(false);
+ expect(matchesRule(windows, null)).toBe(false);
+ expect(matchesRule(null, { field: 'os', operator: 'is', value: 'windows' })).toBe(false);
+ });
+
+ it('matches a site rule on either id or name', () => {
+ expect(matchesRule(windows, { field: 'site', operator: 'is', value: 'site-1' })).toBe(true);
+ expect(
+ matchesRule({ id: 'd1', siteName: 'HQ' }, { field: 'site', operator: 'is', value: 'hq' }),
+ ).toBe(true);
+ // No siteId and no siteName must not accidentally equal an empty rule value.
+ expect(matchesRule({ id: 'd1' }, { field: 'site', operator: 'is', value: 'hq' })).toBe(false);
+ });
+
+ it('tolerates a tags value that is not an array of strings', () => {
+ expect(
+ matchesRule({ id: 'd1', tags: 'prod' }, { field: 'tag', operator: 'contains', value: 'prod' }),
+ ).toBe(false);
+ expect(
+ matchesRule({ id: 'd1', tags: [null, 'prod'] }, { field: 'tag', operator: 'contains', value: 'prod' }),
+ ).toBe(true);
+ });
+
+ it('falls back to substring matching on an invalid regex', () => {
+ expect(
+ matchesRule(windows, { field: 'hostname', operator: 'matches', value: 'web-' }),
+ ).toBe(true);
+ expect(
+ matchesRule(windows, { field: 'hostname', operator: 'matches', value: '([' }),
+ ).toBe(false);
+ });
+});
+
+describe('matchDeviceIds', () => {
+ const devices = [
+ { id: 'd1', hostname: 'web-01', osType: 'windows' },
+ { id: 'd2', hostname: 'db-01', osType: 'linux' },
+ ];
+
+ it('ANDs the rules and returns matching ids', () => {
+ expect(matchDeviceIds(devices, [{ field: 'os', operator: 'is', value: 'windows' }])).toEqual(['d1']);
+ expect(
+ matchDeviceIds(devices, [
+ { field: 'os', operator: 'is', value: 'windows' },
+ { field: 'hostname', operator: 'contains', value: 'db' },
+ ]),
+ ).toEqual([]);
+ });
+
+ it('returns [] for absent, empty or malformed rule lists', () => {
+ expect(matchDeviceIds(devices, undefined)).toEqual([]);
+ expect(matchDeviceIds(devices, [])).toEqual([]);
+ expect(matchDeviceIds(devices, [null, undefined])).toEqual([]);
+ expect(matchDeviceIds(undefined, [{ field: 'os', operator: 'is', value: 'windows' }])).toEqual([]);
+ });
+});
diff --git a/apps/web/src/components/devices/deviceGroupMatching.ts b/apps/web/src/components/devices/deviceGroupMatching.ts
new file mode 100644
index 000000000..5ca108248
--- /dev/null
+++ b/apps/web/src/components/devices/deviceGroupMatching.ts
@@ -0,0 +1,123 @@
+/**
+ * Client-side evaluation of a device group's LEGACY `rules` array.
+ *
+ * Groups authored today store a rich `filterConditions` tree that only the
+ * server can evaluate (it reaches hardware, metrics and software tables), so
+ * this matcher exists purely to keep pre-filter groups showing a plausible
+ * member count until the server has computed one. Two rules follow from that:
+ *
+ * 1. It runs against whatever `/devices` returned, NOT against a type this
+ * page controls. The list endpoint returns `osType`; it has never returned
+ * `os`. Reading `device.os.toLowerCase()` threw for every device, the throw
+ * escaped render, and the whole Astro island unmounted — a permanently
+ * blank /devices/groups page until the offending group row was deleted.
+ * 2. Every field it touches — on the device AND on the rule — is treated as
+ * possibly absent. A malformed group row is a rendering nuisance, never a
+ * page-level crash.
+ */
+
+export type LegacyRuleField = 'os' | 'site' | 'tag' | 'hostname';
+
+export type LegacyRuleOperator =
+ | 'is'
+ | 'is_not'
+ | 'contains'
+ | 'not_contains'
+ | 'matches'
+ | 'not_matches';
+
+export interface DeviceGroupRule {
+ id: string;
+ field: LegacyRuleField;
+ operator: LegacyRuleOperator;
+ value: string;
+}
+
+/**
+ * The subset of a device the matcher reads, in the shape `/devices` actually
+ * returns. Everything but `id` is optional on purpose — the API omits fields
+ * (`siteName` is never in the list payload at all) and older rows carry nulls.
+ */
+export interface MatchableDevice {
+ id: string;
+ hostname?: string | null;
+ /** Canonical OS field from the API. */
+ osType?: string | null;
+ /** Only ever existed in this page's hand-written type; tolerated on read. */
+ os?: string | null;
+ siteId?: string | null;
+ siteName?: string | null;
+ tags?: unknown;
+}
+
+/** Reads the OS the devices API reports, tolerating the historical `os` key. */
+export function deviceOsType(device: MatchableDevice | null | undefined): string {
+ if (!device) return '';
+ return String(device.osType ?? device.os ?? '');
+}
+
+const normalize = (value: unknown): string => String(value ?? '').trim().toLowerCase();
+
+/**
+ * True when `device` satisfies `rule`. Never throws: an unknown field, a rule
+ * with no value, or a device missing the field all resolve to a boolean.
+ */
+export function matchesRule(
+ device: MatchableDevice | null | undefined,
+ rule: Partial | null | undefined,
+): boolean {
+ if (!device || !rule) return false;
+
+ const normalizedValue = normalize(rule.value);
+ if (!normalizedValue) return false;
+
+ if (rule.field === 'os') {
+ const match = normalize(deviceOsType(device)) === normalizedValue;
+ return rule.operator === 'is' ? match : !match;
+ }
+
+ if (rule.field === 'site') {
+ const siteIdMatch = device.siteId != null && normalize(device.siteId) === normalizedValue;
+ const siteNameMatch = device.siteName != null && normalize(device.siteName) === normalizedValue;
+ const match = siteIdMatch || siteNameMatch;
+ return rule.operator === 'is' ? match : !match;
+ }
+
+ if (rule.field === 'tag') {
+ const hasTag = Array.isArray(device.tags)
+ ? device.tags.some((tag) => normalize(tag) === normalizedValue)
+ : false;
+ return rule.operator === 'contains' ? hasTag : !hasTag;
+ }
+
+ // Default branch: hostname, and anything the group row names that this
+ // matcher doesn't know about (which is most of the filter vocabulary).
+ const rawHostname = String(device.hostname ?? '');
+ const hostname = rawHostname.toLowerCase();
+
+ if (rule.operator === 'contains' || rule.operator === 'not_contains') {
+ const match = hostname.includes(normalizedValue);
+ return rule.operator === 'contains' ? match : !match;
+ }
+
+ const regexMatch = (() => {
+ try {
+ return new RegExp(String(rule.value ?? ''), 'i').test(rawHostname);
+ } catch {
+ return hostname.includes(normalizedValue);
+ }
+ })();
+ return rule.operator === 'matches' ? regexMatch : !regexMatch;
+}
+
+/** Ids of the devices satisfying every rule. Empty when there are no rules. */
+export function matchDeviceIds(
+ devices: readonly MatchableDevice[] | null | undefined,
+ rules: ReadonlyArray | null | undefined> | null | undefined,
+): string[] {
+ const activeRules = Array.isArray(rules) ? rules.filter(Boolean) : [];
+ if (activeRules.length === 0) return [];
+ return (devices ?? [])
+ .filter((device) => activeRules.every((rule) => matchesRule(device, rule)))
+ .map((device) => device.id);
+}
diff --git a/apps/web/src/components/devices/filterMigration.ts b/apps/web/src/components/devices/filterMigration.ts
index b99bfb779..cad2eec88 100644
--- a/apps/web/src/components/devices/filterMigration.ts
+++ b/apps/web/src/components/devices/filterMigration.ts
@@ -1,4 +1,6 @@
-import type { FilterCondition, FilterConditionGroup } from '@breeze/shared';
+import type { FilterCondition, FilterConditionGroup, FilterOperator } from '@breeze/shared';
+import { FILTER_FIELDS } from '../filters/filterFields';
+import { OPERATOR_LABELS } from '../filters/OperatorSelector';
interface LegacyDeviceGroupRule {
id: string;
@@ -52,6 +54,62 @@ export function legacyRulesToFilterConditions(rules: LegacyDeviceGroupRule[]): F
return { operator: 'AND', conditions };
}
+const FIELD_LABELS = new Map(FILTER_FIELDS.map(field => [field.key, field.label]));
+
+const NO_VALUE_OPERATORS: FilterOperator[] = ['isNull', 'isNotNull', 'isEmpty', 'isNotEmpty'];
+
+function isConditionGroup(entry: FilterCondition | FilterConditionGroup): entry is FilterConditionGroup {
+ return entry !== null && typeof entry === 'object' && Array.isArray((entry as FilterConditionGroup).conditions);
+}
+
+function formatFilterValue(value: unknown): string {
+ if (value === null || value === undefined) return '';
+ if (Array.isArray(value)) return value.map(item => formatFilterValue(item)).filter(Boolean).join(', ');
+ if (typeof value === 'object') {
+ const record = value as Record;
+ if ('amount' in record && 'unit' in record) return `${record.amount} ${record.unit}`;
+ if ('from' in record && 'to' in record) return `${formatFilterValue(record.from)} - ${formatFilterValue(record.to)}`;
+ return '';
+ }
+ return String(value);
+}
+
+function describeEntry(entry: FilterCondition | FilterConditionGroup): string {
+ if (isConditionGroup(entry)) {
+ const inner = entry.conditions.map(describeEntry).filter(Boolean);
+ if (inner.length === 0) return '';
+ return `(${inner.join(entry.operator === 'OR' ? ' OR ' : ' AND ')})`;
+ }
+
+ const fieldLabel = FIELD_LABELS.get(entry.field) ?? entry.field;
+ const operatorLabel = OPERATOR_LABELS[entry.operator] ?? entry.operator;
+ if (NO_VALUE_OPERATORS.includes(entry.operator)) return `${fieldLabel} ${operatorLabel}`;
+
+ const value = formatFilterValue(entry.value);
+ return value ? `${fieldLabel} ${operatorLabel} ${value}` : '';
+}
+
+/**
+ * Human-readable chips for a `filterConditions` tree, using the same field and
+ * operator vocabulary the FilterBuilder shows.
+ *
+ * This is the honest counterpart to `filterConditionsToLegacyRules`: the legacy
+ * rule vocabulary covers four fields, the filter vocabulary covers forty, so
+ * round-tripping a real filter through legacy rules for DISPLAY silently
+ * relabels most conditions as "Hostname contains …". Describe the filter
+ * directly instead.
+ */
+export function describeFilterConditions(
+ conditions: FilterConditionGroup | null | undefined
+): string[] {
+ if (!conditions || !Array.isArray(conditions.conditions)) return [];
+ const parts = conditions.conditions.map(describeEntry).filter(Boolean);
+ // A top-level OR must not render as a list of chips — the card reads those
+ // as ANDed. Collapse it into one chip that states the operator.
+ if (conditions.operator === 'OR' && parts.length > 1) return [parts.join(' OR ')];
+ return parts;
+}
+
export function filterConditionsToLegacyRules(
conditions: FilterConditionGroup
): LegacyDeviceGroupRule[] {