Register filter button group governance

This commit is contained in:
rcourtman 2026-06-12 14:38:43 +01:00
parent 36af6620d3
commit 23e65040cc
9 changed files with 215 additions and 37 deletions

View file

@ -1164,6 +1164,7 @@ the canonical monitored-system blocked payload.
and the Patrol runtime-remediation destination shared with the AI settings endpoint, so summary actions, run-history runtime-failure actions, and runtime-finding actions may reuse the governed provider-settings route while still presenting that destination to Patrol operators as Patrol provider configuration instead of generic `AI Settings` copy
and the Patrol route-shell destination itself, so the thin page shell at `frontend-modern/src/pages/AIIntelligence.tsx` may continue to bridge the shared AI-runtime payload boundary while exposing `/patrol` as the canonical product route and keeping retired `/ai` browser entry points unregistered
and the Patrol route-shell accessibility boundary, so brand icons in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx` stay decorative when the same heading already exposes visible Patrol text, preventing duplicate accessible names such as `Pulse Patrol Patrol`
and the Patrol autonomy selector boundary, so the header composes the shared `frontend-modern/src/components/shared/FilterButtonGroup.tsx` segmented primitive for the `Monitor` / `Investigate` / `Remediate` presentation while the API contract remains the sole owner of accepted autonomy values, license-required rejection shape, and the monitor-only clamp
9. Route frontend API-client parsed error propagation, API-error-status fallback handling, allowed-status handling, custom status-specific error handling, command-trigger success envelope handling, shared response parsing pipelines, missing-resource lookup handling, metadata CRUD routing, stream event consumption, response status, collection normalization, scalar payload coercion, and structured error normalization through canonical shared helpers under `frontend-modern/src/api/`
Assistant chat stream workflow-state payloads are part of this same
frontend API-client boundary. `workflow_state` events must keep `phase`,
@ -5334,6 +5335,12 @@ owners must not rely on that 402 as normal control flow for stale local state:
when the current entitlement locks safe remediation, they submit `monitor` even
if older persisted settings or a previous entitlement left `approval`,
`assisted`, or `full` in memory.
The Patrol header presentation for that API boundary must compose the shared
`frontend-modern/src/components/shared/FilterButtonGroup.tsx` segmented
selector: the endpoint contract still owns the accepted autonomy values and
license-required response shape, while the frontend owns only the option
mapping and entitlement-derived disabled state. Local Patrol selector styling
must not become a second source of truth for the API's monitor-only clamp.
The reporting transport contract now also carries an optional narrative
interpretation layer alongside the deterministic data surface. The Go-side
`pkg/reporting.MetricReportRequest` gains optional `Narrator` and

View file

@ -2416,6 +2416,12 @@ small leading status-dot glyph used by filter options. Page and feature
surfaces must not copy the chip-dot `<span>` factory or import the legacy
`PageControls` deck for resource-list filtering; those drift checks live in
`shared-template-registry.json` and run through `shared-template-audit.mjs`.
Segmented selectors are registry-backed too. `FilterButtonGroup` owns the
settings, prominent, compact, and equal segmented selector shells, including
active-button tone, disabled-option behavior, pressed-state semantics,
compact labels, and horizontal scroll treatment through the shared
shell/state/model split. Settings and compact feature surfaces must compose
that primitive instead of copying active-button selector styling locally.
Chart visibility display actions are registry-backed too.
`ChartVisibilityToggleButton` owns the `Show charts` / `Hide charts` label,
pressed-state, title, icon, and toolbar action styling for summary-bearing

View file

@ -597,6 +597,13 @@ surface must render any Patrol readiness
provider, model, and summary carried by the failure object, and keep the
provider-settings action immediately available before the operator chooses to
open the Assistant handoff.
The Patrol autonomy selector in that header must compose the shared
`frontend-modern/src/components/shared/FilterButtonGroup.tsx` segmented
variant instead of rebuilding a local active-button group. Patrol owns only
the domain options (`Monitor`, `Investigate`, `Remediate`), entitlement locks,
and the `full`-as-remediate presentation mapping; the shared primitive owns
the equal-width segmented shell, pressed-state semantics, disabled-option
behavior, and active/inactive selector styling.
That same Patrol-owned presentation rule also applies to the findings empty
state: `frontend-modern/src/components/AI/FindingsPanel.tsx` must not treat
`0 active findings` as equivalent to "your infrastructure looks healthy" when

View file

@ -240,6 +240,25 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "filter-button-group-shell",
"category": "segmented-selector",
"summary": "Settings and compact feature segmented selectors must compose FilterButtonGroup instead of recreating active-button group styling locally.",
"canonical": {
"path": "src/components/shared/FilterButtonGroup.tsx",
"export": "FilterButtonGroup"
},
"requiredConsumers": [
{ "path": "src/components/Settings/GeneralSettingsPanel.tsx" },
{ "path": "src/components/Settings/ReportingPanel.tsx" },
{ "path": "src/features/patrol/PatrolIntelligenceHeader.tsx" }
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/FilterButtonGroup.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "chart-visibility-toggle-button",
"category": "display-toggle",
@ -517,6 +536,27 @@
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "filter-button-group-local-segmented-control-styles",
"category": "segmented-selector",
"summary": "Settings and feature surfaces must not copy the compact active-button segmented-control styling owned by FilterButtonGroup.",
"canonical": {
"path": "src/components/shared/filterButtonGroupModel.ts",
"export": "getFilterButtonGroupButtonClass"
},
"scopes": ["src/components/Settings", "src/features", "src/pages"],
"extensions": [".tsx"],
"allPatterns": [
"flex items-center bg-base rounded-md p-1 border shadow-inner",
"flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200"
],
"legacyReason": "Retired migration debt. Settings and compact feature segmented selectors must use FilterButtonGroup; local active-button selector styling forks are not allowed.",
"allowedPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
}
],
"requiredPatternGuards": [

View file

@ -30,6 +30,7 @@ describe('FilterButtonGroup', () => {
expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupButtonClass');
expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupCompactLabel');
expect(filterButtonGroupModelSource).toContain("prominent: 'grid grid-cols-1 gap-2'");
expect(filterButtonGroupModelSource).toContain('segmented:');
expect(filterButtonGroupModelSource).toContain('compact:');
expect(filterButtonGroupModelSource).toContain('inline-flex items-center gap-1');
});
@ -168,4 +169,30 @@ describe('FilterButtonGroup', () => {
expect(visualLabel).toHaveClass('inline-flex');
expect(visualLabel).toHaveClass('whitespace-nowrap');
});
it('supports equal-width segmented controls for compact feature settings', () => {
render(() => (
<FilterButtonGroup
ariaLabel="Patrol autonomy level"
options={[
{ value: 'monitor', label: 'Monitor' },
{ value: 'approval', label: 'Investigate', disabled: true },
{ value: 'assisted', label: 'Remediate', disabled: true },
]}
value="monitor"
onChange={() => undefined}
variant="segmented"
/>
));
const group = screen.getByRole('group', { name: 'Patrol autonomy level' });
const activeButton = within(group).getByRole('button', { name: 'Monitor' });
const disabledButton = within(group).getByRole('button', { name: 'Investigate' });
expect(activeButton.className).toContain('flex-1');
expect(activeButton.className).toContain('bg-surface');
expect(activeButton.className).toContain('text-blue-600');
expect(disabledButton).toBeDisabled();
expect(disabledButton.className).toContain('opacity-50');
});
});

View file

@ -154,6 +154,7 @@ import reportingPanelSource from '@/components/Settings/ReportingPanel.tsx?raw';
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
import updatesSettingsPanelSource from '@/components/Settings/UpdatesSettingsPanel.tsx?raw';
import userAssignmentsPanelSource from '@/components/Settings/UserAssignmentsPanel.tsx?raw';
import patrolIntelligenceHeaderSource from '@/features/patrol/PatrolIntelligenceHeader.tsx?raw';
import filterBarSource from '@/components/shared/FilterBar/FilterBar.tsx?raw';
import filterChipSource from '@/components/shared/FilterBar/FilterChip.tsx?raw';
import addFilterMenuSource from '@/components/shared/FilterBar/AddFilterMenu.tsx?raw';
@ -187,7 +188,54 @@ describe('shared primitive guardrails', () => {
expect(rawTableUsers).toEqual(['./PulseDataGrid.tsx']);
});
it('routes canonical settings segmented selectors through FilterButtonGroup', () => {
it('routes canonical settings and feature segmented selectors through FilterButtonGroup', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
rules?: Array<{
id: string;
canonical?: { path?: string; export?: string };
requiredConsumers?: Array<{ path?: string }>;
}>;
patternGuards?: Array<{
id: string;
canonical?: { path?: string; export?: string };
allPatterns?: string[];
scopes?: string[];
allowedPaths?: string[];
}>;
};
const registeredRule = registry.rules?.find(
(rule) => rule.id === 'filter-button-group-shell',
);
const registeredGuard = registry.patternGuards?.find(
(guard) => guard.id === 'filter-button-group-local-segmented-control-styles',
);
expect(registeredRule?.canonical?.path).toBe(
'src/components/shared/FilterButtonGroup.tsx',
);
expect(registeredRule?.canonical?.export).toBe('FilterButtonGroup');
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/components/Settings/GeneralSettingsPanel.tsx',
'src/components/Settings/ReportingPanel.tsx',
'src/features/patrol/PatrolIntelligenceHeader.tsx',
]);
expect(registeredGuard?.canonical?.path).toBe(
'src/components/shared/filterButtonGroupModel.ts',
);
expect(registeredGuard?.canonical?.export).toBe('getFilterButtonGroupButtonClass');
expect(registeredGuard?.allPatterns).toEqual([
'flex items-center bg-base rounded-md p-1 border shadow-inner',
'flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200',
]);
expect(registeredGuard?.allowedPaths ?? []).toHaveLength(0);
expect(registeredGuard?.scopes).toEqual(
expect.arrayContaining([
'src/components/Settings',
'src/features',
'src/pages',
]),
);
expect(filterButtonGroupSource).toContain('useFilterButtonGroupState');
expect(filterButtonGroupSource).toContain('getFilterButtonGroupClass');
expect(filterButtonGroupSource).toContain('getFilterButtonGroupButtonClass');
@ -200,6 +248,7 @@ describe('shared primitive guardrails', () => {
expect(filterButtonGroupStateSource).toContain('props.disabled || option.disabled');
expect(filterButtonGroupStateSource).toContain('props.onChange(option.value)');
expect(filterButtonGroupModelSource).toContain("prominent: 'grid grid-cols-1 gap-2'");
expect(filterButtonGroupModelSource).toContain('segmented:');
expect(filterButtonGroupModelSource).toContain('touch-scroll');
expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupButtonClass');
expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupCompactLabel');
@ -215,6 +264,15 @@ describe('shared primitive guardrails', () => {
expect(reportingPanelSource).toContain('variant="prominent"');
expect(reportingPanelSource).not.toContain('getReportingToggleButtonClass');
expect(reportingPanelSource).not.toContain('<For each={REPORTING_RANGE_OPTIONS}>');
expect(patrolIntelligenceHeaderSource).toContain('FilterButtonGroup');
expect(patrolIntelligenceHeaderSource).toContain('variant="segmented"');
expect(patrolIntelligenceHeaderSource).toContain('selectedAutonomyLevel');
expect(patrolIntelligenceHeaderSource).not.toContain(
'flex items-center bg-base rounded-md p-1 border shadow-inner',
);
expect(patrolIntelligenceHeaderSource).not.toContain(
'flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200',
);
});
it('keeps AI model picker labels route-aware for gateway providers', () => {

View file

@ -13,7 +13,12 @@ export interface FilterOption<T extends string | number> {
disabled?: boolean;
}
export type FilterButtonGroupVariant = 'default' | 'settings' | 'compact' | 'prominent';
export type FilterButtonGroupVariant =
| 'default'
| 'settings'
| 'compact'
| 'prominent'
| 'segmented';
export type FilterButtonGroupOptionTone =
| 'default'
| 'info'
@ -39,6 +44,8 @@ const groupClassByVariant: Record<FilterButtonGroupVariant, string> = {
compact:
'inline-flex items-center gap-1 bg-surface-hover rounded-md p-0.5 ring-1 ring-border-subtle overflow-x-auto scrollbar-hide',
prominent: 'grid grid-cols-1 gap-2',
segmented:
'flex items-center gap-1 rounded-md border border-border bg-base p-1 shadow-inner overflow-x-auto scrollbar-hide',
};
const labelClassByVariant: Partial<Record<FilterButtonGroupVariant, string>> = {
@ -109,6 +116,16 @@ export function getFilterButtonGroupButtonClass(
].join(' ');
}
if (variant === 'segmented') {
return [
'flex-1 min-h-8 px-2 py-1.5 text-xs font-semibold rounded-md transition-all whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
active
? 'bg-surface text-blue-600 shadow-sm dark:text-blue-400'
: 'text-muted hover:text-base-content hover:bg-surface-hover',
disabled ? 'opacity-50 cursor-not-allowed' : '',
].join(' ');
}
return [
'flex flex-1 justify-center sm:flex-none sm:justify-start items-center gap-2 px-3 sm:px-4 py-2.5 sm:py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
active

View file

@ -10,6 +10,8 @@ import { PageHeader } from '@/components/shared/PageHeader';
import { Toggle, TogglePrimitive } from '@/components/shared/Toggle';
import { CountdownTimer } from '@/components/patrol';
import { FormSelect } from '@/components/shared/FormSelect';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
import type { PatrolAutonomyLevel } from '@/api/patrol';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { formatRelativeTime } from '@/utils/format';
import { groupModelsByProvider } from '@/utils/patrolFormat';
@ -21,6 +23,8 @@ import { PATROL_PROVIDER_SETTINGS_ACTION } from '@/utils/patrolRuntimeActions';
import type { PatrolConfigurationFailureInput } from './patrolInvestigationContextModel';
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
type PatrolSelectableAutonomyLevel = Exclude<PatrolAutonomyLevel, 'full'>;
const isNonEmptyConfigurationDetail = (value?: string | null): value is string =>
Boolean(value?.trim());
@ -73,6 +77,34 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
if (!model || models.length === 0) return false;
return !models.some((candidate) => candidate.id === model);
});
const selectedAutonomyLevel = createMemo<PatrolSelectableAutonomyLevel>(() => {
const level = state.autonomyLevel();
return level === 'full' ? 'assisted' : level;
});
const getAutonomyOptionTitle = (level: PatrolSelectableAutonomyLevel) => {
const isProLocked = () => state.autoFixLocked() && level !== 'monitor';
if (!presentationPolicyHidesUpgradePrompts() && isProLocked()) {
return level === 'approval'
? 'Investigation is not enabled on this plan'
: 'Safe remediation workflows are not enabled on this plan';
}
return undefined;
};
const autonomyLevelOptions = createMemo<FilterOption<PatrolSelectableAutonomyLevel>[]>(() => [
{ value: 'monitor', label: 'Monitor' },
{
value: 'approval',
label: 'Investigate',
disabled: state.autoFixLocked(),
title: getAutonomyOptionTitle('approval'),
},
{
value: 'assisted',
label: 'Remediate',
disabled: state.autoFixLocked(),
title: getAutonomyOptionTitle('assisted'),
},
]);
return (
<div class="space-y-4">
@ -288,41 +320,15 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
</label>
</div>
<div class="flex items-center bg-base rounded-md p-1 border shadow-inner">
<For each={['monitor', 'approval', 'assisted'] as const}>
{(level) => {
const isProLocked = () =>
state.autoFixLocked() && (level === 'approval' || level === 'assisted');
const isDisabled = () => !state.patrolEnabledLocal() || isProLocked();
const isActive = () =>
level === 'assisted'
? state.autonomyLevel() === 'assisted' ||
state.autonomyLevel() === 'full'
: state.autonomyLevel() === level;
return (
<button
onClick={() => state.handleAutonomyChange(level)}
disabled={isDisabled()}
title={
!presentationPolicyHidesUpgradePrompts() && isProLocked()
? level === 'approval'
? 'Investigation is not enabled on this plan'
: 'Safe remediation workflows are not enabled on this plan'
: undefined
}
class={`flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200 ${isActive() ? ' text-blue-600 dark:text-blue-400 shadow-[0_1px_3px_rgba(0,0,0,0.1)]' : isDisabled() ? ' ' : 'text-muted hover:text-base-content hover:bg-surface-hover'} ${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{level === 'monitor'
? 'Monitor'
: level === 'approval'
? 'Investigate'
: 'Remediate'}
</button>
);
}}
</For>
</div>
<FilterButtonGroup
ariaLabel="Patrol autonomy level"
class="w-full"
disabled={!state.patrolEnabledLocal()}
options={autonomyLevelOptions()}
value={selectedAutonomyLevel()}
onChange={(level) => state.handleAutonomyChange(level)}
variant="segmented"
/>
<Show when={!presentationPolicyHidesUpgradePrompts() && state.autoFixLocked()}>
<div class="pl-1 text-[11px] text-muted">
Investigation and safe remediation workflows are not enabled on this plan.

View file

@ -3542,6 +3542,16 @@ describe('frontend resource type boundaries', () => {
expect(patrolIntelligenceHeaderSource).toContain(
'!presentationPolicyHidesUpgradePrompts() && state.alertAnalysisLocked()',
);
expect(patrolIntelligenceHeaderSource).toContain(
"from '@/components/shared/FilterButtonGroup'",
);
expect(patrolIntelligenceHeaderSource).toContain('variant="segmented"');
expect(patrolIntelligenceHeaderSource).not.toContain(
'flex items-center bg-base rounded-md p-1 border shadow-inner',
);
expect(patrolIntelligenceHeaderSource).not.toContain(
'flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200',
);
expect(appSource).not.toContain("import('./pages/Operations')");
expect(appSource).not.toContain('<Route path="/operations/*"');
expect(settingsNavigationModelSource).not.toContain(