diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index 88ff83ae..ce3c2616 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -15,7 +15,10 @@ import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdmin import { TenantScopeSelector } from "@/components/TenantScopeSelector"; import { isTemplateRule } from "@/lib/analyzeRuleTabs"; -import { AnalyzeRule, RuleForm, PastedAnalyzeJson, EMPTY_FORM, EMPTY_CONDITION, ruleToForm, jsonToForm, formToEvaluateOn, validateOnEventTypes } from "./types"; +import { groupRulesByCategory, categoryLabel } from "@/lib/ruleGroups"; +import { RuleCategoryGroups } from "@/components/rules/RuleCategoryGroups"; +import { useCollapsedGroups } from "@/hooks/useCollapsedGroups"; +import { AnalyzeRule, RuleForm, PastedAnalyzeJson, EMPTY_FORM, EMPTY_CONDITION, ruleToForm, jsonToForm, formToEvaluateOn, validateOnEventTypes, getCategoryColor } from "./types"; import { BLOCKED_INTERIM_TRIGGER_EVENT_TYPES } from "@/utils/guardrails.generated"; /** Pre-flight for the evaluation-trigger form fields; returns an error message or null. */ @@ -57,6 +60,8 @@ export default function AnalyzeRulesPage() { // Active tab: "rules" (all real rules) vs "templates" (copy blueprints) const [activeTab, setActiveTab] = useState<"rules" | "templates">("rules"); + // Collapsed category groups, remembered per tab in this browser. + const groupState = useCollapsedGroups("analyze-rules:collapsed-categories"); // Expanded / editing state const [expandedRuleId, setExpandedRuleId] = useState(null); @@ -446,9 +451,8 @@ export default function AnalyzeRulesPage() { showSuccess(`Custom rule created from "${configureTemplateRule.title}" and enabled — see the Rules tab.`); setConfigureTemplateRule(null); await fetchRules(); - // Surface the new custom rule where it lives: switch to the Rules tab and expand it. - setActiveTab("rules"); - setExpandedRuleId(newRuleId); + // Surface the new custom rule where it lives: Rules tab, its category group, expanded. + revealRule(newRuleId, configureTemplateRule.category); } setCreatingFromTemplate(false); }; @@ -502,6 +506,26 @@ export default function AnalyzeRulesPage() { return matchesTab && matchesSearch && matchesSeverity && matchesCategory && matchesType; }); + // Category groups for the active tab: counters come from the whole tab, the list from the filter. + const tabRules = rulesList.filter((r) => (activeTab === "templates") === isTemplateRule(r)); + const ruleGroups = groupRulesByCategory(tabRules, filteredRules); + const searchActive = searchQuery.trim() !== ""; + const filtersActive = searchActive || severityFilter !== "all" || categoryFilter !== "all" || typeFilter !== "all"; + + // Open one rule where it lives: its tab, its category group (un-collapsed), the card expanded + // and scrolled into view. `categoryHint` covers a rule that is not in the list yet (fresh copy). + const revealRule = (ruleId: string, categoryHint?: string) => { + const target = rulesList.find((r) => r.ruleId === ruleId); + const tab = target && isTemplateRule(target) ? "templates" : "rules"; + const category = (target?.category ?? categoryHint)?.toLowerCase(); + if (category) groupState.expand(tab, category); + setActiveTab(tab); + setExpandedRuleId(ruleId); + setTimeout(() => { + document.getElementById(`rule-card-${ruleId}`)?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 100); + }; + // Total template count (unfiltered) drives the tab badge + whether the tab bar shows at all. const templateCount = rulesList.filter(isTemplateRule).length; const ruleTabCount = rulesList.length - templateCount; @@ -624,12 +648,7 @@ export default function AnalyzeRulesPage() { return ( + + + + + {groups.map((group) => { + const isOpen = forceExpanded || !collapsed.has(group.category); + const color = categoryColor(group.category); + const headerId = `rule-group-${group.category}`; + const panelId = `rule-group-panel-${group.category}`; + return ( +
+ + {isOpen && ( +
+ {group.rules.map((rule) => ( +
+ {renderRule(rule)} +
+ ))} +
+ )} +
+ ); + })} + + ); +} diff --git a/src/Web/autopilot-monitor-web/hooks/useCollapsedGroups.ts b/src/Web/autopilot-monitor-web/hooks/useCollapsedGroups.ts new file mode 100644 index 00000000..697625d2 --- /dev/null +++ b/src/Web/autopilot-monitor-web/hooks/useCollapsedGroups.ts @@ -0,0 +1,99 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; + +/** + * Per-viewer collapse state for grouped lists, persisted in localStorage. + * + * One storage key per page; inside it one string[] of collapsed group tokens per `scope` + * (the rule pages use their tab as scope, so "Rules" and "Templates" remember independently). + * The stored value is the render baseline (read once per key via useMemo); every change goes + * into React state AND back to storage, so there is no state-sync effect. Storage access is + * best-effort — a blocked or unavailable localStorage degrades to "everything expanded". + */ + +type ScopedSets = Record>; + +function readStore(storageKey: string): ScopedSets { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: ScopedSets = {}; + for (const [scope, value] of Object.entries(parsed as Record)) { + if (Array.isArray(value)) { + out[scope] = new Set(value.filter((v): v is string => typeof v === "string")); + } + } + return out; + } catch { + return {}; + } +} + +function writeStore(storageKey: string, store: ScopedSets): void { + if (typeof window === "undefined") return; + try { + const serializable: Record = {}; + for (const [scope, set] of Object.entries(store)) { + if (set.size > 0) serializable[scope] = Array.from(set); + } + window.localStorage.setItem(storageKey, JSON.stringify(serializable)); + } catch { + // Persistence is a convenience; the in-memory state still applies for this page view. + } +} + +const EMPTY: ReadonlySet = new Set(); + +export interface CollapsedGroups { + /** Collapsed tokens for a scope (empty set = everything expanded). */ + collapsed: (scope: string) => ReadonlySet; + toggle: (scope: string, token: string) => void; + /** Make sure one group is visible — used when the page programmatically opens an item inside it. */ + expand: (scope: string, token: string) => void; + expandAll: (scope: string) => void; + collapseAll: (scope: string, tokens: readonly string[]) => void; +} + +export function useCollapsedGroups(storageKey: string): CollapsedGroups { + const baseline = useMemo(() => readStore(storageKey), [storageKey]); + const [overrides, setOverrides] = useState>({}); + + const current = useCallback((): ScopedSets => overrides[storageKey] ?? baseline, [overrides, storageKey, baseline]); + + const commit = useCallback((next: ScopedSets) => { + writeStore(storageKey, next); + setOverrides((prev) => ({ ...prev, [storageKey]: next })); + }, [storageKey]); + + const collapsed = useCallback((scope: string) => current()[scope] ?? EMPTY, [current]); + + const toggle = useCallback((scope: string, token: string) => { + const store = current(); + const set = new Set(store[scope] ?? EMPTY); + if (set.has(token)) set.delete(token); else set.add(token); + commit({ ...store, [scope]: set }); + }, [current, commit]); + + const expand = useCallback((scope: string, token: string) => { + const store = current(); + const set = store[scope]; + if (!set || !set.has(token)) return; + const next = new Set(set); + next.delete(token); + commit({ ...store, [scope]: next }); + }, [current, commit]); + + const expandAll = useCallback((scope: string) => { + commit({ ...current(), [scope]: EMPTY }); + }, [current, commit]); + + const collapseAll = useCallback((scope: string, tokens: readonly string[]) => { + commit({ ...current(), [scope]: new Set(tokens) }); + }, [current, commit]); + + return { collapsed, toggle, expand, expandAll, collapseAll }; +} diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/ruleGroups.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/ruleGroups.test.ts new file mode 100644 index 00000000..06c7b177 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/ruleGroups.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { RULE_CATEGORY_ORDER, categoryLabel, compareCategories, groupRulesByCategory } from "../ruleGroups"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const SCHEMA_DIR = path.resolve(__dirname, "../../../../../rules/schema"); +const analyzeSchema: unknown = JSON.parse(readFileSync(path.join(SCHEMA_DIR, "analyze-rule.schema.json"), "utf8")); +const gatherSchema: unknown = JSON.parse(readFileSync(path.join(SCHEMA_DIR, "gather-rule.schema.json"), "utf8")); + +interface R { ruleId: string; category: string; enabled: boolean; isBuiltIn?: boolean; isCommunity?: boolean } +const rule = (ruleId: string, category: string, enabled = true, kind: "builtin" | "community" | "custom" = "builtin"): R => ({ + ruleId, + category, + enabled, + isBuiltIn: kind === "builtin", + isCommunity: kind === "community", +}); + +function schemaCategoryEnum(schema: unknown): string[] { + const defs = (schema as { $defs?: Record }> }).$defs ?? {}; + for (const def of Object.values(defs)) { + const e = def.properties?.category?.enum; + if (e) return e; + } + throw new Error("category enum not found in schema"); +} + +describe("RULE_CATEGORY_ORDER", () => { + it("is exactly the analyze-rule schema enum, in schema order", () => { + expect([...RULE_CATEGORY_ORDER]).toEqual(schemaCategoryEnum(analyzeSchema)); + }); + + it("covers every gather-rule schema category", () => { + for (const c of schemaCategoryEnum(gatherSchema)) { + expect(RULE_CATEGORY_ORDER).toContain(c); + } + }); +}); + +describe("categoryLabel", () => { + it("capitalizes and special-cases ESP", () => { + expect(categoryLabel("network")).toBe("Network"); + expect(categoryLabel("esp")).toBe("ESP"); + expect(categoryLabel("Apps")).toBe("Apps"); + }); +}); + +describe("compareCategories", () => { + it("orders schema categories first, unknown ones alphabetically after", () => { + const sorted = ["zeta", "device", "alpha", "network"].sort(compareCategories); + expect(sorted).toEqual(["network", "device", "alpha", "zeta"]); + }); +}); + +describe("groupRulesByCategory", () => { + const tab: R[] = [ + rule("A1", "apps"), + rule("A2", "apps", false, "custom"), + rule("N1", "network"), + rule("D1", "device", true, "community"), + rule("D2", "device", false), + rule("S1", "security"), + ]; + + it("returns schema order and counts from the unfiltered tab", () => { + const groups = groupRulesByCategory(tab, tab); + expect(groups.map((g) => g.category)).toEqual(["network", "apps", "device", "security"]); + const apps = groups.find((g) => g.category === "apps")!; + expect(apps).toMatchObject({ label: "Apps", total: 2, active: 1, custom: 1 }); + expect(apps.rules.map((r) => r.ruleId)).toEqual(["A1", "A2"]); + const device = groups.find((g) => g.category === "device")!; + expect(device).toMatchObject({ total: 2, active: 1, custom: 0 }); + }); + + it("keeps counters from the tab while listing only the visible rules; empty groups disappear", () => { + const visible = tab.filter((r) => r.enabled && r.category !== "security"); + const groups = groupRulesByCategory(tab, visible); + expect(groups.map((g) => g.category)).toEqual(["network", "apps", "device"]); + const apps = groups.find((g) => g.category === "apps")!; + expect(apps.rules.map((r) => r.ruleId)).toEqual(["A1"]); + expect(apps.total).toBe(2); + }); + + it("preserves the incoming order of rules inside a group", () => { + const groups = groupRulesByCategory(tab, [tab[1], tab[0]]); + expect(groups[0].rules.map((r) => r.ruleId)).toEqual(["A2", "A1"]); + }); + + it("normalizes category casing and appends unknown categories", () => { + const mixed: R[] = [rule("X", "Custom-Thing"), rule("Y", "ESP")]; + const groups = groupRulesByCategory(mixed, mixed); + expect(groups.map((g) => g.category)).toEqual(["esp", "custom-thing"]); + expect(groups[0].label).toBe("ESP"); + }); + + it("returns no groups for an empty tab", () => { + expect(groupRulesByCategory([], [])).toEqual([]); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/ruleGroups.ts b/src/Web/autopilot-monitor-web/lib/ruleGroups.ts new file mode 100644 index 00000000..f5530ac0 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/ruleGroups.ts @@ -0,0 +1,96 @@ +/** + * Category grouping for the rule pages (analyze + gather). + * + * The rule schemas define a closed category enum; the UI never introduces a new one. Groups + * are rendered in the schema's order so the page reads the same for every tenant, and the + * counters in each group header come from the UNFILTERED tab so a filtered view still tells + * the user how large the group really is. + */ + +/** Canonical display order — the analyze-rule schema enum; gather rules use a subset. */ +export const RULE_CATEGORY_ORDER: readonly string[] = [ + "network", + "identity", + "enrollment", + "apps", + "esp", + "device", + "security", +]; + +/** Human label for a category value ("esp" → "ESP", otherwise capitalized). */ +export function categoryLabel(category: string): string { + const c = category.toLowerCase(); + if (c === "esp") return "ESP"; + return c.charAt(0).toUpperCase() + c.slice(1); +} + +/** The minimal rule shape the grouping needs; both page-local rule types satisfy it. */ +export interface GroupableRule { + category: string; + enabled: boolean; + isBuiltIn?: boolean; + isCommunity?: boolean; +} + +export interface RuleCategoryGroup { + /** Lower-cased category value (stable key + storage token). */ + category: string; + label: string; + /** Rules currently visible in this group (after search/severity/type filters). */ + rules: T[]; + /** Counters from the unfiltered tab, so a filtered view still shows the group's real size. */ + total: number; + active: number; + custom: number; +} + +function categoryRank(category: string): number { + const idx = RULE_CATEGORY_ORDER.indexOf(category); + return idx === -1 ? RULE_CATEGORY_ORDER.length : idx; +} + +/** Schema order first; categories the schema does not know follow alphabetically. */ +export function compareCategories(a: string, b: string): number { + const rank = categoryRank(a) - categoryRank(b); + return rank !== 0 ? rank : a.localeCompare(b); +} + +function isCustom(rule: GroupableRule): boolean { + return !rule.isBuiltIn && !rule.isCommunity; +} + +/** + * Group the visible rules of one tab by category. + * + * @param tabRules Every rule that belongs to the current tab, before filtering — drives the counters. + * @param visibleRules The filtered subset that is actually rendered. Groups with no visible rule are omitted. + */ +export function groupRulesByCategory( + tabRules: readonly T[], + visibleRules: readonly T[], +): RuleCategoryGroup[] { + const groups = new Map>(); + const ensure = (category: string): RuleCategoryGroup => { + let g = groups.get(category); + if (!g) { + g = { category, label: categoryLabel(category), rules: [], total: 0, active: 0, custom: 0 }; + groups.set(category, g); + } + return g; + }; + + for (const rule of tabRules) { + const g = ensure(rule.category.toLowerCase()); + g.total += 1; + if (rule.enabled) g.active += 1; + if (isCustom(rule)) g.custom += 1; + } + for (const rule of visibleRules) { + ensure(rule.category.toLowerCase()).rules.push(rule); + } + + return Array.from(groups.values()) + .filter((g) => g.rules.length > 0) + .sort((a, b) => compareCategories(a.category, b.category)); +}