diff --git a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts new file mode 100644 index 000000000..a2891e0e7 --- /dev/null +++ b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { filterPlatformResources } from '../sharedPlatformPage'; + +const makeResource = ( + partial: Partial & Pick, +): Resource => ({ + name: partial.id, + displayName: partial.id, + platformId: 'lab', + platformType: 'docker', + sourceType: 'agent', + sources: ['docker'], + lastSeen: 1_700_000_000_000, + ...partial, +}); + +describe('filterPlatformResources', () => { + const resources: Resource[] = [ + makeResource({ id: 'host-alpha', type: 'agent', status: 'online' }), + makeResource({ id: 'host-bravo', type: 'agent', status: 'running' }), + makeResource({ id: 'host-charlie', type: 'agent', status: 'degraded' }), + makeResource({ id: 'host-delta', type: 'agent', status: 'offline' }), + makeResource({ id: 'host-echo', type: 'agent', status: 'stopped' }), + makeResource({ id: 'host-foxtrot', type: 'agent', status: 'paused' }), + makeResource({ + id: 'host-with-tag', + type: 'agent', + status: 'online', + tags: ['prod', 'gpu'], + }), + ]; + + it('keeps all rows when no filters apply', () => { + expect(filterPlatformResources(resources, '', 'all')).toHaveLength(resources.length); + }); + + it('collapses online/running into the online status chip', () => { + const filtered = filterPlatformResources(resources, '', 'online'); + expect(filtered.map((r) => r.id).sort()).toEqual( + ['host-alpha', 'host-bravo', 'host-with-tag'].sort(), + ); + }); + + it('collapses degraded/paused into the degraded chip', () => { + const filtered = filterPlatformResources(resources, '', 'degraded'); + expect(filtered.map((r) => r.id).sort()).toEqual(['host-charlie', 'host-foxtrot'].sort()); + }); + + it('collapses offline/stopped into the offline chip', () => { + const filtered = filterPlatformResources(resources, '', 'offline'); + expect(filtered.map((r) => r.id).sort()).toEqual(['host-delta', 'host-echo'].sort()); + }); + + it('searches against id, display name, parent, and tags case-insensitively', () => { + expect(filterPlatformResources(resources, 'ALPHA', 'all').map((r) => r.id)).toEqual([ + 'host-alpha', + ]); + expect(filterPlatformResources(resources, 'gpu', 'all').map((r) => r.id)).toEqual([ + 'host-with-tag', + ]); + }); + + 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()); + }); +}); diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index e59e0e1f0..3a8c42876 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -1,10 +1,12 @@ import { A } from '@solidjs/router'; import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert'; -import { For, Show, createSignal, type Component, type JSX } from 'solid-js'; +import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; import { EmptyState } from '@/components/shared/EmptyState'; +import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup'; +import { SearchInput } from '@/components/shared/SearchInput'; import { TableCard } from '@/components/shared/TableCard'; import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable'; -import type { Resource } from '@/types/resource'; +import type { Resource, ResourceStatus } from '@/types/resource'; export type PlatformTabSpec = { id: TabId; @@ -79,14 +81,97 @@ export function PlatformErrorState(props: { ); } +// Status filter applied client-side by the platform-page toolbar. Mirrors +// the v5 dashboard/storage status segmented control: All / Online (running) +// / Degraded / Offline (stopped). Resource statuses are normalized through +// `mapResourceStatusToTriad` so per-platform vocabulary differences (e.g. +// 'running' vs 'online', 'stopped' vs 'offline') collapse to one chip set. +export type PlatformResourceStatusFilter = 'all' | 'online' | 'degraded' | 'offline'; + +const PLATFORM_STATUS_FILTER_OPTIONS: FilterOption[] = [ + { value: 'all', label: 'All' }, + { value: 'online', label: 'Online' }, + { value: 'degraded', label: 'Degraded' }, + { value: 'offline', label: 'Offline' }, +]; + +const ONLINE_STATUSES = new Set(['online', 'running']); +const OFFLINE_STATUSES = new Set(['offline', 'stopped']); +const DEGRADED_STATUSES = new Set(['degraded', 'paused']); + +const mapResourceStatusToTriad = ( + status: ResourceStatus | undefined, +): Exclude | 'unknown' => { + if (!status) return 'unknown'; + if (ONLINE_STATUSES.has(status)) return 'online'; + if (DEGRADED_STATUSES.has(status)) return 'degraded'; + if (OFFLINE_STATUSES.has(status)) return 'offline'; + return 'unknown'; +}; + +const matchesPlatformSearch = (resource: Resource, search: string): boolean => { + if (!search) return true; + const needle = search.trim().toLowerCase(); + if (!needle) return true; + const haystack = [ + resource.name, + resource.displayName, + resource.id, + resource.parentName, + ...(resource.tags ?? []), + ] + .filter((value): value is string => typeof value === 'string') + .join(' ') + .toLowerCase(); + return haystack.includes(needle); +}; + +export const filterPlatformResources = ( + resources: Resource[], + search: string, + status: PlatformResourceStatusFilter, +): Resource[] => { + const result: Resource[] = []; + for (const resource of resources) { + if (!matchesPlatformSearch(resource, search)) continue; + if (status !== 'all') { + const mapped = mapResourceStatusToTriad(resource.status); + if (mapped !== status) continue; + } + result.push(resource); + } + return result; +}; + +// Compact operator-facing counter shown at the right of the toolbar so +// users can read total / matching at a glance, mirroring v5's dense +// dashboard counters without spawning a card grid. +const PlatformResourceCounter: Component<{ visible: number; total: number }> = (props) => ( + + {props.total} rows}> + {props.visible} of {props.total} rows + + +); + export const PlatformResourceTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; emptyTitle: string; emptyDescription: string; groupingMode?: 'grouped' | 'flat'; + searchPlaceholder?: string; }> = (props) => { const [expandedResourceId, setExpandedResourceId] = createSignal(null); + const [search, setSearch] = createSignal(''); + const [status, setStatus] = createSignal('all'); + + const filteredResources = createMemo(() => + filterPlatformResources(props.resources, search(), status()), + ); + + const visibleCount = createMemo(() => filteredResources().length); + const totalCount = createMemo(() => props.resources.length); return ( } > - +
+
+
+ +
+ + +
+ 0} + fallback={ + + } + > + + +
); }; diff --git a/tests/integration/tests/68-platform-pages-shell.spec.ts b/tests/integration/tests/68-platform-pages-shell.spec.ts index b45f40bf1..ac1ba39d4 100644 --- a/tests/integration/tests/68-platform-pages-shell.spec.ts +++ b/tests/integration/tests/68-platform-pages-shell.spec.ts @@ -160,19 +160,27 @@ test.describe('Platform pages shell', () => { }); } - test('platform sub-tabs that embed Workloads/Storage surfaces expose v5-style operator controls', async ({ + test('every platform sub-tab exposes v5-style operator controls', async ({ page, }, testInfo) => { test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop chrome audit'); - // Each entry verifies that the embedded canonical surface renders its - // operator toolbar (search + status/grouping/view chips) under - // platform-page chrome — not a stripped table-only view. + // Every populated sub-tab — embedded canonical Workloads/Storage AND + // UnifiedResourceTable-backed infra views — must render an operator + // search input under platform-page chrome. The shared + // PlatformResourceTable wrapper provides the toolbar for the + // UnifiedResourceTable-backed tabs; the embedded surfaces use their + // own canonical FilterBar via `showFilterToolbar`. const cases: ReadonlyArray<{ path: string; testId: string }> = [ + { path: '/docker/overview', testId: 'docker-page' }, { path: '/docker/containers', testId: 'docker-page' }, + { path: '/kubernetes/overview', testId: 'kubernetes-page' }, + { path: '/kubernetes/nodes', testId: 'kubernetes-page' }, { path: '/kubernetes/pods', testId: 'kubernetes-page' }, + { path: '/kubernetes/deployments', testId: 'kubernetes-page' }, { path: '/truenas/storage', testId: 'truenas-page' }, { path: '/truenas/apps', testId: 'truenas-page' }, + { path: '/vmware/overview', testId: 'vmware-page' }, { path: '/vmware/vms', testId: 'vmware-page' }, { path: '/vmware/storage', testId: 'vmware-page' }, ]; @@ -182,13 +190,11 @@ test.describe('Platform pages shell', () => { const pageRoot = page.getByTestId(c.testId); await expect(pageRoot).toBeVisible({ timeout: 30_000 }); - // The canonical Workloads/Storage operator toolbars use the shared - // FilterBar primitive, which renders a search input. If the platform - // page mistakenly stripped the toolbar, no search input would render - // inside the page region. await expect( pageRoot - .locator('input[type="search"], input[placeholder*="Search" i], input[placeholder*="filter" i]') + .locator( + 'input[type="search"], input[placeholder*="Search" i], input[placeholder*="filter" i]', + ) .first(), ).toBeVisible({ timeout: 30_000 }); }