web rules pages: group analyze and gather rules by category with collapsible headers, counters and Expand/Collapse All (state per tab in localStorage); reveal a rule inside its group from telemetry, template copy and view-copy

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-06 17:37:44 +02:00
parent 0dcf0eb503
commit fe919a51aa
8 changed files with 519 additions and 44 deletions
@@ -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<string | null>(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 (
<button
key={ruleId}
onClick={() => {
setExpandedRuleId(ruleId);
setTimeout(() => {
document.getElementById(`rule-card-${ruleId}`)?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
}}
onClick={() => revealRule(ruleId)}
className="w-full text-left group"
>
<div className="flex items-center gap-3">
@@ -738,7 +757,7 @@ export default function AnalyzeRulesPage() {
onChange: setCategoryFilter,
options: [
{ value: "all", label: "All Categories" },
...uniqueCategories.map((cat) => ({ value: cat, label: cat.charAt(0).toUpperCase() + cat.slice(1) })),
...uniqueCategories.map((cat) => ({ value: cat, label: categoryLabel(cat) })),
],
},
// Type filter is meaningless in the Templates tab → hide it there.
@@ -845,9 +864,23 @@ export default function AnalyzeRulesPage() {
showClearButton={!!(searchQuery || severityFilter !== "all" || categoryFilter !== "all" || typeFilter !== "all")}
/>
) : (
<div className="space-y-3">
{filteredRules.map((rule) => (
<div key={rule.ruleId} id={`rule-card-${rule.ruleId}`}>
<RuleCategoryGroups
groups={ruleGroups}
collapsed={groupState.collapsed(activeTab)}
onToggleGroup={(category) => groupState.toggle(activeTab, category)}
onExpandAll={() => groupState.expandAll(activeTab)}
onCollapseAll={() => groupState.collapseAll(activeTab, ruleGroups.map((g) => g.category))}
forceExpanded={searchActive}
filtered={filtersActive}
summary={
<span>
{filteredRules.length} {activeTab === "templates" ? "template" : "rule"}{filteredRules.length !== 1 ? "s" : ""} in {ruleGroups.length} {ruleGroups.length !== 1 ? "categories" : "category"}
{filtersActive && " (filtered)"}
</span>
}
categoryColor={getCategoryColor}
ruleKey={(r) => r.ruleId}
renderRule={(rule) => (
<AnalyzeRuleCard
rule={rule}
isExpanded={expandedRuleId === rule.ruleId}
@@ -882,15 +915,14 @@ export default function AnalyzeRulesPage() {
onConfigureTemplate={(r) => setConfigureTemplateRule(r)}
templateCopyExists={templateCopyMap.has(rule.ruleId)}
templateCopyRuleId={templateCopyMap.get(rule.ruleId)}
onScrollToCopy={(copyId) => setExpandedRuleId(copyId)}
onScrollToCopy={(copyId) => revealRule(copyId)}
hitRate={ruleStatsMap[rule.ruleId]?.hitRate ?? null}
fireCount={ruleStatsMap[rule.ruleId]?.fireCount ?? null}
trend={ruleStatsMap[rule.ruleId]?.trend ?? null}
regression={ruleStatsMap[rule.ruleId]?.regression ?? null}
/>
</div>
))}
</div>
)}
/>
)}
</div>
)}
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { GatherRule, NewRuleForm, PastedGatherJson, CATEGORY_COLORS, COLLECTOR_TYPE_LABELS, formatTrigger, formatGatherPhase, gatherRuleToForm } from "../types";
import { GatherRule, NewRuleForm, PastedGatherJson, getCategoryColor, COLLECTOR_TYPE_LABELS, formatTrigger, formatGatherPhase, gatherRuleToForm } from "../types";
import { GatherRuleFormFields } from "./GatherRuleFormFields";
import { FormJsonToggle, JsonModeToggleButtons, ReadOnlyJsonView } from "@/components/rules/FormJsonToggle";
import { validateGatherRuleTarget } from "@/utils/guardValidation";
@@ -60,7 +60,7 @@ export function GatherRuleCard({
unrestrictedMode = false,
}: GatherRuleCardProps) {
const [showJson, setShowJson] = useState(false);
const catColor = CATEGORY_COLORS[rule.category] || { bg: "bg-gray-100", text: "text-gray-700" };
const catColor = getCategoryColor(rule.category);
const canEdit = !rule.isBuiltIn && !rule.isCommunity;
const targetValidation = useMemo(
@@ -13,7 +13,10 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs
import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { GatherRule, NewRuleForm, PastedGatherJson, EMPTY_FORM, PHASE_TRIGGERS, buildScopeFields, gatherRuleToForm, targetBlocked, validateScopeSelection } from "./types";
import { GatherRule, NewRuleForm, PastedGatherJson, EMPTY_FORM, PHASE_TRIGGERS, buildScopeFields, gatherRuleToForm, targetBlocked, validateScopeSelection, getCategoryColor } from "./types";
import { groupRulesByCategory } from "@/lib/ruleGroups";
import { RuleCategoryGroups } from "@/components/rules/RuleCategoryGroups";
import { useCollapsedGroups } from "@/hooks/useCollapsedGroups";
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
import { GatherRuleCard } from "./components/GatherRuleCard";
import { SectionCardHeader } from "@/components/SectionCardHeader";
@@ -39,6 +42,9 @@ export default function GatherRulesPage() {
const [searchQuery, setSearchQuery] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const [typeFilter, setTypeFilter] = useState("all");
// Collapsed category groups, remembered in this browser (one scope: the page has no tabs).
const groupState = useCollapsedGroups("gather-rules:collapsed-categories");
const GROUP_SCOPE = "rules";
// Expanded / editing state
const [expandedRuleId, setExpandedRuleId] = useState<string | null>(null);
@@ -356,6 +362,10 @@ export default function GatherRulesPage() {
return true;
});
const ruleGroups = groupRulesByCategory(rulesList, filteredRules);
const searchActive = searchQuery.trim() !== "";
const filtersActive = searchActive || categoryFilter !== "all" || typeFilter !== "all";
const totalRules = rulesList.length;
const activeRules = rulesList.filter((r) => r.enabled).length;
const builtInCount = rulesList.filter((r) => r.isBuiltIn).length;
@@ -578,27 +588,36 @@ export default function GatherRulesPage() {
)}
{/* Rules List */}
<div className="space-y-3">
<div className="flex items-center space-x-2 px-1">
<svg className="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span className="text-sm text-gray-500">
{filteredRules.length} rule{filteredRules.length !== 1 ? "s" : ""} found
{(searchQuery || categoryFilter !== "all" || typeFilter !== "all") && " (filtered)"}
</span>
</div>
{filteredRules.length === 0 ? (
<EmptyState
message="No rules match your filters."
onClearFilters={() => { setSearchQuery(""); setCategoryFilter("all"); setTypeFilter("all"); }}
showClearButton={!!(searchQuery || categoryFilter !== "all" || typeFilter !== "all")}
/>
) : (
filteredRules.map((rule) => (
{filteredRules.length === 0 ? (
<EmptyState
message="No rules match your filters."
onClearFilters={() => { setSearchQuery(""); setCategoryFilter("all"); setTypeFilter("all"); }}
showClearButton={filtersActive}
/>
) : (
<RuleCategoryGroups
groups={ruleGroups}
collapsed={groupState.collapsed(GROUP_SCOPE)}
onToggleGroup={(category) => groupState.toggle(GROUP_SCOPE, category)}
onExpandAll={() => groupState.expandAll(GROUP_SCOPE)}
onCollapseAll={() => groupState.collapseAll(GROUP_SCOPE, ruleGroups.map((g) => g.category))}
forceExpanded={searchActive}
filtered={filtersActive}
summary={
<>
<svg className="w-5 h-5 text-indigo-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span>
{filteredRules.length} rule{filteredRules.length !== 1 ? "s" : ""} in {ruleGroups.length} {ruleGroups.length !== 1 ? "categories" : "category"}
{filtersActive && " (filtered)"}
</span>
</>
}
categoryColor={getCategoryColor}
ruleKey={(r) => r.ruleId}
renderRule={(rule) => (
<GatherRuleCard
key={rule.ruleId}
rule={rule}
isExpanded={expandedRuleId === rule.ruleId}
isEditing={editingRuleId === rule.ruleId}
@@ -645,9 +664,9 @@ export default function GatherRulesPage() {
readOnly={isReadOnly}
unrestrictedMode={unrestrictedMode}
/>
))
)}
</div>
)}
/>
)}
</div>
)}
</main>
@@ -94,6 +94,10 @@ export function formatGatherPhase(token: string): string {
return GATHER_PHASES.find((p) => p.value === token)?.label ?? token;
}
export function getCategoryColor(category: string) {
return CATEGORY_COLORS[category.toLowerCase()] || { bg: "bg-gray-100", text: "text-gray-700" };
}
export const CATEGORY_COLORS: Record<string, { bg: string; text: string }> = {
network: { bg: "bg-blue-100", text: "text-blue-700" },
identity: { bg: "bg-purple-100", text: "text-purple-700" },
@@ -0,0 +1,126 @@
"use client";
import type { ReactNode } from "react";
import type { GroupableRule, RuleCategoryGroup } from "@/lib/ruleGroups";
interface RuleCategoryGroupsProps<T extends GroupableRule> {
groups: RuleCategoryGroup<T>[];
/** Category tokens that are collapsed right now. */
collapsed: ReadonlySet<string>;
onToggleGroup: (category: string) => void;
onExpandAll: () => void;
onCollapseAll: () => void;
/**
* Keep every group open regardless of `collapsed` — used while a free-text search is
* active so no hit disappears behind a closed group. Header and Collapse All are disabled.
*/
forceExpanded?: boolean;
/** True when a filter narrows the list; group headers then show "visible of total". */
filtered: boolean;
/** Left side of the toolbar row (e.g. "12 rules in 4 categories"). */
summary: ReactNode;
/** Pill colors for a category — the pages pass their existing category palette. */
categoryColor: (category: string) => { bg: string; text: string };
/** Stable DOM key for a rule (also the wrapper id prefix so a page can scroll to it). */
ruleKey: (rule: T) => string;
renderRule: (rule: T) => ReactNode;
}
/**
* Rules grouped by category with a collapsible header per group and Expand/Collapse All.
* The cards themselves are unchanged; only the wrapper structure is new.
*/
export function RuleCategoryGroups<T extends GroupableRule>({
groups,
collapsed,
onToggleGroup,
onExpandAll,
onCollapseAll,
forceExpanded = false,
filtered,
summary,
categoryColor,
ruleKey,
renderRule,
}: RuleCategoryGroupsProps<T>) {
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 px-1">
<div className="flex items-center gap-2 min-w-0 text-sm text-gray-500">{summary}</div>
<div className="flex gap-1.5 items-center flex-shrink-0">
<button
type="button"
onClick={onExpandAll}
disabled={forceExpanded || collapsed.size === 0}
title="Expand all categories"
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-50 text-blue-700 hover:bg-blue-100 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-50"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span className="hidden sm:inline">Expand All</span>
</button>
<button
type="button"
onClick={onCollapseAll}
disabled={forceExpanded || groups.every((g) => collapsed.has(g.category))}
title={forceExpanded ? "Categories stay expanded while searching" : "Collapse all categories"}
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-50 text-gray-700 hover:bg-gray-100 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-gray-50"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
<span className="hidden sm:inline">Collapse All</span>
</button>
</div>
</div>
{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 (
<section key={group.category} aria-labelledby={headerId}>
<button
type="button"
id={headerId}
onClick={() => onToggleGroup(group.category)}
disabled={forceExpanded}
aria-expanded={isOpen}
aria-controls={panelId}
title={forceExpanded ? "Categories stay expanded while searching" : isOpen ? "Collapse category" : "Expand category"}
className="w-full flex items-center gap-2 px-1 py-1.5 text-left rounded hover:bg-gray-100 disabled:hover:bg-transparent disabled:cursor-default transition-colors select-none"
>
<svg
className={`w-4 h-4 flex-shrink-0 text-gray-400 transition-transform ${isOpen ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${color.bg} ${color.text}`}>{group.label}</span>
<span className="text-xs text-gray-500 truncate">
{filtered ? `${group.rules.length} of ${group.total}` : group.total}{" "}
{group.total === 1 ? "rule" : "rules"}
{" · "}{group.active} active
{group.custom > 0 && <>{" · "}{group.custom} custom</>}
</span>
</button>
{isOpen && (
<div id={panelId} className="space-y-3 mt-2">
{group.rules.map((rule) => (
<div key={ruleKey(rule)} id={`rule-card-${ruleKey(rule)}`}>
{renderRule(rule)}
</div>
))}
</div>
)}
</section>
);
})}
</div>
);
}
@@ -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<string, ReadonlySet<string>>;
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<string, unknown>)) {
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<string, string[]> = {};
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<string> = new Set();
export interface CollapsedGroups {
/** Collapsed tokens for a scope (empty set = everything expanded). */
collapsed: (scope: string) => ReadonlySet<string>;
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<Record<string, ScopedSets>>({});
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 };
}
@@ -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<string, { properties?: Record<string, { enum?: string[] }> }> }).$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([]);
});
});
@@ -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<T extends GroupableRule> {
/** 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<T extends GroupableRule>(
tabRules: readonly T[],
visibleRules: readonly T[],
): RuleCategoryGroup<T>[] {
const groups = new Map<string, RuleCategoryGroup<T>>();
const ensure = (category: string): RuleCategoryGroup<T> => {
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));
}