proxmox(overview): share the bars/sparklines toggle between the hosts table and the workloads
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run

Before this commit the bars / sparklines segmented control inside the
Workloads filter only drove the guest table below. The new hosts table
on top still showed metric bars regardless of the toggle, which read as
half-wired UX once the user noticed.

Lift ownership of the metric display mode and the sparkline range to
ProxmoxPageSurface:

- useWorkloadsControlsState gains optional `metricDisplayMode`,
  `onMetricDisplayModeChange`, `metricHistoryRange`, and
  `onMetricHistoryRangeChange` options. When supplied, the controls
  layer short-circuits to the page-provided accessor + change handler;
  otherwise it falls back to the existing persistent signals, so
  standalone Workloads usage is unaffected.
- WorkloadsSurface forwards those four overrides through
  WorkloadsSurfaceProps so platform pages can opt in.
- ProxmoxPageSurface creates the two persistent signals at the page
  level (same `STORAGE_KEYS.WORKLOADS_METRIC_DISPLAY_MODE` /
  `STORAGE_KEYS.WORKLOADS_METRIC_HISTORY_RANGE` keys, so existing user
  preference carries over) and passes the accessor + setter down to
  both the embedded WorkloadsSurface and ProxmoxNodesTable.
- ProxmoxNodesTable accepts `metricDisplayMode` + `metricHistoryRange`
  accessors. When the mode is `sparklines` it renders
  MetricMiniSparkline for CPU / Memory / Disk using
  `useWorkloadTableMetricHistory`; the cache key matches the
  workloads-table reader so the two instances dedupe their fetches.
  When the mode is `bars` it keeps the canonical
  ResponsiveMetricCell / StackedMemoryBar / StackedDiskBar rendering
  from the previous commit. The legacy Node shape that
  `getNodeMetricSeries` keys on is projected from the canonical
  Resource (id / name / instance / linkedAgentId).

A new contract test pins the override threading + the page-level wiring
so a future refactor can't silently fork the toggle state again.
performance-and-scalability contract Current-State documents the
shared-toggle model.
This commit is contained in:
rcourtman 2026-05-16 22:22:29 +01:00
parent dc7c83a388
commit dae4fb3bb4
6 changed files with 368 additions and 120 deletions

View file

@ -580,6 +580,26 @@ shell clickable behind another overlay.
## Current State
The bars / sparklines toggle and its sparkline-range picker on the
WorkloadsSurface support page-level ownership through four optional
overrides exposed by
`frontend-modern/src/components/Workloads/useWorkloadsState.ts`:
`metricDisplayMode`, `onMetricDisplayModeChange`, `metricHistoryRange`,
and `onMetricHistoryRangeChange`. When a platform page renders a
sibling table that also reacts to bars/sparklines (Proxmox overview's
top hosts table today), the page owns the persistent signals against
`STORAGE_KEYS.WORKLOADS_METRIC_DISPLAY_MODE` /
`STORAGE_KEYS.WORKLOADS_METRIC_HISTORY_RANGE` and passes the accessors
+ change handlers into both surfaces. `useWorkloadsControlsState` then
short-circuits to the supplied accessor / handler instead of its
internal persistent signal so the toggle and the hosts-table render
stay in lockstep. Embedded sibling tables that opt into sparklines
re-instantiate `useWorkloadTableMetricHistory`; the cache key matches
the workloads-table reader so both readers dedupe their fetches and
the canonical Workloads hot-path budget is preserved. Standalone
WorkloadsSurface callers (no override props) keep the original
persistent-signal-backed behavior.
The embedded WorkloadsSurface exposes a `compactGroupHeaders` prop on
`frontend-modern/src/components/Workloads/useWorkloadsState.ts` that
platform pages owning their own hosts table (Proxmox overview today) set

View file

