mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Make platform filters page-specific
This commit is contained in:
@@ -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
|
||||
/>
|
||||
|
||||
+10
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user