mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user