@ -364,6 +364,49 @@ describe('Workloads platform-page embed contract', () => {
expect(surfaceSource).toContain('props.showFilterToolbar || !props.tableOnly');
});
it('exposes metric-display-mode + history-range override hooks so platform pages can share the toggle across multiple tables', async () => {
const stateSource = (await import('../useWorkloadsState.ts?raw')).default;
expect(stateSource).toContain('metricDisplayMode?: Accessor<WorkloadsMetricDisplayMode>;');
expect(stateSource).toContain(
'onMetricDisplayModeChange?: (value: WorkloadsMetricDisplayMode) => void;',
);
expect(stateSource).toContain('metricHistoryRange?: Accessor<WorkloadTableMetricHistoryRange>;');
expect(stateSource).toContain(
'onMetricHistoryRangeChange?: (value: WorkloadTableMetricHistoryRange) => void;',
);
expect(stateSource).toContain('metricDisplayMode: props.metricDisplayMode,');
expect(stateSource).toContain('onMetricDisplayModeChange: props.onMetricDisplayModeChange,');
const controlsSource = (await import('../useWorkloadsControlsState.ts?raw')).default;
// The controls layer must short-circuit to the page-provided accessor +
// change handler when supplied; the internal persistent signal stays as
// the fallback so standalone usage keeps working.
expect(controlsSource).toContain(
'options.metricDisplayMode ?? internalMetricDisplayMode',
);
expect(controlsSource).toContain('options.onMetricDisplayModeChange');
expect(controlsSource).toContain(
'options.metricHistoryRange ?? internalMetricHistoryRange',
);
const proxmoxSource = (
await import('../../../features/proxmox/ProxmoxPageSurface.tsx?raw')
).default;
expect(proxmoxSource).toContain('STORAGE_KEYS.WORKLOADS_METRIC_DISPLAY_MODE');
expect(proxmoxSource).toContain('STORAGE_KEYS.WORKLOADS_METRIC_HISTORY_RANGE');
expect(proxmoxSource).toContain('metricDisplayMode={metricDisplayMode}');
expect(proxmoxSource).toContain('onMetricDisplayModeChange={setMetricDisplayMode}');
expect(proxmoxSource).toContain('metricHistoryRange={metricHistoryRange}');
expect(proxmoxSource).toContain('onMetricHistoryRangeChange={setMetricHistoryRange}');
const nodesTableSource = (
await import('../../../features/proxmox/ProxmoxNodesTable.tsx?raw')
).default;
expect(nodesTableSource).toContain('useWorkloadTableMetricHistory');
expect(nodesTableSource).toContain('MetricMiniSparkline');
expect(nodesTableSource).toContain('isSparklineMode');
});
it('exposes compactGroupHeaders so platform pages can strip duplicate host stats from group rows', async () => {
const stateSource = (await import('../useWorkloadsState.ts?raw')).default;
expect(stateSource).toContain('compactGroupHeaders?: boolean;');

View file

@ -33,6 +33,14 @@ import {
interface WorkloadsControlsStateOptions {
forcedGroupingMode?: WorkloadsGroupingMode;
// When a platform page owns the metric display mode (e.g. Proxmox
// overview shares it across a top hosts table and the embedded workloads
// surface), pass the accessor + change handler so the controls track the
// page-level state instead of forking a local persistent signal.
metricDisplayMode?: Accessor<WorkloadsMetricDisplayMode>;
onMetricDisplayModeChange?: (value: WorkloadsMetricDisplayMode) => void;
metricHistoryRange?: Accessor<WorkloadTableMetricHistoryRange>;
onMetricHistoryRangeChange?: (value: WorkloadTableMetricHistoryRange) => void;
setShowFilters: (value: boolean | ((current: boolean) => boolean)) => void;
showFilters: Accessor<boolean>;
viewMode: Accessor<ViewMode>;
@ -80,7 +88,7 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
false,
{ deserialize: (raw) => raw === 'true' },
);
const [workloadMetricDisplayMode, setWorkloadMetricDisplayMode] =
const [internalMetricDisplayMode, setInternalMetricDisplayMode] =
usePersistentSignal<WorkloadsMetricDisplayMode>(
STORAGE_KEYS.WORKLOADS_METRIC_DISPLAY_MODE,
DEFAULT_WORKLOADS_METRIC_DISPLAY_MODE,
@ -89,7 +97,17 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
raw === 'bars' || raw === 'sparklines' ? raw : DEFAULT_WORKLOADS_METRIC_DISPLAY_MODE,
},
);
const [workloadMetricHistoryRange, setWorkloadMetricHistoryRange] =
const workloadMetricDisplayMode: Accessor<WorkloadsMetricDisplayMode> =
options.metricDisplayMode ?? internalMetricDisplayMode;
const setWorkloadMetricDisplayMode = (value: WorkloadsMetricDisplayMode): void => {
if (options.onMetricDisplayModeChange) {
options.onMetricDisplayModeChange(value);
return;
}
setInternalMetricDisplayMode(value);
};
const [internalMetricHistoryRange, setInternalMetricHistoryRange] =
usePersistentSignal<WorkloadTableMetricHistoryRange>(
STORAGE_KEYS.WORKLOADS_METRIC_HISTORY_RANGE,
WORKLOAD_TABLE_HISTORY_DEFAULT_RANGE,
@ -98,6 +116,15 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
isWorkloadTableMetricHistoryRange(raw) ? raw : WORKLOAD_TABLE_HISTORY_DEFAULT_RANGE,
},
);
const workloadMetricHistoryRange: Accessor<WorkloadTableMetricHistoryRange> =
options.metricHistoryRange ?? internalMetricHistoryRange;
const setWorkloadMetricHistoryRange = (value: WorkloadTableMetricHistoryRange): void => {
if (options.onMetricHistoryRangeChange) {
options.onMetricHistoryRangeChange(value);
return;
}
setInternalMetricHistoryRange(value);
};
const [sortKey, setSortKey] = createSignal<WorkloadsSortKey | null>(DEFAULT_WORKLOADS_SORT_KEY);
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>(

View file

@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from 'solid-js';
import { createEffect, createMemo, onCleanup, type Accessor } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { ConnectionsAPI, type ConnectionsListResponse } from '@/api/connections';
import type { VM, Container, Node } from '@/types/api';
@ -26,7 +26,12 @@ import {
filterWorkloads,
type FilterWorkloadsParams,
} from './workloadSelectors';
import { type WorkloadsGroupingMode, type WorkloadsSortKey } from './workloadsFilterModel';
import {
type WorkloadsGroupingMode,
type WorkloadsMetricDisplayMode,
type WorkloadsSortKey,
} from './workloadsFilterModel';
import { type WorkloadTableMetricHistoryRange } from './workloadMetricHistoryModel';
import { useWorkloadsControlsState } from './useWorkloadsControlsState';
import { useWorkloadGuestMetadataState } from './useWorkloadGuestMetadataState';
import { useWorkloadSelectionState } from './useWorkloadSelectionState';
@ -72,6 +77,15 @@ export interface WorkloadsSurfaceProps {
// `compactGroupHeaders` strips those stats from the NodeGroupHeader rows
// in grouped mode so the section dividers don't duplicate the info.
compactGroupHeaders?: boolean;
// When a platform page owns the metric display mode + sparkline range
// (so the same toggle drives both the page's hosts table and this
// embedded workloads surface), pass the accessors + change handlers.
// The page is responsible for persisting the values; when omitted, the
// surface falls back to its own persistent signals.
metricDisplayMode?: Accessor<WorkloadsMetricDisplayMode>;
onMetricDisplayModeChange?: (value: WorkloadsMetricDisplayMode) => void;
metricHistoryRange?: Accessor<WorkloadTableMetricHistoryRange>;
onMetricHistoryRangeChange?: (value: WorkloadTableMetricHistoryRange) => void;
}
export type WorkloadSortKey = WorkloadsSortKey;
@ -192,6 +206,10 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
setWorkloadsSummaryRange,
} = useWorkloadsControlsState({
forcedGroupingMode: props.forcedGroupingMode,
metricDisplayMode: props.metricDisplayMode,
onMetricDisplayModeChange: props.onMetricDisplayModeChange,
metricHistoryRange: props.metricHistoryRange,
onMetricHistoryRangeChange: props.onMetricHistoryRangeChange,
setShowFilters,
showFilters,
viewMode,

View file

@ -1,10 +1,11 @@
import { For, Show, type Component, type JSX } from 'solid-js';
import { For, Show, type Accessor, type Component, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline';
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import {
Table,
@ -18,7 +19,10 @@ import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import { normalizeDiskArray } from '@/utils/format';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import type { Disk } from '@/types/api';
import { useWorkloadTableMetricHistory } from '@/components/Workloads/useWorkloadTableMetricHistory';
import { type WorkloadsMetricDisplayMode } from '@/components/Workloads/workloadsFilterModel';
import { type WorkloadTableMetricHistoryRange } from '@/components/Workloads/workloadMetricHistoryModel';
import type { Disk, Node as LegacyNode } from '@/types/api';
import type { Resource } from '@/types/resource';
import {
getResourceClusterLabel,
@ -33,7 +37,10 @@ import {
// sparkline overlays match the rest of the platform-first surfaces. v5's
// NodeSummaryTable didn't have a search box or status chip strip — node lists
// are short and the Workloads filter below covers the place where filtering
// actually matters, so this table renders the rows directly.
// actually matters, so this table renders the rows directly. The bars /
// sparklines toggle in the workloads filter is shared by the page-level
// metricDisplayMode signal so the hosts table swaps to MetricMiniSparkline
// whenever the user flips the toggle.
const formatUptime = (seconds: number | undefined): { label: string; warn: boolean } => {
if (!seconds || seconds <= 0) return { label: '—', warn: false };
@ -67,13 +74,55 @@ const CTS_BADGE =
const ZERO_BADGE =
'inline-flex min-w-[2rem] justify-center items-center rounded-md bg-surface-alt px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-muted';
// Shim a canonical Resource into the legacy Node shape that
// `useWorkloadTableMetricHistory.getNodeMetricSeries` uses for its chart-key
// candidate lookups. The lookup only reads `id`, `linkedAgentId`, `name`, and
// `instance`, so a minimal projection is enough; everything else is left at
// its harmless default. Field-cast through a Partial → unknown → LegacyNode
// chain because the legacy Node type carries dozens of optional shape fields
// the table doesn't need to satisfy here.
const projectResourceToLegacyNode = (resource: Resource): LegacyNode => {
const proxmoxMeta = resource.proxmox ?? {};
const projected: Partial<LegacyNode> & {
id: string;
name: string;
instance: string;
linkedAgentId?: string;
} = {
id: resource.id,
name: resource.name,
instance: proxmoxMeta.instance ?? '',
linkedAgentId: resource.agent?.agentId ?? undefined,
};
return projected as unknown as LegacyNode;
};
const formatPercentLabel = (value: number | null | undefined): string => {
if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
const normalized = value <= 1 ? value * 100 : value;
return `${Math.round(Math.max(0, normalized))}%`;
};
export const ProxmoxNodesTable: Component<{
nodes: Resource[];
guests: Resource[];
metricDisplayMode?: Accessor<WorkloadsMetricDisplayMode>;
metricHistoryRange?: Accessor<WorkloadTableMetricHistoryRange>;
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
}> = (props) => {
const displayMode = () => props.metricDisplayMode?.() ?? 'bars';
const isSparklineMode = () => displayMode() === 'sparklines';
// Use the same canonical history reader the workloads table uses; cache
// keys collide so the two readers dedupe their fetches.
const metricHistory = useWorkloadTableMetricHistory({
enabled: isSparklineMode,
range: () => props.metricHistoryRange?.() ?? '1h',
selectedNode: () => '',
});
return (
<Show
when={props.nodes.length > 0}
@ -88,85 +137,104 @@ export const ProxmoxNodesTable: Component<{
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="w-full min-w-[1080px] border-collapse text-xs">
<TableHeader class="bg-surface-alt text-muted border-b border-border">
<TableRow class="text-left text-[10px] uppercase tracking-wide">
<TableHead class="px-3 py-2 font-medium">Node</TableHead>
<TableHead class="px-3 py-2 font-medium">Version</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Uptime</TableHead>
<TableHead class="px-3 py-2 font-medium" style={{ width: '180px' }}>CPU</TableHead>
<TableHead class="px-3 py-2 font-medium" style={{ width: '180px' }}>Memory</TableHead>
<TableHead class="px-3 py-2 font-medium" style={{ width: '180px' }}>Disk</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Temp</TableHead>
<TableHead class="px-3 py-2 font-medium text-center">VMs</TableHead>
<TableHead class="px-3 py-2 font-medium text-center">CTs</TableHead>
<TableHead class="px-3 py-2 font-medium">Cluster</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={props.nodes}>
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
const version = () => asTrimmedString(getResourceVersion(node));
const cluster = () => getResourceClusterLabel(node);
const counts = () =>
countGuestsForNode(props.guests, getResourceNodeName(node));
const indicator = () => getSimpleStatusIndicator(node.status);
const isOnline = () => indicator().variant === 'success';
const uptime = () => formatUptime(node.uptime);
const metricsKey = () => buildMetricKeyForUnifiedResource(node);
const temperature = () => node.temperature;
const cpuPercent = () => node.cpu?.current ?? 0;
const memoryUsed = () => node.memory?.used ?? 0;
const memoryTotal = () => node.memory?.total ?? 0;
const memoryPercentOnly = () =>
!memoryTotal() && typeof node.memory?.current === 'number'
? node.memory.current
: undefined;
const aggregateDisk = (): Disk | undefined =>
node.disk
? ({
total: node.disk.total ?? 0,
used: node.disk.used ?? 0,
free: node.disk.free ?? 0,
usage: node.disk.current ?? 0,
} as Disk)
: undefined;
return (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
size="sm"
variant={indicator().variant}
title={node.status || 'unknown'}
ariaHidden
/>
<span class="font-semibold text-base-content truncate" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell class="px-3 py-2">
<Show
when={version()}
fallback={<span class="text-muted"></span>}
>
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 font-mono text-[10px] text-base-content">
{version()}
</span>
</Show>
</TableCell>
<TableCell
class={`px-3 py-2 text-right tabular-nums ${
uptime().warn
? 'text-orange-600 dark:text-orange-400'
: 'text-base-content'
}`}
>
{uptime().label}
</TableCell>
<TableCell class="px-3 py-2">
<Table class="w-full min-w-[1080px] border-collapse text-xs">
<TableHeader class="bg-surface-alt text-muted border-b border-border">
<TableRow class="text-left text-[10px] uppercase tracking-wide">
<TableHead class="px-3 py-2 font-medium">Node</TableHead>
<TableHead class="px-3 py-2 font-medium">Version</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Uptime</TableHead>
<TableHead class="px-3 py-2 font-medium" style={{ width: '180px' }}>
CPU
</TableHead>
<TableHead class="px-3 py-2 font-medium" style={{ width: '180px' }}>
Memory
</TableHead>
<TableHead class="px-3 py-2 font-medium" style={{ width: '180px' }}>
Disk
</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Temp</TableHead>
<TableHead class="px-3 py-2 font-medium text-center">VMs</TableHead>
<TableHead class="px-3 py-2 font-medium text-center">CTs</TableHead>
<TableHead class="px-3 py-2 font-medium">Cluster</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={props.nodes}>
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
const version = () => asTrimmedString(getResourceVersion(node));
const cluster = () => getResourceClusterLabel(node);
const counts = () => countGuestsForNode(props.guests, getResourceNodeName(node));
const indicator = () => getSimpleStatusIndicator(node.status);
const isOnline = () => indicator().variant === 'success';
const uptime = () => formatUptime(node.uptime);
const metricsKey = () => buildMetricKeyForUnifiedResource(node);
const temperature = () => node.temperature;
const cpuPercent = () => node.cpu?.current ?? 0;
const memoryUsed = () => node.memory?.used ?? 0;
const memoryTotal = () => node.memory?.total ?? 0;
const memoryPercent = () =>
memoryTotal() > 0
? (memoryUsed() / memoryTotal()) * 100
: typeof node.memory?.current === 'number'
? node.memory.current
: 0;
const memoryPercentOnly = () =>
!memoryTotal() && typeof node.memory?.current === 'number'
? node.memory.current
: undefined;
const diskPercent = () => node.disk?.current ?? 0;
const aggregateDisk = (): Disk | undefined =>
node.disk
? ({
total: node.disk.total ?? 0,
used: node.disk.used ?? 0,
free: node.disk.free ?? 0,
usage: node.disk.current ?? 0,
} as Disk)
: undefined;
const legacyNode = () => projectResourceToLegacyNode(node);
const cpuSeries = () =>
metricHistory.getNodeMetricSeries(legacyNode(), 'cpu');
const memorySeries = () =>
metricHistory.getNodeMetricSeries(legacyNode(), 'memory');
const diskSeries = () =>
metricHistory.getNodeMetricSeries(legacyNode(), 'disk');
return (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
size="sm"
variant={indicator().variant}
title={node.status || 'unknown'}
ariaHidden
/>
<span class="font-semibold text-base-content truncate" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell class="px-3 py-2">
<Show when={version()} fallback={<span class="text-muted"></span>}>
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 font-mono text-[10px] text-base-content">
{version()}
</span>
</Show>
</TableCell>
<TableCell
class={`px-3 py-2 text-right tabular-nums ${
uptime().warn
? 'text-orange-600 dark:text-orange-400'
: 'text-base-content'
}`}
>
{uptime().label}
</TableCell>
<TableCell class="px-3 py-2">
<Show
when={isSparklineMode()}
fallback={
<ResponsiveMetricCell
class="w-full"
value={cpuPercent()}
@ -175,8 +243,19 @@ export const ProxmoxNodesTable: Component<{
isRunning={isOnline()}
showMobile={false}
/>
</TableCell>
<TableCell class="px-3 py-2">
}
>
<MetricMiniSparkline
series={cpuSeries()}
valueLabel={formatPercentLabel(cpuPercent())}
title={`${name()} CPU history`}
/>
</Show>
</TableCell>
<TableCell class="px-3 py-2">
<Show
when={isSparklineMode()}
fallback={
<Show
when={isOnline() && (memoryTotal() > 0 || memoryPercentOnly() != null)}
fallback={
@ -193,8 +272,19 @@ export const ProxmoxNodesTable: Component<{
percentOnly={memoryPercentOnly()}
/>
</Show>
</TableCell>
<TableCell class="px-3 py-2">
}
>
<MetricMiniSparkline
series={memorySeries()}
valueLabel={formatPercentLabel(memoryPercent())}
title={`${name()} memory history`}
/>
</Show>
</TableCell>
<TableCell class="px-3 py-2">
<Show
when={isSparklineMode()}
fallback={
<Show
when={isOnline() && (aggregateDisk() || node.agent?.disks?.length)}
fallback={
@ -210,36 +300,44 @@ export const ProxmoxNodesTable: Component<{
aggregateDisk={aggregateDisk()}
/>
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-right">
<Show
when={typeof temperature() === 'number' && (temperature() as number) > 0}
fallback={<span class="text-xs text-muted"></span>}
>
<TemperatureGauge value={temperature() as number} />
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-center">
<span class={counts().vms > 0 ? VMS_BADGE : ZERO_BADGE}>
{counts().vms}
</span>
</TableCell>
<TableCell class="px-3 py-2 text-center">
<span class={counts().containers > 0 ? CTS_BADGE : ZERO_BADGE}>
{counts().containers}
</span>
</TableCell>
<TableCell class="px-3 py-2">
<span class="inline-flex items-center rounded-md bg-surface-alt px-2 py-0.5 text-[11px] font-medium text-base-content">
{cluster()}
</span>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
}
>
<MetricMiniSparkline
series={diskSeries()}
valueLabel={formatPercentLabel(diskPercent())}
title={`${name()} disk history`}
/>
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-right">
<Show
when={typeof temperature() === 'number' && (temperature() as number) > 0}
fallback={<span class="text-xs text-muted"></span>}
>
<TemperatureGauge value={temperature() as number} />
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-center">
<span class={counts().vms > 0 ? VMS_BADGE : ZERO_BADGE}>
{counts().vms}
</span>
</TableCell>
<TableCell class="px-3 py-2 text-center">
<span class={counts().containers > 0 ? CTS_BADGE : ZERO_BADGE}>
{counts().containers}
</span>
</TableCell>
<TableCell class="px-3 py-2">
<span class="inline-flex items-center rounded-md bg-surface-alt px-2 py-0.5 text-[11px] font-medium text-base-content">
{cluster()}
</span>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</Card>
</Show>
);

View file

@ -3,9 +3,20 @@ import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert';
import { For, Show, createMemo } from 'solid-js';
import StorageSurface from '@/components/Storage/Storage';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import {
DEFAULT_WORKLOADS_METRIC_DISPLAY_MODE,
type WorkloadsMetricDisplayMode,
} from '@/components/Workloads/workloadsFilterModel';
import {
WORKLOAD_TABLE_HISTORY_DEFAULT_RANGE,
isWorkloadTableMetricHistoryRange,
type WorkloadTableMetricHistoryRange,
} from '@/components/Workloads/workloadMetricHistoryModel';
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
import { EmptyState } from '@/components/shared/EmptyState';
import { TableCard } from '@/components/shared/TableCard';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { ProxmoxBackupsTable } from './ProxmoxBackupsTable';
import { ProxmoxCephTable } from './ProxmoxCephTable';
import { ProxmoxMailGatewayTable } from './ProxmoxMailGatewayTable';
@ -83,6 +94,31 @@ export function ProxmoxPageSurface() {
});
const model = createMemo(() => buildProxmoxPageModel(resources()));
// The hosts table at the top and the embedded WorkloadsSurface below share
// the bars/sparklines toggle (and the sparkline history range that ships
// with it). Owning the persistent signals at the page level lets one
// segmented control in the workloads filter drive both tables; the surface
// accepts these as overrides so it skips its own internal persistent
// signal and tracks the shared state directly.
const [metricDisplayMode, setMetricDisplayMode] =
usePersistentSignal<WorkloadsMetricDisplayMode>(
STORAGE_KEYS.WORKLOADS_METRIC_DISPLAY_MODE,
DEFAULT_WORKLOADS_METRIC_DISPLAY_MODE,
{
deserialize: (raw) =>
raw === 'bars' || raw === 'sparklines' ? raw : DEFAULT_WORKLOADS_METRIC_DISPLAY_MODE,
},
);
const [metricHistoryRange, setMetricHistoryRange] =
usePersistentSignal<WorkloadTableMetricHistoryRange>(
STORAGE_KEYS.WORKLOADS_METRIC_HISTORY_RANGE,
WORKLOAD_TABLE_HISTORY_DEFAULT_RANGE,
{
deserialize: (raw) =>
isWorkloadTableMetricHistoryRange(raw) ? raw : WORKLOAD_TABLE_HISTORY_DEFAULT_RANGE,
},
);
return (
<div data-testid="proxmox-page" class="space-y-3">
<ProxmoxSectionTabs active={activeTab()} />
@ -133,6 +169,8 @@ export function ProxmoxPageSurface() {
<ProxmoxNodesTable
nodes={model().pveNodes}
guests={model().guests}
metricDisplayMode={metricDisplayMode}
metricHistoryRange={metricHistoryRange}
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."
@ -148,6 +186,10 @@ export function ProxmoxPageSurface() {
suppressPlatformFilter
forcedPlatform={PROXMOX_PLATFORM_FILTER}
compactGroupHeaders
metricDisplayMode={metricDisplayMode}
onMetricDisplayModeChange={setMetricDisplayMode}
metricHistoryRange={metricHistoryRange}
onMetricHistoryRangeChange={setMetricHistoryRange}
/>
</div>
</Show>