Make platform filters page-specific

This commit is contained in:
rcourtman
2026-05-19 18:00:43 +01:00
parent 743d4fb95e
commit 2d62ae5f19
24 changed files with 364 additions and 39 deletions
@@ -238,6 +238,14 @@ Feature surfaces under `frontend-modern/src/features/` may own product-specific
assessment semantics, but they must keep those semantics in their governed
presentation helpers and render them inside the shared neutral Pulse surface
language rather than introducing page-local verdict bands or nested cards.
Shared filter/search primitives may provide the common shell, keyboard behavior,
history, and reset mechanics, but the owning page or table must supply
domain-specific visible copy, scope filters, status labels, and searchable
field coverage. Platform pages must not surface generic "rows/resources"
search affordances when the visible table is actually pods, VMs, datastores,
apps, mail gateways, storage pools, backup jobs, or another product-owned
object model.
Patrol's primary assessment strip is descriptive only; it must not render a
Patrol-authored recommended next step, suggested prompt chips, or a secondary
action band inside the assessment shell. If the same assessment opens
@@ -305,6 +305,12 @@ 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.
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
context into route option derivation so Docker, Kubernetes, TrueNAS,
Proxmox, and VMware pages expose filters for the objects visible on that
page rather than generic mixed-workload labels.
19. Extend threshold-slider value-position math, title/label derivation, and drag scroll-lock runtime through `frontend-modern/src/components/Workloads/thresholdSliderModel.ts` and `frontend-modern/src/components/Workloads/useThresholdSliderState.ts` rather than rebuilding slider-local state and pointer lifecycle inside `frontend-modern/src/components/Workloads/ThresholdSlider.tsx`
20. Extend stacked disk-bar capacity math, segment/tooltip derivation, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedDiskBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedDiskBarState.ts` rather than rebuilding disk-bar-local state, mode branching, and tooltip shaping inside `frontend-modern/src/components/Workloads/StackedDiskBar.tsx`. Compact multi-disk rows default to same-height per-disk lanes so each filesystem has its own visible usage bar without implying the whole host disk state is one max or aggregate percentage; explicit `mode="stacked"` remains the capacity-contribution stack for callers that intentionally need that presentation, while tooltips continue to carry the full per-disk breakdown.
21. Extend stacked memory-bar capacity math, balloon/swap derivation, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts` rather than rebuilding memory-bar-local state, tooltip shaping, and label-fit logic inside `frontend-modern/src/components/Workloads/StackedMemoryBar.tsx`
@@ -220,8 +220,13 @@ bypass the API fail-closed execution gate.
as unlabeled inline primary controls from that catalog in the same compact
second-row rail as sort, charts, and reset, while Node, Source, Role, and
Group remain menu/chip filters because their option sets are scope- or
data-driven. Subtabs (Pools / Physical Disks) sit above the bar as
navigation, not filters. Platform-page storage and recovery section tabs
data-driven. Embedded platform storage surfaces must provide page-owned
filter aria labels, search placeholders, and empty-search copy for the
visible storage model (for example Proxmox storage, TrueNAS pools and
datasets, or vSphere datastores) while preserving the canonical
`FilterDef[]` route-state contract in `StoragePageControls`. Subtabs
(Pools / Physical Disks) sit above the bar as navigation, not filters.
Platform-page storage and recovery section tabs
are still capability-gated navigation: Proxmox Storage,
Backups, Ceph, Replication, and adjacent recovery sections may appear only
when the platform page model has matching storage, backup, Ceph,
@@ -26,6 +26,9 @@ type StorageProps = {
// page already locks source scope through `forcedSourceFilter`.
showFilterToolbar?: boolean;
suppressSourceFilter?: boolean;
filterAriaLabel?: string;
filterSearchPlaceholder?: string;
filterSearchEmptyMessage?: string;
};
const Storage: Component<StorageProps> = (props) => {
@@ -186,6 +189,9 @@ const Storage: Component<StorageProps> = (props) => {
setView={setView}
search={search}
setSearch={setSearch}
filterAriaLabel={props.filterAriaLabel}
searchPlaceholder={props.filterSearchPlaceholder}
searchEmptyMessage={props.filterSearchEmptyMessage}
groupBy={groupBy}
setGroupBy={setGroupBy}
sortKey={sortKey}
@@ -42,6 +42,9 @@ type StoragePageControlsProps = {
setView: (value: StorageView) => void;
search: () => string;
setSearch: (value: string) => void;
filterAriaLabel?: string;
searchPlaceholder?: string;
searchEmptyMessage?: string;
searchTrailing?: JSX.Element;
groupBy: () => StorageGroupKey;
setGroupBy: (value: StorageGroupKey) => void;
@@ -266,14 +269,15 @@ export const StoragePageControls: Component<StoragePageControlsProps> = (props)
<FilterBar
role="group"
ariaLabel="Storage filters"
ariaLabel={props.filterAriaLabel ?? 'Storage filters'}
isMobile={isMobile}
search={{
value: props.search,
setValue: props.setSearch,
placeholder: 'Search storage... (e.g., local, nfs, node:pve1)',
placeholder:
props.searchPlaceholder ?? 'Search storage by pool, dataset, node, or device',
historyKey: STORAGE_KEYS.STORAGE_SEARCH_HISTORY,
emptyMessage: 'Your recent storage searches will show here.',
emptyMessage: props.searchEmptyMessage ?? 'Recent storage searches appear here.',
}}
searchTrailing={props.searchTrailing}
filters={buildFilters()}
@@ -1884,6 +1884,38 @@ describe('Storage', () => {
);
});
it('passes platform-owned filter copy into embedded storage controls', () => {
mockLocationPath = '/vmware/storage';
hookResources = [
buildStorageResource('vsphere-datastore', 'vsan-prod', 'esxi-01', {
platformType: 'vmware-vsphere',
parentName: 'esxi-01',
storage: {
type: 'datastore',
topology: 'datastore',
nodes: ['esxi-01'],
},
}),
];
render(() => (
<Storage
embedded
tableOnly
showFilterToolbar
forcedSourceFilter="vmware-vsphere"
filterAriaLabel="vSphere datastore filters"
filterSearchPlaceholder="Search vSphere datastores by name, host, or capacity group"
filterSearchEmptyMessage="Recent vSphere datastore searches appear here."
/>
));
expect(screen.getByRole('group', { name: 'vSphere datastore filters' })).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search vSphere datastores by name, host, or capacity group'),
).toBeInTheDocument();
});
it('supports Proxmox platform table embedding without standalone page chrome', async () => {
mockLocationPath = '/proxmox/storage';
hookResources = [
@@ -37,11 +37,10 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
const workloadTypeOptions = (): FilterSelectOption[] =>
(isProxmoxScope()
? WORKLOAD_TYPE_OPTIONS.filter((option) =>
option.value === 'all' || option.value === 'vm' || option.value === 'container',
).map((option) =>
option.value === 'container' ? { ...option, label: 'LXCs' } : option,
)
? WORKLOAD_TYPE_OPTIONS.filter(
(option) =>
option.value === 'all' || option.value === 'vm' || option.value === 'container',
).map((option) => (option.value === 'container' ? { ...option, label: 'LXCs' } : option))
: WORKLOAD_TYPE_OPTIONS
).map((option) => ({
value: option.value,
@@ -54,16 +53,11 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
: option.value === 'pod'
? BoxesIcon
: undefined,
tone:
option.value === 'vm'
? 'info'
: option.value === 'container'
? 'success'
: undefined,
tone: option.value === 'vm' ? 'info' : option.value === 'container' ? 'success' : undefined,
}));
const workloadStatusOptions = (): FilterSelectOption[] =>
WORKLOAD_STATUS_FILTER_OPTIONS.map((option) => ({
(props.statusOptions ?? WORKLOAD_STATUS_FILTER_OPTIONS).map((option) => ({
value: option.value,
label: option.label,
leading:
@@ -95,6 +89,7 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
hostFilterValue: props.hostFilter?.value,
platformFilterValue: props.platformFilter?.value,
namespaceFilterValue: props.namespaceFilter?.value,
containerRuntimeFilterValue: props.containerRuntimeFilter?.value,
}),
);
@@ -128,18 +123,16 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
});
}
filters.push(
{
id: 'workloads-status',
label: 'Status',
group: 'status',
inline: true,
value: props.statusMode,
setValue: (value: string) => props.setStatusMode(value as WorkloadsStatusMode),
defaultValue: DEFAULT_WORKLOADS_STATUS_MODE,
options: workloadStatusOptions,
},
);
filters.push({
id: 'workloads-status',
label: 'Status',
group: 'status',
inline: true,
value: props.statusMode,
setValue: (value: string) => props.setStatusMode(value as WorkloadsStatusMode),
defaultValue: DEFAULT_WORKLOADS_STATUS_MODE,
options: workloadStatusOptions,
});
const hostFilter = props.hostFilter;
if (hostFilter) {
@@ -199,13 +192,14 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
return (
<FilterBar
role="group"
ariaLabel="Workloads filters"
ariaLabel={props.ariaLabel ?? 'Workloads filters'}
isMobile={isMobile}
search={{
value: props.search,
setValue: props.setSearch,
placeholder: 'Search or filter...',
placeholder: props.searchPlaceholder ?? 'Search workloads by name, ID, node, or image',
historyKey: STORAGE_KEYS.WORKLOADS_SEARCH_HISTORY,
emptyMessage: props.searchEmptyMessage ?? 'Recent workload searches appear here.',
onBeforeAutoFocus: props.onBeforeAutoFocus,
}}
searchTrailing={props.searchTrailing}
@@ -114,6 +114,10 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
setSortKey={state.setSortKey}
setSortDirection={state.setSortDirection}
onBeforeAutoFocus={state.handleBeforeAutoFocus}
ariaLabel={props.filterAriaLabel}
searchPlaceholder={props.filterSearchPlaceholder}
searchEmptyMessage={props.filterSearchEmptyMessage}
statusOptions={props.filterStatusOptions}
columnVisibility={state.workloadsFilterColumnVisibility()}
chartsCollapsed={
state.isWorkloadsRoute() ? state.workloadsSummaryCollapsed : undefined
@@ -98,6 +98,30 @@ describe('WorkloadsFilter', () => {
expect(screen.getByTestId('search-input')).toBeInTheDocument();
});
it('accepts page-owned search copy and status labels', () => {
render(() => (
<WorkloadsFilter
{...makeProps({
searchPlaceholder: 'Search pods by namespace or image',
statusOptions: [
{ value: 'all', label: 'All' },
{ value: 'running', label: 'Running' },
{ value: 'degraded', label: 'Needs attention' },
{ value: 'stopped', label: 'Not running' },
],
})}
/>
));
expect(screen.getByPlaceholderText('Search pods by namespace or image')).toBeInTheDocument();
expect(
within(inlineFilterGroup('Status')).getByRole('button', { name: 'Needs attention' }),
).toBeInTheDocument();
expect(
within(inlineFilterGroup('Status')).getByRole('button', { name: 'Not running' }),
).toBeInTheDocument();
});
it('exposes Type and Status as one-click inline controls', () => {
render(() => <WorkloadsFilter {...makeProps()} />);
expect(inlineFilterGroup('Type')).toBeInTheDocument();
@@ -146,6 +146,55 @@ describe('workloadSelectors', () => {
expect(combined.map((g) => g.name)).toEqual(['alpha-api']);
});
it('searches workload-native fields shown by platform pages', () => {
const guests = [
makeGuest(1, {
name: 'api-pod',
type: 'pod',
workloadType: 'pod',
image: 'registry.local/api:v2',
namespace: 'payments',
contextLabel: 'prod-cluster',
node: 'worker-a',
status: 'running',
}),
makeGuest(2, {
name: 'worker-container',
type: 'app-container',
workloadType: 'app-container',
image: 'redis:7',
containerRuntime: 'podman',
contextLabel: 'edge-docker-host',
platformType: 'docker',
status: 'running',
}),
];
expect(
filterWorkloads({
guests,
viewMode: 'pod',
statusMode: 'all',
searchTerm: 'payments',
selectedNode: null,
selectedHostHint: null,
selectedKubernetesContext: null,
}).map((g) => g.name),
).toEqual(['api-pod']);
expect(
filterWorkloads({
guests,
viewMode: 'app-container',
statusMode: 'all',
searchTerm: 'podman',
selectedNode: null,
selectedHostHint: null,
selectedKubernetesContext: null,
}).map((g) => g.name),
).toEqual(['worker-container']);
});
it('filters k8s by selected context key and filters non-k8s by host hint', () => {
const guests = [
makeGuest(1, {
@@ -13,6 +13,7 @@ import { useWorkloadUrlSync } from './useWorkloadUrlSync';
export interface WorkloadRouteStateOptions {
allGuests: Accessor<WorkloadGuest[]>;
allowEmbeddedScopeFilters?: boolean;
forcedViewMode?: ViewMode;
showFilters: Accessor<boolean>;
setShowFilters: Setter<boolean>;
}
@@ -40,6 +41,7 @@ export function useWorkloadRouteState(options: WorkloadRouteStateOptions) {
serialize: (value) => value,
},
);
const filterViewMode = () => options.forcedViewMode ?? viewMode();
const handleNodeSelect = (nodeId: string | null, nodeType: 'pve' | 'pbs' | 'pmg' | null) => {
const selection = resolveWorkloadsWorkloadNodeSelection({
@@ -93,7 +95,7 @@ export function useWorkloadRouteState(options: WorkloadRouteStateOptions) {
allGuests: options.allGuests,
isWorkloadsRoute,
allowEmbeddedScopeFilters: () => options.allowEmbeddedScopeFilters === true,
viewMode,
viewMode: filterViewMode,
containerRuntime,
selectedPlatform,
selectedNode,
@@ -29,6 +29,7 @@ import {
import {
type WorkloadsGroupingMode,
type WorkloadsMetricDisplayMode,
type WorkloadsStatusOption,
type WorkloadsSortKey,
} from './workloadsFilterModel';
import { type WorkloadTableMetricHistoryRange } from './workloadMetricHistoryModel';
@@ -65,6 +66,10 @@ export interface WorkloadsSurfaceProps {
forcedViewMode?: ViewMode;
forcedGroupingMode?: WorkloadsGroupingMode;
defaultSortKey?: WorkloadsSortKey;
filterAriaLabel?: string;
filterSearchPlaceholder?: string;
filterSearchEmptyMessage?: string;
filterStatusOptions?: readonly WorkloadsStatusOption[];
// When the surface is mounted inside a platform-first page, the page owns
// platform scope through `forcedPlatform`. Setting `showFilterToolbar`
// keeps the operator-facing WorkloadsFilter visible alongside the table
@@ -189,6 +194,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
} = useWorkloadRouteState({
allGuests,
allowEmbeddedScopeFilters: props.allowEmbeddedScopeFilters,
forcedViewMode: props.forcedViewMode,
showFilters,
setShowFilters,
});
@@ -49,6 +49,36 @@ type SortDirection = 'asc' | 'desc';
type SortValue = string | number | boolean | null | undefined;
const workloadSearchCandidates = (guest: WorkloadGuest): Array<string | number | undefined> => [
guest.name,
guest.id,
guest.displayId,
guest.vmid,
guest.node,
guest.status,
guest.image,
guest.namespace,
guest.contextLabel,
guest.clusterName,
guest.instance,
guest.platformType,
guest.workloadType,
guest.containerRuntime,
guest.containerId,
guest.dockerHostId,
guest.kubernetesAgentId,
];
const matchesWorkloadTextSearch = (guest: WorkloadGuest, term: string): boolean => {
const needle = term.trim().toLowerCase();
if (!needle) return true;
return workloadSearchCandidates(guest)
.filter(
(value): value is string | number => typeof value === 'string' || typeof value === 'number',
)
.some((value) => String(value).toLowerCase().includes(needle));
};
export const filterWorkloads = ({
guests: allGuests,
viewMode,
@@ -155,13 +185,7 @@ export const filterWorkloads = ({
if (textSearches.length > 0) {
guests = guests.filter((g) =>
textSearches.some(
(term) =>
g.name.toLowerCase().includes(term) ||
g.vmid.toString().includes(term) ||
g.node.toLowerCase().includes(term) ||
g.status.toLowerCase().includes(term),
),
textSearches.some((term) => matchesWorkloadTextSearch(g, term)),
);
}
}
@@ -9,6 +9,11 @@ export type WorkloadsGroupingMode = 'grouped' | 'flat';
export type WorkloadsMetricDisplayMode = 'bars' | 'sparklines';
export type WorkloadsSortKey = keyof WorkloadGuest | 'diskIo' | 'netIo';
export interface WorkloadsStatusOption {
value: WorkloadsStatusMode;
label: string;
}
export interface WorkloadsFilterSelectOption {
value: string;
label: string;
@@ -35,6 +40,10 @@ export interface WorkloadsFilterProps {
setSortKey: (value: WorkloadsSortKey) => void;
setSortDirection: (value: 'asc' | 'desc') => void;
onBeforeAutoFocus?: () => boolean;
ariaLabel?: string;
searchPlaceholder?: string;
searchEmptyMessage?: string;
statusOptions?: readonly WorkloadsStatusOption[];
columnVisibility?: {
availableColumns: ColumnDef[];
isColumnHidden: (id: string) => boolean;
@@ -67,6 +76,7 @@ export interface CountActiveWorkloadsFiltersOptions {
hostFilterValue?: string;
platformFilterValue?: string;
namespaceFilterValue?: string;
containerRuntimeFilterValue?: string;
}
export type HasActiveWorkloadsFiltersOptions = CountActiveWorkloadsFiltersOptions;
@@ -89,6 +99,7 @@ export const countActiveWorkloadsFilters = (
if ((options.hostFilterValue ?? '') !== '') count++;
if ((options.platformFilterValue ?? '') !== '') count++;
if ((options.namespaceFilterValue ?? '') !== '') count++;
if ((options.containerRuntimeFilterValue ?? '') !== '') count++;
return count;
};
@@ -2,6 +2,7 @@ import ContainerIcon from 'lucide-solid/icons/container';
import { Show, createMemo } from 'solid-js';
import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel';
import { useWorkloadsState } from '@/components/Workloads/useWorkloadsState';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import {
@@ -27,6 +28,12 @@ const DOCKER_WORKLOAD_COLUMN_LABEL_OVERRIDES = {
context: 'Host',
disk: 'Writable layer',
} as const;
const DOCKER_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
{ value: 'all', label: 'All' },
{ value: 'running', label: 'Running' },
{ value: 'degraded', label: 'Attention' },
{ value: 'stopped', label: 'Stopped' },
];
const dockerIcon = () => <ContainerIcon class="h-6 w-6 text-slate-400" />;
@@ -128,6 +135,10 @@ export function DockerPageSurface() {
setSortKey={workloadsState.setSortKey}
setSortDirection={workloadsState.setSortDirection}
onBeforeAutoFocus={workloadsState.handleBeforeAutoFocus}
ariaLabel="Docker workload filters"
searchPlaceholder="Search containers by name, image, host, or runtime"
searchEmptyMessage="Recent Docker container searches appear here."
statusOptions={DOCKER_WORKLOAD_STATUS_OPTIONS}
columnVisibility={workloadsState.workloadsFilterColumnVisibility()}
containerRuntimeFilter={workloadsState.containerRuntimeFilterConfig()}
hostFilter={workloadsState.hostFilterConfig()}
@@ -226,6 +226,9 @@ describe('dockerPageModel', () => {
expect(filterDockerHosts(hosts, { searchTerm: 'docker 01' }).map((host) => host.id)).toEqual([
'agent:docker-01',
]);
expect(filterDockerHosts(hosts, { searchTerm: 'podman' }).map((host) => host.id)).toEqual([
'agent:podman-01',
]);
expect(
filterDockerHosts(hosts, { selectedHostScope: 'agent:podman-01' }).map((host) => host.id),
).toEqual(['agent:podman-01']);
@@ -159,6 +159,14 @@ const resourceSearchCandidates = (resource: Resource): Array<string | undefined>
resource.parentName,
resource.agent?.hostname,
resource.docker?.hostname,
resource.docker?.runtime,
resource.docker?.runtimeVersion,
resource.docker?.dockerVersion,
resource.docker?.os,
resource.docker?.kernelVersion,
resource.docker?.architecture,
resource.docker?.swarm?.clusterName,
resource.docker?.swarm?.nodeRole,
resource.identity?.hostname,
resource.canonicalIdentity?.displayName,
resource.canonicalIdentity?.hostname,
@@ -1,6 +1,7 @@
import ShipWheelIcon from 'lucide-solid/icons/ship-wheel';
import { Show, createMemo } from 'solid-js';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import {
PlatformErrorState,
@@ -20,6 +21,12 @@ const KUBERNETES_RESOURCE_QUERY = 'type=k8s-cluster,k8s-node,pod,k8s-deployment,
const KUBERNETES_PLATFORM_FILTER = 'kubernetes';
const KUBERNETES_WORKLOAD_FORCED_VIEW_MODE = 'pod';
const KUBERNETES_WORKLOAD_COLUMN_SCOPE = 'kubernetes-pods';
const KUBERNETES_POD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
{ value: 'all', label: 'All' },
{ value: 'running', label: 'Running' },
{ value: 'degraded', label: 'Needs attention' },
{ value: 'stopped', label: 'Not running' },
];
const k8sIcon = () => <ShipWheelIcon class="h-6 w-6 text-slate-400" />;
@@ -94,8 +101,13 @@ export function KubernetesPageSurface() {
tableOnly
showFilterToolbar
suppressPlatformFilter
allowEmbeddedScopeFilters
forcedPlatform={KUBERNETES_PLATFORM_FILTER}
forcedViewMode={KUBERNETES_WORKLOAD_FORCED_VIEW_MODE}
filterAriaLabel="Kubernetes pod filters"
filterSearchPlaceholder="Search pods by name, namespace, image, cluster, or node"
filterSearchEmptyMessage="Recent Kubernetes pod searches appear here."
filterStatusOptions={KUBERNETES_POD_STATUS_OPTIONS}
columnVisibilityStorageScope={KUBERNETES_WORKLOAD_COLUMN_SCOPE}
compactGroupHeaders
/>
@@ -11,6 +11,8 @@ const mockWorkloadsSurface = vi.fn((props: WorkloadsSurfaceProps) => (
data-forced-platform={props.forcedPlatform}
data-forced-view-mode={props.forcedViewMode}
data-column-scope={props.columnVisibilityStorageScope}
data-allow-scope-filters={String(props.allowEmbeddedScopeFilters)}
data-filter-placeholder={props.filterSearchPlaceholder}
/>
));
@@ -76,5 +78,13 @@ describe('KubernetesPageSurface contract', () => {
'data-column-scope',
'kubernetes-pods',
);
expect(screen.getByTestId('kubernetes-workloads-surface')).toHaveAttribute(
'data-allow-scope-filters',
'true',
);
expect(screen.getByTestId('kubernetes-workloads-surface')).toHaveAttribute(
'data-filter-placeholder',
'Search pods by name, namespace, image, cluster, or node',
);
});
});
@@ -66,6 +66,34 @@ describe('filterPlatformResources', () => {
]);
});
it('searches platform-native metadata used by bespoke tables', () => {
const nativeRows: Resource[] = [
makeResource({
id: 'docker-host',
type: 'agent',
status: 'online',
docker: { runtimeVersion: '24.0.7', swarm: { nodeRole: 'manager' } },
}),
makeResource({
id: 'k8s-deploy',
type: 'k8s-deployment',
status: 'online',
kubernetes: {
clusterName: 'prod-cluster',
namespace: 'payments',
containerRuntimeVersion: 'containerd://1.7',
},
}),
];
expect(filterPlatformResources(nativeRows, 'manager', 'all').map((r) => r.id)).toEqual([
'docker-host',
]);
expect(filterPlatformResources(nativeRows, 'payments', 'all').map((r) => r.id)).toEqual([
'k8s-deploy',
]);
});
it('combines search and status filters', () => {
const filtered = filterPlatformResources(resources, 'host', 'degraded');
expect(filtered.map((r) => r.id).sort()).toEqual(['host-charlie', 'host-foxtrot'].sort());
@@ -146,6 +146,38 @@ const matchesPlatformSearch = (resource: Resource, search: string): boolean => {
resource.displayName,
resource.id,
resource.parentName,
resource.platformId,
resource.platformType,
resource.agent?.hostname,
resource.identity?.hostname,
resource.canonicalIdentity?.displayName,
resource.canonicalIdentity?.hostname,
resource.canonicalIdentity?.primaryId,
...(resource.canonicalIdentity?.aliases ?? []),
resource.docker?.hostname,
resource.docker?.runtime,
resource.docker?.runtimeVersion,
resource.docker?.dockerVersion,
resource.docker?.image,
resource.docker?.mode,
resource.docker?.swarm?.clusterName,
resource.docker?.swarm?.nodeRole,
resource.kubernetes?.clusterName,
resource.kubernetes?.clusterId,
resource.kubernetes?.context,
resource.kubernetes?.namespace,
resource.kubernetes?.podName,
resource.kubernetes?.nodeName,
resource.kubernetes?.version,
resource.kubernetes?.kubeletVersion,
resource.kubernetes?.containerRuntimeVersion,
resource.pmg?.hostname,
resource.pmg?.version,
resource.vmware?.connectionName,
resource.vmware?.vcenterHost,
resource.vmware?.runtimeHostName,
resource.vmware?.clusterName,
resource.vmware?.datastoreNames?.join(' '),
...(resource.tags ?? []),
]
.filter((value): value is string => typeof value === 'string')
@@ -4,6 +4,7 @@ import StorageSurface from '@/components/Storage/Storage';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import {
DEFAULT_WORKLOADS_METRIC_DISPLAY_MODE,
type WorkloadsStatusOption,
type WorkloadsMetricDisplayMode,
} from '@/components/Workloads/workloadsFilterModel';
import {
@@ -45,6 +46,12 @@ const PROXMOX_RESOURCE_QUERY =
const PROXMOX_PLATFORM_FILTER = 'proxmox-pve';
const PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE = 'proxmox';
const VALID_TABS = new Set<ProxmoxPageTabId>(PROXMOX_TAB_SPECS.map((tab) => tab.id));
const PROXMOX_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
{ value: 'all', label: 'All' },
{ value: 'running', label: 'Running' },
{ value: 'degraded', label: 'Attention' },
{ value: 'stopped', label: 'Stopped' },
];
const proxmoxIcon = () => <ProxmoxIcon class="h-6 w-6 text-slate-400" />;
@@ -147,6 +154,10 @@ export function ProxmoxPageSurface() {
allowEmbeddedScopeFilters
forcedPlatform={PROXMOX_PLATFORM_FILTER}
statusModeStorageScope={PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE}
filterAriaLabel="Proxmox workload filters"
filterSearchPlaceholder="Search VMs and LXCs by name, VMID, node, or status"
filterSearchEmptyMessage="Recent Proxmox workload searches appear here."
filterStatusOptions={PROXMOX_WORKLOAD_STATUS_OPTIONS}
compactGroupHeaders
groupNodeDrawerMode="disabled"
metricDisplayMode={metricDisplayMode}
@@ -164,6 +175,9 @@ export function ProxmoxPageSurface() {
tableOnly
showFilterToolbar
forcedSourceFilter={PROXMOX_PLATFORM_FILTER}
filterAriaLabel="Proxmox storage filters"
filterSearchPlaceholder="Search Proxmox storage by pool, datastore, node, or device"
filterSearchEmptyMessage="Recent Proxmox storage searches appear here."
/>
</Show>
<Show when={activeTab() === 'replication'}>
@@ -3,6 +3,7 @@ import DatabaseIcon from 'lucide-solid/icons/database';
import { Show, createMemo } from 'solid-js';
import StorageSurface from '@/components/Storage/Storage';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import {
PlatformErrorState,
@@ -24,6 +25,12 @@ import {
const TRUENAS_RESOURCE_QUERY = 'type=agent,app-container,storage,physical_disk';
const TRUENAS_PLATFORM_FILTER = 'truenas';
const VALID_TABS = new Set<TrueNASPageTabId>(TRUENAS_TAB_SPECS.map((tab) => tab.id));
const TRUENAS_APP_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
{ value: 'all', label: 'All' },
{ value: 'running', label: 'Running' },
{ value: 'degraded', label: 'Attention' },
{ value: 'stopped', label: 'Stopped' },
];
const truenasIcon = () => <DatabaseIcon class="h-6 w-6 text-slate-400" />;
@@ -98,7 +105,13 @@ export function TrueNASPageSurface() {
tableOnly
showFilterToolbar
suppressPlatformFilter
allowEmbeddedScopeFilters
forcedPlatform={TRUENAS_PLATFORM_FILTER}
forcedViewMode="app-container"
filterAriaLabel="TrueNAS app filters"
filterSearchPlaceholder="Search TrueNAS apps by name, image, namespace, or system"
filterSearchEmptyMessage="Recent TrueNAS app searches appear here."
filterStatusOptions={TRUENAS_APP_STATUS_OPTIONS}
compactGroupHeaders
/>
</Show>
@@ -110,6 +123,9 @@ export function TrueNASPageSurface() {
tableOnly
showFilterToolbar
forcedSourceFilter={TRUENAS_PLATFORM_FILTER}
filterAriaLabel="TrueNAS storage filters"
filterSearchPlaceholder="Search TrueNAS pools, datasets, disks, or nodes"
filterSearchEmptyMessage="Recent TrueNAS storage searches appear here."
/>
</Show>
</Show>
@@ -3,6 +3,7 @@ import CpuIcon from 'lucide-solid/icons/cpu';
import { Show, createMemo } from 'solid-js';
import StorageSurface from '@/components/Storage/Storage';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import {
PlatformErrorState,
@@ -18,6 +19,12 @@ import { VMWARE_TAB_SPECS, buildVmwarePageModel, type VmwarePageTabId } from './
const VMWARE_RESOURCE_QUERY = 'type=agent,vm,storage';
const VMWARE_PLATFORM_FILTER = 'vmware-vsphere';
const VALID_TABS = new Set<VmwarePageTabId>(VMWARE_TAB_SPECS.map((tab) => tab.id));
const VMWARE_VM_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
{ value: 'all', label: 'All' },
{ value: 'running', label: 'Powered on' },
{ value: 'degraded', label: 'Attention' },
{ value: 'stopped', label: 'Powered off' },
];
const vmwareIcon = () => <CpuIcon class="h-6 w-6 text-slate-400" />;
@@ -91,7 +98,13 @@ export function VmwarePageSurface() {
tableOnly
showFilterToolbar
suppressPlatformFilter
allowEmbeddedScopeFilters
forcedPlatform={VMWARE_PLATFORM_FILTER}
forcedViewMode="vm"
filterAriaLabel="vSphere VM filters"
filterSearchPlaceholder="Search vSphere VMs by name, VM ID, host, or status"
filterSearchEmptyMessage="Recent vSphere VM searches appear here."
filterStatusOptions={VMWARE_VM_STATUS_OPTIONS}
compactGroupHeaders
/>
</div>
@@ -103,6 +116,9 @@ export function VmwarePageSurface() {
showFilterToolbar
forcedSourceFilter={VMWARE_PLATFORM_FILTER}
forcedView="pools"
filterAriaLabel="vSphere datastore filters"
filterSearchPlaceholder="Search vSphere datastores by name, host, or capacity group"
filterSearchEmptyMessage="Recent vSphere datastore searches appear here."
/>
</Show>
</Show>