mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
frontend(platforms): add v5-style operator toolbar to UnifiedResourceTable platform sub-tabs
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:
parent
65b069dbba
commit
469691bc74
3 changed files with 204 additions and 17 deletions
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue