mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix(frontend): keep Proxmox workload controls in view
This commit is contained in:
@@ -2072,8 +2072,18 @@ default` instead of fusing provider and badge text such as
|
||||
across platform workload surfaces. A page with adjacent totals, such as
|
||||
Proxmox's Nodes header, must own one visibility signal and pass it to both
|
||||
the shared filter and table instead of creating independently persisted
|
||||
signals that only synchronize after a reload. Docker / Podman, Kubernetes, TrueNAS,
|
||||
VMware vSphere, and Standalone Machines must continue using their shared
|
||||
signals that only synchronize after a reload. A provider host or node table
|
||||
must render before the workload filter, and the workload filter must sit
|
||||
immediately before the `WorkloadsSurface` it controls. It must never precede
|
||||
a provider table whose rows it does not filter; Proxmox and VMware vSphere
|
||||
use the same host-or-node, workload-controls, workload-table reading order.
|
||||
Large provider inventories use the shared `createPlatformTablePreview` and
|
||||
`PlatformTablePreviewToggle` boundary to keep the controlled workload table
|
||||
in the initial reading flow: Proxmox shows eight node rows by default on
|
||||
larger layouts and four on phone-sized layouts, with an accessible,
|
||||
reversible show-all control in the Nodes header. Expansion is deliberate
|
||||
session state and must not persist a page-burying expanded default.
|
||||
Docker / Podman, Kubernetes, TrueNAS, VMware vSphere, and Standalone Machines must continue using their shared
|
||||
`PlatformTableToolbar` counters and table headers; none may add a parallel
|
||||
estate card grid or provider-only spotlight surface.
|
||||
|
||||
|
||||
@@ -408,7 +408,13 @@ and unified-resource projection, not backup or recovery evidence.
|
||||
`ProxmoxPageSurface` must flow the already-loaded workload inventory into the
|
||||
shared workload filter counts and derive cluster/standalone topology through
|
||||
`platformEstateOverviewModel.ts` for the existing Nodes header rather than
|
||||
creating a Proxmox-only metric or spotlight panel. Those counts must not imply
|
||||
creating a Proxmox-only metric or spotlight panel. The node inventory may use
|
||||
the shared bounded table-preview disclosure so workload controls remain in the
|
||||
initial reading flow, but it must retain canonical node ordering and reveal the
|
||||
same already-loaded collection rather than changing backup or recovery scope.
|
||||
The workload filter belongs immediately before the workload table it controls,
|
||||
after the node preview; it must not appear to filter the node table. Those
|
||||
counts and layout controls must not imply
|
||||
protection, verification, or restore readiness beyond the evidence held by the
|
||||
workflow-owned Storage, Backups, Ceph, and Mail surfaces.
|
||||
Proxmox backup inventory loading and load-failure chrome is likewise a
|
||||
|
||||
@@ -382,6 +382,13 @@ touch- and keyboard-operable inline detail disclosure.
|
||||
Future platform tables must keep that split: row data and platform semantics
|
||||
stay in the unified-resource consumer, and the repeated table shell stays in the
|
||||
shared frontend primitive.
|
||||
Large platform inventories may bound their initial row projection through the
|
||||
shared platform-table preview primitive so the next canonical surface remains
|
||||
in the initial reading flow. Proxmox node consumers retain the page model's
|
||||
canonical ordering, render eight rows on larger layouts and four rows on phone
|
||||
layouts by default, and expose all rows only through the reversible shared
|
||||
header disclosure. The preview must slice the already-sorted unified-resource
|
||||
collection; it must not fetch, re-rank, omit, or derive a second node truth.
|
||||
Alert decoration on those platform rows consumes the canonical active-alert
|
||||
read model and the detector-enabled accessor. External notification activation
|
||||
is not a resource-health field and must never suppress row alerts, change
|
||||
|
||||
@@ -1,46 +1,49 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "66c87fb6e8595af47c360573bb8efa51d46492d1",
|
||||
"verified_at": "2026-08-19T18:57:18Z",
|
||||
"base_sha": "e7de6602f7d43052950fb72d9591ecf154090f10",
|
||||
"verified_at": "2026-08-19T19:07:24Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/AI/Chat/index.tsx",
|
||||
"frontend-modern/src/components/GitHubStarBanner.tsx",
|
||||
"frontend-modern/src/components/shared/MobileNavBar.tsx",
|
||||
"frontend-modern/src/components/shared/FilterButtonGroup.tsx",
|
||||
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
|
||||
"frontend-modern/src/index.css"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/AI/Chat/index.tsx": "67d3dc20ea1100c3047f27052c35aaa4b1e15a8d2ebeda1022fecae9c772835d",
|
||||
"frontend-modern/src/components/GitHubStarBanner.tsx": "ea65775ad40dc08d54d6e08f6f3c736c545917c11e8e7fbddb71d856afc11d79",
|
||||
"frontend-modern/src/components/shared/MobileNavBar.tsx": "16a6917b7fdc25d77bd9cd6760f132f08a0f13f833ede6d278237e37378549e6",
|
||||
"frontend-modern/src/index.css": "1b195e474bdf5cfa33af971d1a01ed6b7e7e58eb998d823616a292c0ecc6fb1f"
|
||||
"frontend-modern/src/components/shared/FilterButtonGroup.tsx": "4f7c115ad13ad614f5ebb3f73d2c0f7561342e2a6b58b2bde36cf11d828d54ca",
|
||||
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "c946582423cf0f95e3e8f92d676c175a51c2f474be70ed383f882b7ef30e8973",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "01ea9d0ec46b0cb97870aa145b17fdd1fcc4fd8b3576ffdd21dfd2f4ad50e5c7",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "66fd5ef686cd052d37fa59ceacdae524a9274fd12e1f22f4c2be0e1ca80f5ce4",
|
||||
"frontend-modern/src/index.css": "d8fe7b2c49b50003ab493cd37e37bbdd5c637764c1efd23fa20b966550c7ba5f"
|
||||
},
|
||||
"routes": [
|
||||
"/",
|
||||
"/proxmox"
|
||||
"/proxmox/overview",
|
||||
"/vmware/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"authenticated mobile 375x812, bottom navigation mounted and measuring 45px, --pulse-mobile-nav-height published as 45px",
|
||||
"authenticated mobile with the Assistant overlay open, backdrop and panel both ending at the bar's top edge",
|
||||
"authenticated desktop 1280x800, bar display:none and the variable published as 0px",
|
||||
"re-verified after rebase onto 66c87fb6e, which reworked the FilterBar and Proxmox estate surfaces on the same route"
|
||||
"Proxmox desktop default bounded node preview with eight rows, visible workload controls, and the workload table in the initial viewport",
|
||||
"Proxmox desktop expanded 32-node estate and restored eight-row collapsed preview",
|
||||
"Proxmox phone default bounded node preview with four rows and the reveal control visible",
|
||||
"Proxmox phone expanded 32-node estate and restored four-row collapsed preview without horizontal overflow",
|
||||
"Proxmox inventory totals shown and hidden across filter counts, Nodes topology, and the node reveal label",
|
||||
"Proxmox and vSphere 12px labels with 11px counts sharing identical 16px line boxes and baselines"
|
||||
],
|
||||
"interactions": [
|
||||
"opened the Assistant overlay and measured backdrop bottom 767 against bar top 767, gap 0px, previously a 35px un-dimmed clickable band",
|
||||
"hit-tested 5px above the bar and found the Assistant panel covering it rather than page content",
|
||||
"probed .filter-bottom-nav-aware-panel computed bottom, resolving to 45px on mobile from the shared variable",
|
||||
"probed the star banner class combination at 800px and confirmed md:bottom-4 still wins at 16px",
|
||||
"dispatched a resize event and confirmed the published value republishes from 0px to 45px",
|
||||
"reloaded at 1280x800 and confirmed the bar is display:none with the variable published as 0px"
|
||||
"Expanded and collapsed Show all 32 nodes on desktop and phone",
|
||||
"Switched Proxmox workload status from All to Attention and observed the adjacent workload table change from 150 rendered rows to 21",
|
||||
"Hid and restored Inventory totals from the View menu and confirmed the node reveal label stopped exposing the total while hidden",
|
||||
"Compared the final shared inline count geometry on Proxmox and vSphere",
|
||||
"Cleared the temporary Proxmox status filter and left /proxmox/overview at 1440x900"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -546,7 +546,7 @@ describe('App architecture', () => {
|
||||
expect(appStylesSource).toContain('.pulse-shell--full-width');
|
||||
expect(appStylesSource).toContain('.pulse-wide-data-surface.space-y-3');
|
||||
expect(appStylesSource).toContain('.filter-bar > div > div:first-child button');
|
||||
expect(appStylesSource).toContain('.proxmox-nodes-card > :first-child');
|
||||
expect(appStylesSource).not.toContain('.proxmox-nodes-card > :first-child');
|
||||
expect(appStylesSource).toContain('.pulse-wide-data-surface .host-row');
|
||||
expect(appStylesSource).toContain('.pulse-footer > div');
|
||||
expect(appLayoutSource).toContain('tabs mb-2 hidden xl:flex');
|
||||
|
||||
@@ -80,7 +80,9 @@ describe('FilterButtonGroup', () => {
|
||||
|
||||
const button = screen.getByRole('button', { name: 'VMs, 1,234' });
|
||||
expect(button).toHaveTextContent('VMs1,234');
|
||||
expect(button.querySelector('[aria-hidden="true"]')).toHaveClass('tabular-nums');
|
||||
const count = button.querySelector('[aria-hidden="true"]');
|
||||
expect(count).toHaveClass('tabular-nums', 'leading-4');
|
||||
expect(count?.parentElement).toHaveClass('items-baseline');
|
||||
});
|
||||
|
||||
it('blocks disabled option changes in the runtime owner', () => {
|
||||
|
||||
@@ -73,31 +73,31 @@ export function FilterButtonGroup<T extends string | number>(props: FilterButton
|
||||
>
|
||||
{option.leading}
|
||||
{Icon && <Icon class={iconClass()} />}
|
||||
<Show
|
||||
when={option.visualLabel}
|
||||
fallback={
|
||||
filterButtonGroup.variant() === 'prominent' ? (
|
||||
<span>{option.label}</span>
|
||||
) : (
|
||||
<>
|
||||
<span class="hidden sm:inline">{option.label}</span>
|
||||
<span class="sm:hidden">{getFilterButtonGroupCompactLabel(option)}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
{renderedLabel()}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={option.count !== undefined}>
|
||||
<span
|
||||
class="inline-flex items-center self-center text-[11px] font-semibold leading-none tabular-nums text-base-content/70"
|
||||
aria-hidden="true"
|
||||
<span class="inline-flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
<Show
|
||||
when={option.visualLabel}
|
||||
fallback={
|
||||
filterButtonGroup.variant() === 'prominent' ? (
|
||||
<span>{option.label}</span>
|
||||
) : (
|
||||
<>
|
||||
<span class="hidden sm:inline">{option.label}</span>
|
||||
<span class="sm:hidden">{getFilterButtonGroupCompactLabel(option)}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{option.count!.toLocaleString()}
|
||||
</span>
|
||||
</Show>
|
||||
<span>{renderedLabel()}</span>
|
||||
</Show>
|
||||
<Show when={option.count !== undefined}>
|
||||
<span
|
||||
class="inline-block text-[11px] font-semibold leading-4 tabular-nums text-base-content/70"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{option.count!.toLocaleString()}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -500,6 +500,8 @@ describe('shared primitive guardrails', () => {
|
||||
expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupCompactLabel');
|
||||
expect(filterButtonGroupModelSource).toContain("option.label.startsWith('All ')");
|
||||
expect(filterButtonGroupModelSource).toContain('count?: number');
|
||||
expect(filterButtonGroupSource).toContain('items-baseline');
|
||||
expect(filterButtonGroupSource).toContain('text-[11px] font-semibold leading-4 tabular-nums');
|
||||
expect(workloadsFilterSource).toContain('inventoryStats');
|
||||
expect(workloadsFilterSource).toContain('PLATFORM_ESTATE_COUNTS_STORAGE_KEY');
|
||||
expect(generalSettingsPanelSource).toContain('FilterButtonGroup');
|
||||
@@ -5264,6 +5266,9 @@ describe('shared primitive guardrails', () => {
|
||||
expect(sharedPlatformPageSource).toContain('export function getPlatformTableClass');
|
||||
expect(sharedPlatformPageSource).toContain("className !== 'min-w-full'");
|
||||
expect(sharedPlatformPageSource).toContain('class={getPlatformTableClass(props.tableClass)}');
|
||||
expect(sharedPlatformPageSource).toContain('export function createPlatformTablePreview');
|
||||
expect(sharedPlatformPageSource).toContain('export function PlatformTablePreviewToggle');
|
||||
expect(sharedPlatformPageSource).toContain('aria-expanded={props.expanded}');
|
||||
|
||||
for (const [path, source] of [
|
||||
['src/features/docker/DockerHostsTable.tsx', dockerHostsTableSource],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { A } from '@solidjs/router';
|
||||
import ChevronDownIcon from 'lucide-solid/icons/chevron-down';
|
||||
import ChevronLeftIcon from 'lucide-solid/icons/chevron-left';
|
||||
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
|
||||
import ChevronUpIcon from 'lucide-solid/icons/chevron-up';
|
||||
import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw';
|
||||
import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert';
|
||||
import {
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Accessor,
|
||||
type Component,
|
||||
type JSX,
|
||||
} from 'solid-js';
|
||||
@@ -306,6 +309,66 @@ export function PlatformTableShell(props: PlatformTableShellProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function createPlatformTablePreview<Row>(options: {
|
||||
rows: Accessor<readonly Row[]>;
|
||||
limit: Accessor<number>;
|
||||
}) {
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
const limit = createMemo(() => Math.max(1, Math.trunc(options.limit())));
|
||||
const canExpand = createMemo(() => options.rows().length > limit());
|
||||
const visibleRows = createMemo<readonly Row[]>(() =>
|
||||
expanded() && canExpand() ? options.rows() : options.rows().slice(0, limit()),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
if (!canExpand()) setExpanded(false);
|
||||
});
|
||||
|
||||
return {
|
||||
expanded,
|
||||
canExpand,
|
||||
limit,
|
||||
visibleRows,
|
||||
setExpanded,
|
||||
toggle: () => {
|
||||
if (canExpand()) setExpanded((current) => !current);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function PlatformTablePreviewToggle(props: {
|
||||
expanded: boolean;
|
||||
canExpand: boolean;
|
||||
total: number;
|
||||
noun: string;
|
||||
showCount?: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const collapsedLabel = () =>
|
||||
props.showCount === false ? `Show all ${props.noun}` : `Show all ${props.total} ${props.noun}`;
|
||||
const label = () => (props.expanded ? `Show fewer ${props.noun}` : collapsedLabel());
|
||||
|
||||
return (
|
||||
<Show when={props.canExpand}>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 min-w-11 items-center justify-center gap-1 rounded px-1.5 text-[11px] font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 sm:min-h-8"
|
||||
aria-expanded={props.expanded}
|
||||
aria-label={label()}
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
<span>{label()}</span>
|
||||
<Show
|
||||
when={props.expanded}
|
||||
fallback={<ChevronDownIcon class="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
>
|
||||
<ChevronUpIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
const getPlatformTableAlignClass = (align: PlatformTableCellAlign = 'left'): string => {
|
||||
if (align === 'right') return 'text-right';
|
||||
if (align === 'center') return 'text-center';
|
||||
|
||||
@@ -34,7 +34,9 @@ import {
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableMetricFallback,
|
||||
PlatformTablePreviewToggle,
|
||||
PlatformTableShell,
|
||||
createPlatformTablePreview,
|
||||
createPlatformTableSortState,
|
||||
formatPlatformTableIntegerValue,
|
||||
formatPlatformTablePercentValue,
|
||||
@@ -223,6 +225,14 @@ export const ProxmoxNodesTable: Component<{
|
||||
const sortedNodes = createMemo(() =>
|
||||
sort.sortRows(props.nodes, (node, key) => getHostSortValue(node, props.guests, key)),
|
||||
);
|
||||
const previewLimit = createMemo(() =>
|
||||
layoutMode() === 'narrow' || layoutMode() === 'phone' || layoutMode() === 'mobile' ? 4 : 8,
|
||||
);
|
||||
const nodePreview = createPlatformTablePreview({ rows: sortedNodes, limit: previewLimit });
|
||||
const toggleNodePreview = () => {
|
||||
if (nodePreview.expanded()) setSelectedNodeId(null);
|
||||
nodePreview.toggle();
|
||||
};
|
||||
|
||||
// Use the same canonical history reader the workloads table uses; cache
|
||||
// keys collide so the two readers dedupe their fetches.
|
||||
@@ -255,26 +265,36 @@ export const ProxmoxNodesTable: Component<{
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<Show
|
||||
when={
|
||||
inventoryCountsVisible() &&
|
||||
props.topology &&
|
||||
(props.topology.clusters > 0 || props.topology.standalone > 0)
|
||||
}
|
||||
>
|
||||
<span class="text-[10px] font-medium text-muted">
|
||||
<Show when={props.topology!.clusters > 0}>
|
||||
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
|
||||
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
|
||||
</Show>
|
||||
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
|
||||
{' / '}
|
||||
</Show>
|
||||
<Show when={props.topology!.standalone > 0}>
|
||||
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Show
|
||||
when={
|
||||
inventoryCountsVisible() &&
|
||||
props.topology &&
|
||||
(props.topology.clusters > 0 || props.topology.standalone > 0)
|
||||
}
|
||||
>
|
||||
<span class="text-[10px] font-medium text-muted">
|
||||
<Show when={props.topology!.clusters > 0}>
|
||||
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
|
||||
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
|
||||
</Show>
|
||||
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
|
||||
{' / '}
|
||||
</Show>
|
||||
<Show when={props.topology!.standalone > 0}>
|
||||
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<PlatformTablePreviewToggle
|
||||
expanded={nodePreview.expanded()}
|
||||
canExpand={nodePreview.canExpand()}
|
||||
total={props.nodes.length}
|
||||
noun="nodes"
|
||||
showCount={inventoryCountsVisible()}
|
||||
onToggle={toggleNodePreview}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
cardClass="proxmox-nodes-card"
|
||||
tableClass={`${getProxmoxHostTableMinWidthClass(layoutMode())} table-fixed text-xs`}
|
||||
@@ -313,7 +333,7 @@ export const ProxmoxNodesTable: Component<{
|
||||
</For>
|
||||
}
|
||||
body={
|
||||
<For each={sortedNodes()}>
|
||||
<For each={nodePreview.visibleRows()}>
|
||||
{(node) => {
|
||||
const name = () => asTrimmedString(node.name) || node.id;
|
||||
const nativeNodeName = () => asTrimmedString(node.proxmox?.nodeName) ?? '';
|
||||
|
||||
@@ -328,6 +328,18 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
||||
|
||||
return (
|
||||
<div ref={overviewWidth.setElement} class="pulse-wide-data-surface space-y-4">
|
||||
<ProxmoxNodesTable
|
||||
nodes={filteredNodes()}
|
||||
guests={currentModel().guests}
|
||||
metricDisplayMode={props.metricDisplayMode}
|
||||
metricHistoryRange={props.metricHistoryRange}
|
||||
layoutWidth={overviewWidth.width}
|
||||
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
|
||||
emptyTitle="No Proxmox VE nodes"
|
||||
emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory."
|
||||
topology={estateTopology()}
|
||||
inventoryCountsVisible={props.inventoryCountsVisible}
|
||||
/>
|
||||
<Show when={showSharedFilterToolbar()}>
|
||||
<div data-summary-clear-ignore>
|
||||
<WorkloadsFilter
|
||||
@@ -371,18 +383,6 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<ProxmoxNodesTable
|
||||
nodes={filteredNodes()}
|
||||
guests={currentModel().guests}
|
||||
metricDisplayMode={props.metricDisplayMode}
|
||||
metricHistoryRange={props.metricHistoryRange}
|
||||
layoutWidth={overviewWidth.width}
|
||||
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
|
||||
emptyTitle="No Proxmox VE nodes"
|
||||
emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory."
|
||||
topology={estateTopology()}
|
||||
inventoryCountsVisible={props.inventoryCountsVisible}
|
||||
/>
|
||||
<WorkloadsSurface
|
||||
state={workloadsState}
|
||||
vms={[]}
|
||||
|
||||
@@ -86,6 +86,19 @@ const makeNodeResource = (overrides: Partial<Resource> = {}): Resource => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeNodeResources = (count: number): Resource[] =>
|
||||
Array.from({ length: count }, (_, index) =>
|
||||
makeNodeResource({
|
||||
id: `agent:pve-node-${index + 1}`,
|
||||
name: `pve-node-${index + 1}`,
|
||||
displayName: `pve-node-${index + 1}`,
|
||||
proxmox: {
|
||||
clusterName: 'homelab',
|
||||
nodeName: `pve-node-${index + 1}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getMetricThresholdsMock.mockReturnValue({ warning: 80, critical: 85 });
|
||||
@@ -100,12 +113,54 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('ProxmoxNodesTable', () => {
|
||||
it('keeps large estates bounded until the operator expands the node preview', () => {
|
||||
render(() => (
|
||||
<ProxmoxNodesTable
|
||||
nodes={makeNodeResources(10)}
|
||||
guests={[]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No Proxmox VE nodes"
|
||||
emptyDescription="No nodes"
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getAllByRole('row')).toHaveLength(9);
|
||||
const showAll = screen.getByRole('button', { name: 'Show all 10 nodes' });
|
||||
expect(showAll).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(showAll);
|
||||
expect(screen.getAllByRole('row')).toHaveLength(11);
|
||||
expect(screen.getByRole('button', { name: 'Show fewer nodes' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show fewer nodes' }));
|
||||
expect(screen.getAllByRole('row')).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('keeps the phone preview to four rows with its reveal control available', () => {
|
||||
render(() => (
|
||||
<ProxmoxNodesTable
|
||||
nodes={makeNodeResources(6)}
|
||||
guests={[]}
|
||||
layoutWidth={() => 390}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No Proxmox VE nodes"
|
||||
emptyDescription="No nodes"
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getAllByRole('row')).toHaveLength(5);
|
||||
expect(screen.getByRole('button', { name: 'Show all 6 nodes' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides node and topology totals through the page-owned inventory preference', () => {
|
||||
render(() => (
|
||||
<ProxmoxNodesTable
|
||||
nodes={[makeNodeResource()]}
|
||||
nodes={makeNodeResources(10)}
|
||||
guests={[]}
|
||||
topology={{ nodes: 1, clusters: 1, standalone: 0 }}
|
||||
topology={{ nodes: 10, clusters: 1, standalone: 0 }}
|
||||
inventoryCountsVisible={() => false}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No Proxmox VE nodes"
|
||||
@@ -115,6 +170,8 @@ describe('ProxmoxNodesTable', () => {
|
||||
|
||||
expect(screen.getByText('Nodes').parentElement).toHaveTextContent(/^Nodes$/);
|
||||
expect(screen.queryByText('1 cluster')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show all nodes' })).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: 'Show all 10 nodes' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links each node to its PVE web interface without hijacking the row click', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Route, Router } from '@solidjs/router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { ProxmoxPageSurface } from '../ProxmoxPageSurface';
|
||||
import proxmoxPageSurfaceSource from '../ProxmoxPageSurface.tsx?raw';
|
||||
|
||||
const mockUseUnifiedResources = vi.fn();
|
||||
const mockPathname = vi.hoisted(() => vi.fn(() => '/proxmox/overview'));
|
||||
@@ -271,6 +272,16 @@ describe('ProxmoxPageSurface contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('places workload controls beside the workload table they affect', () => {
|
||||
const nodesTableIndex = proxmoxPageSurfaceSource.indexOf('<ProxmoxNodesTable');
|
||||
const workloadFilterIndex = proxmoxPageSurfaceSource.indexOf('<WorkloadsFilter');
|
||||
const workloadsSurfaceIndex = proxmoxPageSurfaceSource.indexOf('<WorkloadsSurface');
|
||||
|
||||
expect(nodesTableIndex).toBeGreaterThan(-1);
|
||||
expect(workloadFilterIndex).toBeGreaterThan(nodesTableIndex);
|
||||
expect(workloadsSurfaceIndex).toBeGreaterThan(workloadFilterIndex);
|
||||
});
|
||||
|
||||
it('keeps Patrol coverage off the Proxmox overview', () => {
|
||||
setResources([
|
||||
makeResource({
|
||||
|
||||
@@ -514,10 +514,6 @@
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.proxmox-nodes-card > :first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pulse-wide-data-surface .table-scroll-shell > .table-fixed tbody td,
|
||||
.pulse-wide-data-surface .table-scroll-shell > .table-fixed tbody .workload-row > td {
|
||||
padding-top: 0;
|
||||
|
||||
Reference in New Issue
Block a user