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[] {