Register FilterBar template governance

This commit is contained in:
rcourtman
2026-06-12 13:29:25 +01:00
parent 1e634709a2
commit f90ae48814
25 changed files with 375 additions and 110 deletions
@@ -202,6 +202,9 @@ FilterBar chip and "+ Filter" pattern for resource filtering, and alert tables
must use the canonical platform table column-kind alignment helpers from
`frontend-modern/src/features/platformPage/` rather than hard-coded table
alignment classes.
Alert filter option semantics stay alert-owned, but FilterBar chip
presentation is frontend-primitives-owned: severity filter leading dots must
use `filterChipStatusDot` rather than alert-local span factories.
Guest metric canonical state remains resource-backed and therefore node-scoped
for Proxmox guests, so node moves must not strand active alert state on the
previous resource ID. When a guest metric alert survives a node move, alerts
@@ -84,6 +84,7 @@ work extends shared components instead of creating new local variants.
59. `frontend-modern/src/components/shared/FilterBar/index.ts`
59a. `frontend-modern/src/components/shared/FilterBar/SavedViewsMenu.tsx`
59b. `frontend-modern/src/components/shared/FilterBar/useSavedViews.ts`
59c. `frontend-modern/src/components/shared/FilterBar/filterOptionPresentation.tsx`
60. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
61. `frontend-modern/src/features/`
62. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
@@ -2392,6 +2393,12 @@ Docker, Kubernetes, Proxmox, Standalone, TrueNAS, vSphere, and future platform
feature tables; source-specific consumers own only the empty-state icon, title,
description, and actions. Platform feature tables must not import
`EmptyState` directly or recreate a `Card`-wrapped empty-state shell.
Filter bars are registry-backed too. `FilterBar` owns resource-list filtering
as a catalog of `FilterDef` entries, while `filterChipStatusDot` owns the
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`.
Inline detail table rows are also registry-backed. `InlineDetailTableRow`
owns the row/cell/content shell and row-click containment for platform,
workload, and infrastructure inline drawers; callers may pass row-specific
@@ -2496,10 +2503,11 @@ rail as view options, matching the v5 filter-bar pattern, and keeps longer or
dynamic scope filters in the menu/chip path. Feature surfaces must not fork
local filter rows or bury high-frequency Type, Status, or Group-by filters
behind an extra menu just to regain one-click behavior.
Pages that have not yet migrated (the alert-history filter card,
Kubernetes deployments drawer) keep using `PageControls` and
`LabeledFilterSelect`, but new resource-list filter surfaces should reach for
`FilterBar` with a catalog rather than reintroducing a per-page select row.
Legacy `PageControls` and labelled select/toggle primitives are not the
resource-list filter shape. If a future surface needs a new filtering
affordance, it should extend the FilterBar catalog model or add a new
registry-backed shared primitive rather than reintroducing a per-page select
row.
Pages may opt into saved views by passing `savedViewsKey` to
`FilterBar`. The `useSavedViews` hook owns the localStorage IO + URL
@@ -374,6 +374,9 @@ regression protection.
Non-Docker Workloads surfaces keep the global `disk` default unless they
declare their own scoped contract.
18. Extend workload filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` (defaults, `countActiveWorkloadsFilters`, `hasActiveWorkloadsFilters`) rather than rebuilding filter-local state inside `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`. Workloads filter presentation now composes the shared `FilterBar` (`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx`) with a per-page `FilterDef[]` catalog rather than the legacy `PageControls` structured control deck. High-frequency Type and Status filters stay in that catalog but render as inline compact segmented controls (`inline: true`), while longer or dynamic scope filters continue through the "+ Filter" menu and chip popovers. View options (grouped/list, charts, columns) sit in the shared `viewOptionsTrailing` slot.
Workload filter option semantics stay workload-owned, but FilterBar chip
presentation is frontend-primitives-owned: status and runtime leading dots
must use `filterChipStatusDot` rather than workload-local span factories.
WorkloadsFilter remains the shared interaction shell only; platform-owned
embedded Workloads surfaces must pass page-owned aria labels, search
placeholders, empty-search copy, status labels, and forced view-mode
@@ -979,6 +979,9 @@ recovery scope, or a storage/recovery-owned secret source.
forking the toolbar. The source scope flows through
`forcedSourceFilter` as a typed page input; the source filter remains
available in the toolbar only when not forced.
Storage filter option semantics stay storage-owned, but FilterBar chip
presentation is frontend-primitives-owned: storage status leading dots must
use `filterChipStatusDot` rather than storage-local span factories.
41. Keep agent memory composition descriptive on the shared unified-resource
contract. `internal/unifiedresources/types.go` carries the reclaimable
page-cache split (`AgentMemoryMeta.cache`, holding used + cache + free
@@ -215,6 +215,10 @@ filter semantics, drawer handoffs, and resource projections, while
Future platform tables must keep that split: row data and platform semantics
stay in the unified-resource consumer, and the repeated table shell stays in the
shared frontend primitive.
Platform filter option semantics follow that split too: unified-resource
consumers own the source-specific status buckets and labels, while the repeated
FilterBar chip leading-dot presentation must use the frontend-primitives-owned
`filterChipStatusDot` helper instead of page-local span factories.
Platform table empty states use that same split. Unified-resource consumers own
the source-specific empty-state vocabulary, action choice, and evidence rule
that decides why a table is empty, but the table-card empty-state shell itself
@@ -113,6 +113,60 @@
"src/components/shared/__tests__/InlineDetailTableRow.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "filter-bar-catalog-shell",
"category": "filter-bar",
"summary": "Resource-list filter surfaces must compose the shared FilterBar catalog shell instead of recreating page-local filter rows or legacy PageControls decks.",
"canonical": {
"path": "src/components/shared/FilterBar/FilterBar.tsx",
"export": "FilterBar"
},
"requiredConsumers": [
{ "path": "src/components/Alerts/ThresholdsTable.tsx" },
{ "path": "src/components/Settings/AuditLogPanel.tsx" },
{ "path": "src/components/Storage/StoragePageControls.tsx" },
{ "path": "src/components/Workloads/WorkloadsFilter.tsx" },
{ "path": "src/features/alerts/AlertHistoryFiltersCard.tsx" },
{ "path": "src/features/platformPage/sharedPlatformPage.tsx" },
{ "path": "src/features/proxmox/ProxmoxBackupsTable.tsx" }
],
"forbiddenPatterns": [
{
"path": "src/components/Storage/StoragePageControls.tsx",
"patterns": [
"from '@/components/shared/PageControls'",
"<PageControls",
"<StorageControls",
"<StorageFilter",
"LabeledFilterSelect",
"FilterSegmentedControl"
]
},
{
"path": "src/components/Workloads/WorkloadsFilter.tsx",
"patterns": [
"from '@/components/shared/PageControls'",
"<PageControls",
"LabeledFilterSelect",
"LabeledFilterToggleGroup"
]
},
{
"path": "src/features/proxmox/ProxmoxBackupsTable.tsx",
"patterns": [
"from '@/components/shared/PageControls'",
"<PageControls",
"LabeledFilterSelect",
"LabeledFilterToggleGroup"
]
}
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/FilterBar/FilterBar.test.tsx",
"scripts/shared-template-audit.mjs"
]
}
],
"patternGuards": [
@@ -162,6 +216,43 @@
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "filter-bar-consumer-legacy-page-controls",
"category": "filter-bar",
"summary": "Page and feature filter surfaces must not import the legacy PageControls deck when they should be expressed as a FilterBar catalog.",
"canonical": {
"path": "src/components/shared/FilterBar/FilterBar.tsx",
"export": "FilterBar"
},
"scopes": ["src/components", "src/features"],
"extensions": [".tsx"],
"allPatterns": ["@/components/shared/PageControls"],
"legacyReason": "Retired migration debt. Resource-list filter surfaces must compose FilterBar; no page-level filter exceptions are currently allowed.",
"allowedPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "filter-chip-status-dot-local-factory",
"category": "filter-bar",
"summary": "Filter option status-dot leading icons must use the FilterBar presentation helper instead of page-local span factories.",
"canonical": {
"path": "src/components/shared/FilterBar/filterOptionPresentation.tsx",
"export": "filterChipStatusDot"
},
"scopes": ["src/components", "src/features"],
"extensions": [".tsx"],
"allPatterns": ["h-2 w-2 rounded-full ${className}"],
"legacyReason": "Retired migration debt. Filter chip leading-dot presentation is owned by the FilterBar primitive family.",
"allowedPaths": [],
"ignoredPaths": ["src/components/shared/SharedPrimitives.guardrails.test.ts"],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
}
],
"requiredPatternGuards": [
@@ -1,7 +1,7 @@
import { Component, JSX, Show, createMemo } from 'solid-js';
import DatabaseIcon from 'lucide-solid/icons/database';
import HardDriveIcon from 'lucide-solid/icons/hard-drive';
import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';
import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar';
import { Subtabs } from '@/components/shared/Subtabs';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { STORAGE_KEYS } from '@/utils/localStorage';
@@ -83,10 +83,6 @@ type StoragePageControlsProps = {
savedViewsKey?: string;
};
const storageStatusDot = (className: string) => (
<span class={`h-2 w-2 rounded-full ${className}`} />
);
export const StoragePageControls: Component<StoragePageControlsProps> = (props) => {
const { isMobile } = useBreakpoint();
const isPoolsView = () => props.view() === 'pools';
@@ -222,13 +218,13 @@ export const StoragePageControls: Component<StoragePageControlsProps> = (props)
label: option.label,
leading:
option.value === 'available'
? storageStatusDot('bg-emerald-500')
? filterChipStatusDot('bg-emerald-500')
: option.value === 'warning' || option.value === 'attention'
? storageStatusDot('bg-amber-500')
? filterChipStatusDot('bg-amber-500')
: option.value === 'critical'
? storageStatusDot('bg-red-500')
? filterChipStatusDot('bg-red-500')
: option.value === 'offline' || option.value === 'unknown'
? storageStatusDot('bg-slate-400')
? filterChipStatusDot('bg-slate-400')
: undefined,
tone:
option.value === 'available'
@@ -4,7 +4,12 @@ import BoxesIcon from 'lucide-solid/icons/boxes';
import MonitorIcon from 'lucide-solid/icons/monitor';
import XIcon from 'lucide-solid/icons/x';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import { FilterBar, type FilterDef, type FilterSelectOption } from '@/components/shared/FilterBar';
import {
FilterBar,
filterChipStatusDot,
type FilterDef,
type FilterSelectOption,
} from '@/components/shared/FilterBar';
import { ChartVisibilityToggleButton, FilterActionButton } from '@/components/shared/FilterToolbar';
import { GroupedTableModeSegmentedControl } from '@/components/shared/GroupedTableModeSegmentedControl';
import { useBreakpoint } from '@/hooks/useBreakpoint';
@@ -24,8 +29,6 @@ import { WORKLOAD_STATUS_FILTER_OPTIONS, WORKLOAD_TYPE_OPTIONS } from './workloa
const PROXMOX_PLATFORM_FILTER = 'proxmox-pve';
const statusDot = (className: string) => <span class={`h-2 w-2 rounded-full ${className}`} />;
export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
const { isMobile } = useBreakpoint();
@@ -62,11 +65,11 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
label: option.label,
leading:
option.value === 'running'
? statusDot('bg-emerald-500')
? filterChipStatusDot('bg-emerald-500')
: option.value === 'degraded'
? statusDot('bg-amber-500')
? filterChipStatusDot('bg-amber-500')
: option.value === 'stopped'
? statusDot('bg-red-500')
? filterChipStatusDot('bg-red-500')
: undefined,
tone:
option.value === 'running'
@@ -86,8 +89,8 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
};
const runtimeChipDot = (value: string): JSX.Element | undefined => {
if (value === 'docker') return statusDot('bg-sky-500');
if (value === 'podman') return statusDot('bg-violet-500');
if (value === 'docker') return filterChipStatusDot('bg-sky-500');
if (value === 'podman') return filterChipStatusDot('bg-violet-500');
return undefined;
};
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@solidjs/testing-library';
import { WorkloadsFilter } from '../WorkloadsFilter';
import workloadsFilterSource from '../WorkloadsFilter.tsx?raw';
import {
DEFAULT_WORKLOADS_SORT_DIRECTION,
DEFAULT_WORKLOADS_SORT_KEY,
@@ -95,6 +96,12 @@ describe('WorkloadsFilter', () => {
});
describe('rendering', () => {
it('uses the shared FilterBar status-dot presentation helper', () => {
expect(workloadsFilterSource).toContain('filterChipStatusDot');
expect(workloadsFilterSource).not.toContain('const statusDot = (className: string)');
expect(workloadsFilterSource).not.toContain('h-2 w-2 rounded-full ' + '${className}');
});
it('renders the search input', () => {
render(() => <WorkloadsFilter {...makeProps()} />);
expect(screen.getByTestId('search-input')).toBeInTheDocument();
@@ -0,0 +1,5 @@
import type { JSX } from 'solid-js';
export const filterChipStatusDot = (className: string): JSX.Element => (
<span class={`h-2 w-2 rounded-full ${className}`} aria-hidden="true" />
);
@@ -3,13 +3,7 @@ export { FilterChip } from './FilterChip';
export { AddFilterMenu } from './AddFilterMenu';
export { SavedViewsMenu } from './SavedViewsMenu';
export { useSavedViews } from './useSavedViews';
export type { SavedView, } from './useSavedViews';
export {
clearFilter,
formatFilterChipValue,
isFilterSet,
} from './filterCatalog';
export type {
FilterDef,
FilterSelectOption,
} from './filterCatalog';
export type { SavedView } from './useSavedViews';
export { filterChipStatusDot } from './filterOptionPresentation';
export { clearFilter, formatFilterChipValue, isFilterSet } from './filterCatalog';
export type { FilterDef, FilterSelectOption } from './filterCatalog';
@@ -150,10 +150,12 @@ 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';
import filterCatalogSource from '@/components/shared/FilterBar/filterCatalog.ts?raw';
import filterBarOptionPresentationSource from '@/components/shared/FilterBar/filterOptionPresentation.tsx?raw';
import filterBarIndexSource from '@/components/shared/FilterBar/index.ts?raw';
import savedViewsMenuSource from '@/components/shared/FilterBar/SavedViewsMenu.tsx?raw';
import useSavedViewsSource from '@/components/shared/FilterBar/useSavedViews.ts?raw';
import storagePageControlsSource from '@/components/Storage/StoragePageControls.tsx?raw';
import proxmoxBackupsTableSharedSource from '@/features/proxmox/proxmoxBackupsTableShared.tsx?raw';
const sharedSources = import.meta.glob(['./*.tsx', './cards/*.tsx', './responsive/*.tsx'], {
query: '?raw',
@@ -1057,7 +1059,7 @@ describe('shared primitive guardrails', () => {
vsphereHostsTableSource,
]) {
expect(source).toContain('PlatformTableEmptyState');
expect(source).not.toContain("@/components/shared/EmptyState");
expect(source).not.toContain('@/components/shared/EmptyState');
expect(source).not.toContain('<EmptyState');
expect(source).not.toContain('<Card padding="lg">');
}
@@ -1405,14 +1407,71 @@ describe('shared primitive guardrails', () => {
});
it('keeps the chip-based FilterBar on a catalog descriptor with shell, chip, and add-menu owners', () => {
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[];
allowedPaths?: string[];
ignoredPaths?: string[];
}>;
};
const filterBarRule = registry.rules?.find((rule) => rule.id === 'filter-bar-catalog-shell');
const legacyPageControlsGuard = registry.patternGuards?.find(
(guard) => guard.id === 'filter-bar-consumer-legacy-page-controls',
);
const chipDotGuard = registry.patternGuards?.find(
(guard) => guard.id === 'filter-chip-status-dot-local-factory',
);
expect(filterBarRule?.canonical?.path).toBe('src/components/shared/FilterBar/FilterBar.tsx');
expect(filterBarRule?.canonical?.export).toBe('FilterBar');
expect(filterBarRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual(
expect.arrayContaining([
'src/components/Alerts/ThresholdsTable.tsx',
'src/components/Settings/AuditLogPanel.tsx',
'src/components/Storage/StoragePageControls.tsx',
'src/components/Workloads/WorkloadsFilter.tsx',
'src/features/alerts/AlertHistoryFiltersCard.tsx',
'src/features/platformPage/sharedPlatformPage.tsx',
'src/features/proxmox/ProxmoxBackupsTable.tsx',
]),
);
expect(legacyPageControlsGuard?.canonical?.path).toBe(
'src/components/shared/FilterBar/FilterBar.tsx',
);
expect(legacyPageControlsGuard?.canonical?.export).toBe('FilterBar');
expect(legacyPageControlsGuard?.allPatterns).toEqual(['@/components/shared/PageControls']);
expect(legacyPageControlsGuard?.allowedPaths ?? []).toHaveLength(0);
expect(chipDotGuard?.canonical?.path).toBe(
'src/components/shared/FilterBar/filterOptionPresentation.tsx',
);
expect(chipDotGuard?.canonical?.export).toBe('filterChipStatusDot');
expect(chipDotGuard?.allPatterns).toEqual(['h-2 w-2 rounded-full ' + '${className}']);
expect(chipDotGuard?.allowedPaths ?? []).toHaveLength(0);
expect(chipDotGuard?.ignoredPaths).toEqual([
'src/components/shared/SharedPrimitives.guardrails.test.ts',
]);
expect(filterBarIndexSource).toContain("export { FilterBar } from './FilterBar';");
expect(filterBarIndexSource).toContain("export { FilterChip } from './FilterChip';");
expect(filterBarIndexSource).toContain("export { AddFilterMenu } from './AddFilterMenu';");
expect(filterBarIndexSource).toContain("export { SavedViewsMenu } from './SavedViewsMenu';");
expect(filterBarIndexSource).toContain("export { useSavedViews } from './useSavedViews';");
expect(filterBarIndexSource).toContain(
"export { filterChipStatusDot } from './filterOptionPresentation';",
);
expect(filterBarIndexSource).toContain('isFilterSet');
expect(filterBarIndexSource).toContain('clearFilter');
expect(filterBarIndexSource).toContain('formatFilterChipValue');
expect(filterBarOptionPresentationSource).toContain('export const filterChipStatusDot');
expect(filterBarOptionPresentationSource).toContain('h-2 w-2 rounded-full');
expect(filterBarOptionPresentationSource).toContain('aria-hidden="true"');
expect(filterCatalogSource).toContain('export interface FilterDef');
expect(filterCatalogSource).toContain('defaultValue: string');
@@ -1530,9 +1589,11 @@ describe('shared primitive guardrails', () => {
// render as inline primary controls; Node/Platform/Namespace/Runtime
// remain selector-backed catalog filters. The xl-breakpoint
// segmented↔select swap retires here.
expect(workloadsFilterSource).toContain(
"import { FilterBar, type FilterDef, type FilterSelectOption } from '@/components/shared/FilterBar';",
);
expect(workloadsFilterSource).toContain("from '@/components/shared/FilterBar';");
expect(workloadsFilterSource).toContain('FilterBar,');
expect(workloadsFilterSource).toContain('filterChipStatusDot,');
expect(workloadsFilterSource).toContain('type FilterDef,');
expect(workloadsFilterSource).toContain('type FilterSelectOption,');
expect(workloadsFilterSource).toContain('<FilterBar');
expect(workloadsFilterSource).toContain("ariaLabel={props.ariaLabel ?? 'Workloads filters'}");
expect(workloadsFilterSource).toContain("id: 'workloads-type'");
@@ -1548,6 +1609,7 @@ describe('shared primitive guardrails', () => {
expect(workloadsFilterSource).not.toContain('LabeledFilterToggleGroup');
expect(workloadsFilterSource).not.toContain('LabeledFilterSelect');
expect(workloadsFilterSource).not.toContain('useWorkloadsFilterState');
expect(workloadsFilterSource).not.toContain('const statusDot = (className: string)');
// StoragePageControls — Subtabs sit above the FilterBar; sort key/sort
// direction live in viewOptionsTrailing as raw view-options (not chips).
@@ -1556,10 +1618,12 @@ describe('shared primitive guardrails', () => {
// catalog. The legacy
// 3-layer indirection (StoragePageControls → StorageControls →
// StorageFilter) collapses to one component.
expect(storagePageControlsSource).toContain(
"import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';",
);
expect(storagePageControlsSource).toContain("from '@/components/shared/FilterBar';");
expect(storagePageControlsSource).toContain('FilterBar,');
expect(storagePageControlsSource).toContain('filterChipStatusDot,');
expect(storagePageControlsSource).toContain('type FilterDef');
expect(storagePageControlsSource).toContain('<FilterBar');
expect(storagePageControlsSource).toContain('filterChipStatusDot');
expect(storagePageControlsSource).toContain('<Subtabs');
expect(storagePageControlsSource).toContain(
"ariaLabel={props.filterAriaLabel ?? 'Storage filters'}",
@@ -1585,6 +1649,7 @@ describe('shared primitive guardrails', () => {
expect(storagePageControlsSource).not.toContain('<StorageFilter');
expect(storagePageControlsSource).not.toContain('LabeledFilterSelect');
expect(storagePageControlsSource).not.toContain('FilterSegmentedControl');
expect(storagePageControlsSource).not.toContain('storageStatusDot');
// Storage's three-layer indirection retired — StoragePageControls no
// longer imports the deleted StorageFilter / StorageControls modules,
// and reads the canonical Storage types directly from storagePageState
@@ -1598,5 +1663,15 @@ describe('shared primitive guardrails', () => {
expect(storagePageControlsSource).toContain(
"import type { StorageGroupKey, StorageSortKey } from './useStorageModel';",
);
expect(sharedPlatformPageSource).toContain('filterChipStatusDot');
expect(sharedPlatformPageSource).not.toContain('platformChipStatusDot');
for (const source of [
proxmoxBackupsTableSharedSource,
proxmoxCephTableSource,
proxmoxReplicationTableSource,
]) {
expect(source).toContain('filterChipStatusDot');
expect(source).not.toContain('const statusDot = (className: string)');
}
});
});
@@ -1,4 +1,4 @@
import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';
import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar';
import { STORAGE_KEYS } from '@/utils/localStorage';
import {
ALERT_HISTORY_ALL_TIME_FILTER_LABEL,
@@ -7,8 +7,6 @@ import {
import type { AlertHistoryRange, AlertSeverityFilter } from './alertHistoryModel';
import type { AlertHistoryState } from './useAlertHistoryState';
const severityDot = (className: string) => <span class={`h-2 w-2 rounded-full ${className}`} />;
interface AlertHistoryFiltersCardProps {
state: AlertHistoryState;
isMobile: boolean;
@@ -37,21 +35,20 @@ export function AlertHistoryFiltersCard(props: AlertHistoryFiltersCardProps) {
group: 'status',
inline: true,
value: props.state.severityFilter,
setValue: (value: string) =>
props.state.setSeverityFilter(value as AlertSeverityFilter),
setValue: (value: string) => props.state.setSeverityFilter(value as AlertSeverityFilter),
defaultValue: 'all',
options: () => [
{ value: 'all', label: 'All' },
{
value: 'critical',
label: 'Critical',
leading: severityDot('bg-red-500'),
leading: filterChipStatusDot('bg-red-500'),
tone: 'danger',
},
{
value: 'warning',
label: 'Warning',
leading: severityDot('bg-amber-500'),
leading: filterChipStatusDot('bg-amber-500'),
tone: 'warning',
},
],
@@ -3,13 +3,13 @@ import XIcon from 'lucide-solid/icons/x';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import {
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
platformChipStatusDot,
type PlatformTableFilterOption,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
@@ -33,19 +33,19 @@ const DOCKER_INCIDENT_STATUS_OPTIONS: PlatformTableFilterOption<DockerIncidentSe
value: 'critical',
label: 'Critical',
tone: 'danger',
leading: platformChipStatusDot('bg-red-500'),
leading: filterChipStatusDot('bg-red-500'),
},
{
value: 'warning',
label: 'Warning',
tone: 'warning',
leading: platformChipStatusDot('bg-amber-500'),
leading: filterChipStatusDot('bg-amber-500'),
},
{
value: 'info',
label: 'Info',
tone: 'success',
leading: platformChipStatusDot('bg-emerald-500'),
leading: filterChipStatusDot('bg-emerald-500'),
},
];
@@ -3,13 +3,13 @@ import XIcon from 'lucide-solid/icons/x';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import {
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
platformChipStatusDot,
type PlatformTableFilterOption,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
@@ -34,19 +34,19 @@ const KUBERNETES_INCIDENT_STATUS_OPTIONS: PlatformTableFilterOption<KubernetesIn
value: 'critical',
label: 'Critical',
tone: 'danger',
leading: platformChipStatusDot('bg-red-500'),
leading: filterChipStatusDot('bg-red-500'),
},
{
value: 'warning',
label: 'Warning',
tone: 'warning',
leading: platformChipStatusDot('bg-amber-500'),
leading: filterChipStatusDot('bg-amber-500'),
},
{
value: 'info',
label: 'Info',
tone: 'success',
leading: platformChipStatusDot('bg-emerald-500'),
leading: filterChipStatusDot('bg-emerald-500'),
},
];
@@ -4,7 +4,7 @@ import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert';
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
import { EmptyState } from '@/components/shared/EmptyState';
import { type FilterOption as PlatformTableFilterOption } from '@/components/shared/FilterButtonGroup';
import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';
import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar';
import { type SearchInputProps } from '@/components/shared/SearchInput';
import { Table, TableBody, TableHeader, TableRow } from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
@@ -174,11 +174,7 @@ export function PlatformErrorState(props: {
// 'running' vs 'online', 'stopped' vs 'offline') collapse to one chip set.
export type PlatformResourceStatusFilter = 'all' | 'online' | 'degraded' | 'offline';
export const platformChipStatusDot = (className: string) => (
<span class={`h-2 w-2 rounded-full ${className}`} />
);
const statusDot = platformChipStatusDot;
const statusDot = filterChipStatusDot;
export const PLATFORM_STATUS_FILTER_OPTIONS: PlatformTableFilterOption<PlatformResourceStatusFilter>[] =
[
@@ -7,6 +7,7 @@ import {
type Component,
type JSX,
} from 'solid-js';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import type { StatusIndicatorVariant } from '@/utils/status';
@@ -38,13 +39,26 @@ import { ProxmoxCephClusterDrawer } from './ProxmoxCephClusterDrawer';
type CephStatusFilter = 'all' | 'healthy' | 'warning' | 'critical';
const statusDot = (className: string) => <span class={`h-2 w-2 rounded-full ${className}`} />;
const STATUS_FILTER_OPTIONS: PlatformTableFilterOption<CephStatusFilter>[] = [
{ value: 'all', label: 'All' },
{ value: 'healthy', label: 'Healthy', tone: 'success', leading: statusDot('bg-emerald-500') },
{ value: 'warning', label: 'Warning', tone: 'warning', leading: statusDot('bg-amber-500') },
{ value: 'critical', label: 'Critical', tone: 'danger', leading: statusDot('bg-red-500') },
{
value: 'healthy',
label: 'Healthy',
tone: 'success',
leading: filterChipStatusDot('bg-emerald-500'),
},
{
value: 'warning',
label: 'Warning',
tone: 'warning',
leading: filterChipStatusDot('bg-amber-500'),
},
{
value: 'critical',
label: 'Critical',
tone: 'danger',
leading: filterChipStatusDot('bg-red-500'),
},
];
function classify(resource: Resource): CephStatusFilter {
@@ -1,5 +1,6 @@
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { StatusDot } from '@/components/shared/StatusDot';
import type { StatusIndicatorVariant } from '@/utils/status';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
@@ -24,14 +25,27 @@ import type { ReplicationJob, ReplicationJobsResponse } from '@/types/api';
type ReplicationStatusFilter = 'all' | 'healthy' | 'failed' | 'pending' | 'disabled';
const statusDot = (className: string) => <span class={`h-2 w-2 rounded-full ${className}`} />;
const STATUS_FILTER_OPTIONS: PlatformTableFilterOption<ReplicationStatusFilter>[] = [
{ value: 'all', label: 'All' },
{ value: 'healthy', label: 'Healthy', tone: 'success', leading: statusDot('bg-emerald-500') },
{ value: 'failed', label: 'Failed', tone: 'danger', leading: statusDot('bg-red-500') },
{ value: 'pending', label: 'Pending', tone: 'warning', leading: statusDot('bg-amber-500') },
{ value: 'disabled', label: 'Disabled', tone: 'muted', leading: statusDot('bg-slate-400') },
{
value: 'healthy',
label: 'Healthy',
tone: 'success',
leading: filterChipStatusDot('bg-emerald-500'),
},
{ value: 'failed', label: 'Failed', tone: 'danger', leading: filterChipStatusDot('bg-red-500') },
{
value: 'pending',
label: 'Pending',
tone: 'warning',
leading: filterChipStatusDot('bg-amber-500'),
},
{
value: 'disabled',
label: 'Disabled',
tone: 'muted',
leading: filterChipStatusDot('bg-slate-400'),
},
];
interface ReplicationStatusIndicator {
@@ -2,6 +2,7 @@ import { Show, type Accessor } from 'solid-js';
import ArrowDownIcon from 'lucide-solid/icons/arrow-down';
import ArrowUpIcon from 'lucide-solid/icons/arrow-up';
import ArrowUpDownIcon from 'lucide-solid/icons/arrow-up-down';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { type FilterOption } from '@/components/shared/FilterButtonGroup';
import { ProgressBar } from '@/components/shared/ProgressBar';
import { TableHead } from '@/components/shared/Table';
@@ -30,8 +31,6 @@ import type {
// the orchestrating ProxmoxBackupsTable so each sub-view can import only what
// it renders.
const statusDot = (className: string) => <span class={`h-2 w-2 rounded-full ${className}`} />;
export const PROXMOX_BACKUP_COLUMN_LABELS = {
targetId: 'Target ID',
created: 'Created',
@@ -56,35 +55,75 @@ export const ARCHIVE_STATUS_FILTERS: FilterOption<
'all' | 'protected' | 'verified' | 'unverified'
>[] = [
{ value: 'all', label: 'All' },
{ value: 'protected', label: 'Protected', tone: 'info', leading: statusDot('bg-blue-500') },
{ value: 'verified', label: 'Verified', tone: 'success', leading: statusDot('bg-emerald-500') },
{
value: 'protected',
label: 'Protected',
tone: 'info',
leading: filterChipStatusDot('bg-blue-500'),
},
{
value: 'verified',
label: 'Verified',
tone: 'success',
leading: filterChipStatusDot('bg-emerald-500'),
},
{
value: 'unverified',
label: 'Unverified',
tone: 'warning',
leading: statusDot('bg-amber-500'),
leading: filterChipStatusDot('bg-amber-500'),
},
];
export const PBS_STATUS_FILTERS: FilterOption<'all' | 'protected' | 'verified' | 'unverified'>[] = [
{ value: 'all', label: 'All' },
{ value: 'protected', label: 'Protected', tone: 'info', leading: statusDot('bg-blue-500') },
{ value: 'verified', label: 'Verified', tone: 'success', leading: statusDot('bg-emerald-500') },
{ value: 'unverified', label: 'Unverified', tone: 'warning', leading: statusDot('bg-amber-500') },
{
value: 'protected',
label: 'Protected',
tone: 'info',
leading: filterChipStatusDot('bg-blue-500'),
},
{
value: 'verified',
label: 'Verified',
tone: 'success',
leading: filterChipStatusDot('bg-emerald-500'),
},
{
value: 'unverified',
label: 'Unverified',
tone: 'warning',
leading: filterChipStatusDot('bg-amber-500'),
},
];
export const TASK_STATUS_FILTERS: FilterOption<'all' | 'ok' | 'failed' | 'running'>[] = [
{ value: 'all', label: 'All' },
{ value: 'ok', label: 'OK', tone: 'success', leading: statusDot('bg-emerald-500') },
{ value: 'failed', label: 'Failed', tone: 'danger', leading: statusDot('bg-red-500') },
{ value: 'running', label: 'Running', tone: 'info', leading: statusDot('bg-blue-500') },
{ value: 'ok', label: 'OK', tone: 'success', leading: filterChipStatusDot('bg-emerald-500') },
{ value: 'failed', label: 'Failed', tone: 'danger', leading: filterChipStatusDot('bg-red-500') },
{ value: 'running', label: 'Running', tone: 'info', leading: filterChipStatusDot('bg-blue-500') },
];
export const COVERAGE_FILTERS: FilterOption<CoverageFilterValue>[] = [
{ value: 'all', label: 'All' },
{ value: 'attention', label: 'Attention', tone: 'warning', leading: statusDot('bg-amber-500') },
{ value: 'current', label: 'Current', tone: 'success', leading: statusDot('bg-emerald-500') },
{ value: 'uncovered', label: 'Uncovered', tone: 'danger', leading: statusDot('bg-red-500') },
{
value: 'attention',
label: 'Attention',
tone: 'warning',
leading: filterChipStatusDot('bg-amber-500'),
},
{
value: 'current',
label: 'Current',
tone: 'success',
leading: filterChipStatusDot('bg-emerald-500'),
},
{
value: 'uncovered',
label: 'Uncovered',
tone: 'danger',
leading: filterChipStatusDot('bg-red-500'),
},
];
const recoverableSourceFilterOption = (
@@ -98,7 +137,7 @@ const recoverableSourceFilterOption = (
compactLabel: presentation.compactFilterLabel,
title: presentation.filterTitle,
tone: 'info',
leading: statusDot(presentation.timelineSwatchClassName),
leading: filterChipStatusDot(presentation.timelineSwatchClassName),
};
};
@@ -107,8 +146,18 @@ export const RECOVERABLE_FILTERS: FilterOption<RecoverableFilterValue>[] = [
recoverableSourceFilterOption('pbs'),
recoverableSourceFilterOption('archive'),
recoverableSourceFilterOption('snapshot'),
{ value: 'verified', label: 'Verified', tone: 'success', leading: statusDot('bg-emerald-500') },
{ value: 'unverified', label: 'Unverified', tone: 'warning', leading: statusDot('bg-amber-500') },
{
value: 'verified',
label: 'Verified',
tone: 'success',
leading: filterChipStatusDot('bg-emerald-500'),
},
{
value: 'unverified',
label: 'Unverified',
tone: 'warning',
leading: filterChipStatusDot('bg-amber-500'),
},
];
export const SNAPSHOT_FILTERS: FilterOption<SnapshotFilterValue>[] = [
@@ -117,19 +166,19 @@ export const SNAPSHOT_FILTERS: FilterOption<SnapshotFilterValue>[] = [
value: 'recent',
label: 'Recent ≤30d',
tone: 'success',
leading: statusDot('bg-emerald-500'),
leading: filterChipStatusDot('bg-emerald-500'),
},
{
value: 'stale',
label: 'Stale >30d',
tone: 'warning',
leading: statusDot('bg-amber-500'),
leading: filterChipStatusDot('bg-amber-500'),
},
{
value: 'with-ram',
label: 'With RAM',
tone: 'info',
leading: statusDot('bg-violet-500'),
leading: filterChipStatusDot('bg-violet-500'),
},
];
@@ -2,6 +2,7 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { formatBytes } from '@/utils/format';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
@@ -12,7 +13,6 @@ import {
createPlatformTableFilterState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
platformChipStatusDot,
type PlatformTableFilterOption,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
@@ -40,19 +40,19 @@ const TRUENAS_STORAGE_STATUS_OPTIONS: PlatformTableFilterOption<TrueNASStorageSt
value: 'healthy',
label: 'Healthy',
tone: 'success',
leading: platformChipStatusDot('bg-emerald-500'),
leading: filterChipStatusDot('bg-emerald-500'),
},
{
value: 'attention',
label: 'Attention',
tone: 'warning',
leading: platformChipStatusDot('bg-amber-500'),
leading: filterChipStatusDot('bg-amber-500'),
},
{
value: 'offline',
label: 'Offline',
tone: 'danger',
leading: platformChipStatusDot('bg-red-500'),
leading: filterChipStatusDot('bg-red-500'),
},
];
@@ -3,13 +3,13 @@ import XIcon from 'lucide-solid/icons/x';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import {
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
platformChipStatusDot,
type PlatformTableFilterOption,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
@@ -34,19 +34,19 @@ const VSPHERE_ACTIVITY_STATUS_OPTIONS: PlatformTableFilterOption<VmwareActivityS
value: 'tasks',
label: 'Tasks',
tone: 'success',
leading: platformChipStatusDot('bg-emerald-500'),
leading: filterChipStatusDot('bg-emerald-500'),
},
{
value: 'events',
label: 'Events',
tone: 'info',
leading: platformChipStatusDot('bg-blue-500'),
leading: filterChipStatusDot('bg-blue-500'),
},
{
value: 'failed',
label: 'Failed',
tone: 'danger',
leading: platformChipStatusDot('bg-red-500'),
leading: filterChipStatusDot('bg-red-500'),
},
];
@@ -3,13 +3,13 @@ import XIcon from 'lucide-solid/icons/x';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import {
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
platformChipStatusDot,
type PlatformTableFilterOption,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
@@ -33,19 +33,19 @@ const VSPHERE_INCIDENT_STATUS_OPTIONS: PlatformTableFilterOption<VmwareIncidentS
value: 'critical',
label: 'Critical',
tone: 'danger',
leading: platformChipStatusDot('bg-red-500'),
leading: filterChipStatusDot('bg-red-500'),
},
{
value: 'warning',
label: 'Warning',
tone: 'warning',
leading: platformChipStatusDot('bg-amber-500'),
leading: filterChipStatusDot('bg-amber-500'),
},
{
value: 'info',
label: 'Info',
tone: 'success',
leading: platformChipStatusDot('bg-emerald-500'),
leading: filterChipStatusDot('bg-emerald-500'),
},
];
@@ -2,6 +2,7 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
@@ -10,7 +11,6 @@ import {
createPlatformTableFilterState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
platformChipStatusDot,
type PlatformTableFilterOption,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
@@ -34,25 +34,25 @@ const VSPHERE_DATASTORE_STATUS_OPTIONS: PlatformTableFilterOption<VmwareDatastor
value: 'accessible',
label: 'Accessible',
tone: 'success',
leading: platformChipStatusDot('bg-emerald-500'),
leading: filterChipStatusDot('bg-emerald-500'),
},
{
value: 'attention',
label: 'Attention',
tone: 'warning',
leading: platformChipStatusDot('bg-amber-500'),
leading: filterChipStatusDot('bg-amber-500'),
},
{
value: 'inaccessible',
label: 'Inaccessible',
tone: 'danger',
leading: platformChipStatusDot('bg-red-500'),
leading: filterChipStatusDot('bg-red-500'),
},
{
value: 'maintenance',
label: 'Maintenance',
tone: 'warning',
leading: platformChipStatusDot('bg-amber-500'),
leading: filterChipStatusDot('bg-amber-500'),
},
{ value: 'unknown', label: 'Unknown' },
];
@@ -1,6 +1,7 @@
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
@@ -9,7 +10,6 @@ import {
createPlatformTableFilterState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
platformChipStatusDot,
type PlatformTableFilterOption,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
@@ -33,13 +33,13 @@ const VSPHERE_NETWORK_STATUS_OPTIONS: PlatformTableFilterOption<VmwareNetworkSta
value: 'healthy',
label: 'Healthy',
tone: 'success',
leading: platformChipStatusDot('bg-emerald-500'),
leading: filterChipStatusDot('bg-emerald-500'),
},
{
value: 'attention',
label: 'Attention',
tone: 'warning',
leading: platformChipStatusDot('bg-amber-500'),
leading: filterChipStatusDot('bg-amber-500'),
},
{ value: 'unknown', label: 'Unknown' },
];
@@ -465,6 +465,9 @@ describe('tab path helpers', () => {
expect(alertHistoryFrequencyCardSource).toContain('getAlertBucketCountLabel');
expect(alertHistoryFiltersCardSource).toContain('export function AlertHistoryFiltersCard');
expect(alertHistoryFiltersCardSource).toContain('getAlertHistorySearchPlaceholder');
expect(alertHistoryFiltersCardSource).toContain('filterChipStatusDot');
expect(alertHistoryFiltersCardSource).not.toContain('severityDot');
expect(alertHistoryFiltersCardSource).not.toContain('h-2 w-2 rounded-full ${className}');
expect(alertResourceIncidentsPanelSource).toContain(
'export function AlertResourceIncidentsPanel',
);