frontend(platforms): add v5-style operator toolbar to UnifiedResourceTable platform sub-tabs
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run

Closes the second half of the v5→v6 affordance bridge. The first commit
in this chain (65b069dbb) brought operator controls to the embedded
Workloads/Storage sub-tabs (Docker Containers, K8s Pods, TrueNAS
Storage/Apps, vSphere VMs/Storage). The UnifiedResourceTable-backed
sub-tabs (Docker Hosts, K8s Clusters/Nodes/Deployments, vSphere Hosts)
were left with the table's built-in sort handles only — no search, no
status chips, no counters — which read as a non-native experience
against the v5 dashboard/storage filter density.

Extend the shared PlatformResourceTable in
`frontend-modern/src/features/platformPage/sharedPlatformPage.tsx`
with a canonical operator toolbar:

- SearchInput (the canonical primitive, with the standard placeholder
  + clear-on-escape semantics) for name/display-name/id/parent/tags
  search.
- FilterButtonGroup status chip strip: All / Online / Degraded /
  Offline, with the per-platform status vocabulary (running, paused,
  stopped, etc.) collapsed through the shared
  `mapResourceStatusToTriad` helper.
- Compact "N of M rows" counter on the right of the toolbar so
  operators can read total / matching at a glance without spawning a
  card grid.
- Client-side filtering via `filterPlatformResources` before resources
  reach UnifiedResourceTable, so the canonical table stays the data
  surface and the platform page owns the chrome.

Every platform sub-tab now exposes operator controls:

- Docker Hosts (UnifiedResourceTable + new toolbar)
- Docker Containers (WorkloadsSurface canonical toolbar)
- Kubernetes Clusters/Nodes/Deployments (UnifiedResourceTable + new
  toolbar)
- Kubernetes Pods (WorkloadsSurface canonical toolbar)
- TrueNAS Storage/Apps (canonical toolbars)
- vSphere Hosts (UnifiedResourceTable + new toolbar)
- vSphere VMs/Storage (canonical toolbars)

Browser verification (Playwright, chromium, live mock-mode):
- Expanded the operator-controls audit to cover every populated sub-tab
  route (11 routes total). All assertions pass. 9/9 spec tests green.

Targeted vitest:
- New sharedPlatformPage.test.ts covers filterPlatformResources
  (6 tests, all pass) including the status-vocabulary collapse
  (online↔running, degraded↔paused, offline↔stopped) and the
  search-across-id/displayName/parent/tags contract.
- WorkloadsSurface and Storage surface contract tests continue to
  pass.
This commit is contained in:
rcourtman 2026-05-16 00:08:12 +01:00
parent 65b069dbba
commit 469691bc74
3 changed files with 204 additions and 17 deletions

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { filterPlatformResources } from '../sharedPlatformPage';
const makeResource = (
partial: Partial<Resource> & Pick<Resource, 'id' | 'type' | 'status'>,
): 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());
});
});

View file

@ -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<TabId extends string> = {
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<PlatformResourceStatusFilter>[] = [
{ value: 'all', label: 'All' },
{ value: 'online', label: 'Online' },
{ value: 'degraded', label: 'Degraded' },
{ value: 'offline', label: 'Offline' },
];
const ONLINE_STATUSES = new Set<ResourceStatus>(['online', 'running']);
const OFFLINE_STATUSES = new Set<ResourceStatus>(['offline', 'stopped']);
const DEGRADED_STATUSES = new Set<ResourceStatus>(['degraded', 'paused']);
const mapResourceStatusToTriad = (
status: ResourceStatus | undefined,
): Exclude<PlatformResourceStatusFilter, 'all'> | '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) => (
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
<Show when={props.visible !== props.total} fallback={<>{props.total} rows</>}>
{props.visible} of {props.total} rows
</Show>
</span>
);
export const PlatformResourceTable: Component<{
resources: Resource[];
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
groupingMode?: 'grouped' | 'flat';
searchPlaceholder?: string;
}> = (props) => {
const [expandedResourceId, setExpandedResourceId] = createSignal<string | null>(null);
const [search, setSearch] = createSignal('');
const [status, setStatus] = createSignal<PlatformResourceStatusFilter>('all');
const filteredResources = createMemo(() =>
filterPlatformResources(props.resources, search(), status()),
);
const visibleCount = createMemo(() => filteredResources().length);
const totalCount = createMemo(() => props.resources.length);
return (
<Show
@ -99,12 +184,40 @@ export const PlatformResourceTable: Component<{
/>
}
>
<UnifiedResourceTable
resources={props.resources}
expandedResourceId={expandedResourceId()}
onExpandedResourceChange={setExpandedResourceId}
groupingMode={props.groupingMode ?? 'grouped'}
/>
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-[180px] flex-1 sm:max-w-xs">
<SearchInput
value={search}
onChange={setSearch}
placeholder={props.searchPlaceholder ?? 'Search rows'}
/>
</div>
<FilterButtonGroup
options={PLATFORM_STATUS_FILTER_OPTIONS}
value={status()}
onChange={setStatus}
/>
<PlatformResourceCounter visible={visibleCount()} total={totalCount()} />
</div>
<Show
when={filteredResources().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No rows match current filters"
description="Adjust the search or status filter to see more rows."
/>
}
>
<UnifiedResourceTable
resources={filteredResources()}
expandedResourceId={expandedResourceId()}
onExpandedResourceChange={setExpandedResourceId}
groupingMode={props.groupingMode ?? 'grouped'}
/>
</Show>
</div>
</Show>
);
};

View file

@ -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 });
}