mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-18 23:41:06 +00:00
proxmox(overview): share the bars/sparklines toggle between the hosts table and the workloads
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:
parent
dc7c83a388
commit
dae4fb3bb4
6 changed files with 368 additions and 120 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;');
|
||||
|
|
|
|||
|
|
@ -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'>(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue