mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
1c8a9346ef
commit
ffd50a0052
21 changed files with 274 additions and 53 deletions
|
|
@ -113,6 +113,27 @@ describe('NodeDrawer', () => {
|
|||
expect(screen.getByText('Temp monitor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('colors overview thermal rows from configured thresholds', () => {
|
||||
render(() => (
|
||||
<NodeDrawer
|
||||
node={makeNode({
|
||||
temperature: {
|
||||
cpuPackage: 76,
|
||||
cpuMax: 76,
|
||||
cpuMin: 50,
|
||||
cpuMaxRecord: 76,
|
||||
available: true,
|
||||
hasCPU: true,
|
||||
lastUpdate: new Date().toISOString(),
|
||||
},
|
||||
})}
|
||||
temperatureThresholds={{ warning: 80, critical: 85 }}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getAllByText('76°C')[0]).toHaveClass('text-green-600');
|
||||
});
|
||||
|
||||
it('renders node-only thermal history without requiring a table temperature column', async () => {
|
||||
render(() => <NodeDrawer node={makeNode()} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { DiscoveryTab } from '@/components/Discovery/DiscoveryTab';
|
|||
import { DiscoveryLoadingFallback } from '@/components/shared/DiscoveryLoadingFallback';
|
||||
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
|
||||
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
|
||||
import { nodeOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import type { Disk, Node } from '@/types/api';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
import { getNodeDisplayName } from '@/utils/nodes';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
|
||||
|
|
@ -27,6 +30,7 @@ interface NodeDrawerProps {
|
|||
node: Node;
|
||||
disks?: Disk[];
|
||||
discoveryTarget?: NodeDrawerDiscoveryTarget;
|
||||
temperatureThresholds?: MetricDisplayThresholds | null;
|
||||
}
|
||||
|
||||
type NodeDrawerTab = 'overview' | 'history' | 'discovery';
|
||||
|
|
@ -36,12 +40,22 @@ export const NodeDrawer: Component<NodeDrawerProps> = (props) => {
|
|||
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
|
||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||
);
|
||||
const alertsActivation = useAlertsActivation();
|
||||
|
||||
const headingId = () => `node-drawer-heading-${props.node.id}`;
|
||||
const displayName = createMemo(() => getNodeDisplayName(props.node));
|
||||
const historyTarget = createMemo(() => getNodeDrawerHistoryTarget(props.node));
|
||||
const fallbackMetrics = createMemo(() => getNodeDrawerHistoryFallbackMetrics(props.node));
|
||||
const headerIndicator = createMemo(() => getSimpleStatusIndicator(props.node.status));
|
||||
const temperatureThresholds = createMemo(() =>
|
||||
props.temperatureThresholds !== undefined
|
||||
? props.temperatureThresholds
|
||||
: alertsActivation.getMetricThresholds(
|
||||
'node',
|
||||
'temperature',
|
||||
nodeOverrideIdCandidates(props.node),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<section class="space-y-3" aria-labelledby={headingId()} data-testid="node-drawer">
|
||||
|
|
@ -74,7 +88,11 @@ export const NodeDrawer: Component<NodeDrawerProps> = (props) => {
|
|||
{/* Use CSS hidden instead of Show to avoid mount/unmount which causes scroll jumps.
|
||||
overflow-anchor: none prevents browser scroll anchoring from jumping when display toggles. */}
|
||||
<div class={activeTab() === 'overview' ? '' : 'hidden'} style={{ 'overflow-anchor': 'none' }}>
|
||||
<NodeDrawerOverview node={props.node} disks={props.disks} />
|
||||
<NodeDrawerOverview
|
||||
node={props.node}
|
||||
disks={props.disks}
|
||||
temperatureThresholds={temperatureThresholds()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={activeTab() === 'history' ? '' : 'hidden'} style={{ 'overflow-anchor': 'none' }}>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
formatUptime,
|
||||
normalizeDiskArray,
|
||||
} from '@/utils/format';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
import { getNodeDisplayName } from '@/utils/nodes';
|
||||
import { formatTemperature, getCpuTemperature, getTemperatureTextClass } from '@/utils/temperature';
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ import { DrawerDiskListCard, buildDrawerDiskListItems } from './DrawerDiskListCa
|
|||
interface NodeDrawerOverviewProps {
|
||||
node: Node;
|
||||
disks?: Disk[];
|
||||
temperatureThresholds?: MetricDisplayThresholds | null;
|
||||
}
|
||||
|
||||
interface NodeOverviewRow {
|
||||
|
|
@ -89,25 +91,29 @@ const pushTemperature = (
|
|||
rows: NodeOverviewRow[],
|
||||
label: string,
|
||||
value: number | null | undefined,
|
||||
thresholds: MetricDisplayThresholds | null | undefined,
|
||||
): void => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return;
|
||||
rows.push({
|
||||
label,
|
||||
value: formatTemperature(value),
|
||||
valueClass: getTemperatureTextClass(value),
|
||||
valueClass: getTemperatureTextClass(value, thresholds),
|
||||
});
|
||||
};
|
||||
|
||||
const getThermalRows = (temperature: Temperature | undefined): NodeOverviewRow[] => {
|
||||
const getThermalRows = (
|
||||
temperature: Temperature | undefined,
|
||||
thresholds: MetricDisplayThresholds | null | undefined,
|
||||
): NodeOverviewRow[] => {
|
||||
if (!temperature?.available) return [];
|
||||
|
||||
const rows: NodeOverviewRow[] = [];
|
||||
const primary = getCpuTemperature(temperature);
|
||||
|
||||
pushTemperature(rows, 'CPU current', primary);
|
||||
pushTemperature(rows, 'CPU package', temperature.cpuPackage);
|
||||
pushTemperature(rows, 'CPU low', temperature.cpuMin);
|
||||
pushTemperature(rows, 'CPU record', temperature.cpuMaxRecord);
|
||||
pushTemperature(rows, 'CPU current', primary, thresholds);
|
||||
pushTemperature(rows, 'CPU package', temperature.cpuPackage, thresholds);
|
||||
pushTemperature(rows, 'CPU low', temperature.cpuMin, thresholds);
|
||||
pushTemperature(rows, 'CPU record', temperature.cpuMaxRecord, thresholds);
|
||||
|
||||
if (temperature.nvme?.length) {
|
||||
rows.push({
|
||||
|
|
@ -325,7 +331,10 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
|
|||
<DrawerDiskListCard disks={perDiskItems()} testId="node-drawer-disks" />
|
||||
</Show>
|
||||
<DetailCard title="Telemetry" rows={telemetryRows()} />
|
||||
<DetailCard title="Thermals" rows={getThermalRows(props.node.temperature)} />
|
||||
<DetailCard
|
||||
title="Thermals"
|
||||
rows={getThermalRows(props.node.temperature, props.temperatureThresholds)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ type WorkloadPanelProps = Pick<
|
|||
| 'bottomSpacerHeight'
|
||||
| 'compactGroupHeaders'
|
||||
| 'getGroupLabel'
|
||||
| 'getNodeTemperatureThresholds'
|
||||
| 'groupedGuests'
|
||||
| 'groupedWindowing'
|
||||
| 'groupLabelBadges'
|
||||
|
|
@ -181,7 +182,10 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
|||
<span title="Proxmox VE version">PVE {version}</span>
|
||||
</Show>
|
||||
<Show when={temperature !== null}>
|
||||
<span class={getTemperatureTextClass(temperature)} title="CPU temperature">
|
||||
<span
|
||||
class={getTemperatureTextClass(temperature, props.getNodeTemperatureThresholds(node))}
|
||||
title="CPU temperature"
|
||||
>
|
||||
{formatTemperature(temperature)}
|
||||
</span>
|
||||
</Show>
|
||||
|
|
@ -461,6 +465,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
|||
>
|
||||
<NodeGroupHeader
|
||||
node={node()!}
|
||||
temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}
|
||||
renderAs="tr"
|
||||
colspan={props.totalColumns()}
|
||||
columns={
|
||||
|
|
@ -502,7 +507,10 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
|||
colspan={props.totalColumns()}
|
||||
data-inline-node-detail-for={groupKey()}
|
||||
>
|
||||
<NodeDrawer node={node()!} />
|
||||
<NodeDrawer
|
||||
node={node()!}
|
||||
temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}
|
||||
/>
|
||||
</InlineDetailTableRow>
|
||||
</Show>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -91,12 +91,14 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
|
|||
}
|
||||
|
||||
if (visibleInventoryIssues().length > 0) {
|
||||
return state.workloadsNoInventoryState?.() ?? {
|
||||
title: 'No workload inventory available',
|
||||
description:
|
||||
'Pulse has infrastructure sources, but no VM, container, or pod inventory is available right now.',
|
||||
actionLabel: 'Review infrastructure sources',
|
||||
};
|
||||
return (
|
||||
state.workloadsNoInventoryState?.() ?? {
|
||||
title: 'No workload inventory available',
|
||||
description:
|
||||
'Pulse has infrastructure sources, but no VM, container, or pod inventory is available right now.',
|
||||
actionLabel: 'Review infrastructure sources',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -169,6 +171,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
|
|||
bottomSpacerHeight={state.bottomSpacerHeight}
|
||||
compactGroupHeaders={state.compactGroupHeaders}
|
||||
getGroupLabel={state.getGroupLabel}
|
||||
getNodeTemperatureThresholds={state.getNodeTemperatureThresholds}
|
||||
groupedGuests={state.groupedGuests}
|
||||
groupedWindowing={state.groupedWindowing}
|
||||
groupLabelBadges={state.groupLabelBadges}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type WorkloadsTableProps = Pick<
|
|||
| 'bottomSpacerHeight'
|
||||
| 'compactGroupHeaders'
|
||||
| 'getGroupLabel'
|
||||
| 'getNodeTemperatureThresholds'
|
||||
| 'groupedGuests'
|
||||
| 'groupedWindowing'
|
||||
| 'groupLabelBadges'
|
||||
|
|
@ -102,6 +103,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
|
|||
bottomSpacerHeight={props.bottomSpacerHeight}
|
||||
compactGroupHeaders={props.compactGroupHeaders}
|
||||
getGroupLabel={props.getGroupLabel}
|
||||
getNodeTemperatureThresholds={props.getNodeTemperatureThresholds}
|
||||
groupedGuests={props.groupedGuests}
|
||||
groupedWindowing={props.groupedWindowing}
|
||||
groupLabelBadges={props.groupLabelBadges}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createEffect, createMemo, onCleanup, type Accessor } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { ConnectionsAPI, type ConnectionsListResponse } from '@/api/connections';
|
||||
import { nodeOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
|
||||
import type { VM, Container, Node } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { ViewMode, WorkloadGuest, WorkloadType } from '@/types/workloads';
|
||||
|
|
@ -350,6 +351,9 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
|||
reconnect();
|
||||
};
|
||||
|
||||
const getNodeTemperatureThresholds = (node: Node) =>
|
||||
alertsActivation.getMetricThresholds('node', 'temperature', nodeOverrideIdCandidates(node));
|
||||
|
||||
createEffect(() => {
|
||||
if (!workloadsEnabled()) return;
|
||||
const handle = window.setInterval(() => {
|
||||
|
|
@ -481,6 +485,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
|||
focusedSummaryWorkloadGroupScope,
|
||||
focusedSummaryWorkloadGroupId,
|
||||
getGroupLabel,
|
||||
getNodeTemperatureThresholds,
|
||||
groupedGuests,
|
||||
groupedWindowing,
|
||||
guestMetadata,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Component, For, Show, type JSX } from 'solid-js';
|
||||
import type { Node } from '@/types/api';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
import { getNodeDisplayName, getNodeExternalUrl, hasAlternateDisplayName } from '@/utils/nodes';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { getNodeStatusIndicator } from '@/utils/status';
|
||||
|
|
@ -22,6 +23,7 @@ interface NodeGroupHeaderProps {
|
|||
renderColumnCell?: (columnId: string, node: Node) => JSX.Element;
|
||||
renderAs?: 'tr' | 'div';
|
||||
showFactsInName?: boolean;
|
||||
temperatureThresholds?: MetricDisplayThresholds | null;
|
||||
trClass?: string;
|
||||
trProps?: JSX.HTMLAttributes<HTMLTableRowElement> &
|
||||
Partial<Record<`data-${string}`, string | undefined>>;
|
||||
|
|
@ -89,7 +91,10 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
|
|||
<span title="Proxmox VE version">PVE {pveVersion()}</span>
|
||||
</Show>
|
||||
<Show when={cpuTemperature() !== null}>
|
||||
<span class={getTemperatureTextClass(cpuTemperature())} title="CPU temperature">
|
||||
<span
|
||||
class={getTemperatureTextClass(cpuTemperature(), props.temperatureThresholds)}
|
||||
title="CPU temperature"
|
||||
>
|
||||
{formatTemperature(cpuTemperature())}
|
||||
</span>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { Component, createMemo } from 'solid-js';
|
||||
import { formatTemperature, getTemperatureTextClass } from '@/utils/temperature';
|
||||
import {
|
||||
formatTemperature,
|
||||
getTemperatureTextClass,
|
||||
type TemperatureDisplayMetric,
|
||||
} from '@/utils/temperature';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
|
||||
interface TemperatureGaugeProps {
|
||||
|
|
@ -9,6 +13,7 @@ interface TemperatureGaugeProps {
|
|||
critical?: number;
|
||||
warning?: number;
|
||||
thresholds?: MetricDisplayThresholds | null;
|
||||
metric?: TemperatureDisplayMetric;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
|
|
@ -25,7 +30,7 @@ export const TemperatureGauge: Component<TemperatureGaugeProps> = (props) => {
|
|||
});
|
||||
|
||||
const textColorClass = createMemo(() =>
|
||||
getTemperatureTextClass(props.value, explicitThresholds()),
|
||||
getTemperatureTextClass(props.value, explicitThresholds(), props.metric),
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -52,4 +52,21 @@ describe('NodeGroupHeader', () => {
|
|||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('colors grouped header temperatures from configured thresholds', () => {
|
||||
render(() => (
|
||||
<NodeGroupHeader
|
||||
node={makeNode({
|
||||
temperature: {
|
||||
available: true,
|
||||
cpuPackage: 76,
|
||||
lastUpdate: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
})}
|
||||
temperatureThresholds={{ warning: 80, critical: 85 }}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('76°C')).toHaveClass('text-green-600');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { Resource } from '@/types/resource';
|
|||
import {
|
||||
buildContainerRuntimeResources,
|
||||
buildProjectedOverrides,
|
||||
nodeOverrideIdCandidates,
|
||||
normalizeRawOverridesConfig,
|
||||
} from '../alertOverridesModel';
|
||||
|
||||
|
|
@ -17,6 +18,25 @@ const makeResource = (overrides: Partial<Resource>): Resource =>
|
|||
}) as Resource;
|
||||
|
||||
describe('alertOverridesModel', () => {
|
||||
it('builds node temperature override candidates from stable node and agent identities', () => {
|
||||
expect(
|
||||
nodeOverrideIdCandidates({
|
||||
id: 'agent:pve-node-1',
|
||||
name: 'pve-node-1',
|
||||
instance: 'homelab',
|
||||
host: 'https://pve-node-1:8006',
|
||||
linkedAgentId: 'agent-linked',
|
||||
}),
|
||||
).toEqual([
|
||||
'agent-linked',
|
||||
'agent:pve-node-1',
|
||||
'homelab-pve-node-1',
|
||||
'homelab:pve-node-1',
|
||||
'pve-node-1',
|
||||
'https://pve-node-1:8006',
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes disk override ids into canonical agent disk keys', () => {
|
||||
expect(
|
||||
normalizeRawOverridesConfig({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { PBSInstance } from '@/types/api';
|
||||
import type { Node, PBSInstance } from '@/types/api';
|
||||
import type { RawOverrideConfig } from '@/types/alerts';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getActionableAgentIdFromResource, isTrueNASSystemResource } from '@/utils/agentResources';
|
||||
|
|
@ -154,6 +154,18 @@ export const hostOverrideIdCandidates = (resource: Resource): string[] => {
|
|||
);
|
||||
};
|
||||
|
||||
export const nodeOverrideIdCandidates = (
|
||||
node: Pick<Node, 'id' | 'name' | 'instance' | 'host' | 'linkedAgentId'>,
|
||||
): string[] =>
|
||||
uniqueIds(
|
||||
node.linkedAgentId,
|
||||
node.id,
|
||||
node.instance && node.name ? `${node.instance}-${node.name}` : undefined,
|
||||
node.instance && node.name ? `${node.instance}:${node.name}` : undefined,
|
||||
node.name,
|
||||
node.host,
|
||||
);
|
||||
|
||||
export const dockerHostOverrideIdCandidates = (resource: Resource): string[] => {
|
||||
const data = platformData(resource);
|
||||
const docker = asRecord(data?.docker);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import {
|
|||
} from '@/components/Workloads/DrawerDiskListCard';
|
||||
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
|
||||
import { useResourceDetailDrawerDockerActionsState } from '@/components/Infrastructure/useResourceDetailDrawerDockerActionsState';
|
||||
import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
|
||||
import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
formatBytes,
|
||||
|
|
@ -103,9 +105,17 @@ const DetailCard = (props: { title: string; rows: DockerOverviewRow[] }) => (
|
|||
);
|
||||
|
||||
export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const docker = () => props.host.docker;
|
||||
const agent = () => props.host.agent;
|
||||
const linkedAgentId = () => cleanText(agent()?.agentId);
|
||||
const temperatureThresholds = createMemo(() =>
|
||||
alertsActivation.getMetricThresholds(
|
||||
'node',
|
||||
'temperature',
|
||||
hostOverrideIdCandidates(props.host),
|
||||
),
|
||||
);
|
||||
|
||||
const runtimeLabel = (): string => {
|
||||
const runtime = cleanText(docker()?.runtime) || 'Docker';
|
||||
|
|
@ -302,7 +312,7 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
|
|||
rows.push({
|
||||
label: 'CPU temp',
|
||||
value: formatTemperature(temperature),
|
||||
valueClass: getTemperatureTextClass(temperature),
|
||||
valueClass: getTemperatureTextClass(temperature, temperatureThresholds()),
|
||||
});
|
||||
}
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Disk } from '@/types/api';
|
||||
|
|
@ -19,7 +19,10 @@ vi.mock('@/contexts/appRuntime', () => ({
|
|||
useWebSocket: () => ({ activeAlerts: {} as Record<string, never> }),
|
||||
}));
|
||||
vi.mock('@/stores/alertsActivation', () => ({
|
||||
useAlertsActivation: () => ({ activationState: () => 'active' }),
|
||||
useAlertsActivation: () => ({
|
||||
activationState: () => 'active',
|
||||
getMetricThresholds: () => ({ warning: 80, critical: 85 }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Workloads/StackedMemoryBar', () => ({
|
||||
|
|
@ -132,6 +135,23 @@ describe('DockerHostsTable', () => {
|
|||
expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute('type', 'button');
|
||||
});
|
||||
|
||||
it('colors drawer host temperatures from configured thresholds', () => {
|
||||
render(() => (
|
||||
<DockerHostsTable
|
||||
resources={[makeDockerHost({ temperature: 76 })]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No Docker hosts"
|
||||
emptyDescription="No hosts"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
fireEvent.click(screen.getByText('docker-01').closest('tr')!);
|
||||
|
||||
const drawer = screen.getByTestId('docker-host-drawer');
|
||||
expect(within(drawer).getByText('76°C')).toHaveClass('text-green-600');
|
||||
});
|
||||
|
||||
it('surfaces container update actions in the host drawer', () => {
|
||||
render(() => (
|
||||
<DockerHostsTable
|
||||
|
|
|
|||
|
|
@ -588,6 +588,7 @@ export const ProxmoxNodesTable: Component<{
|
|||
<NodeDrawer
|
||||
node={selectedNode()}
|
||||
disks={normalizeDiskArray(node.agent?.disks)}
|
||||
temperatureThresholds={temperatureThresholds()}
|
||||
discoveryTarget={(() => {
|
||||
const config = toDiscoveryConfig(node);
|
||||
return config
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ vi.mock('@/components/Workloads/useWorkloadTableMetricHistory', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@/components/Workloads/NodeDrawer', () => ({
|
||||
NodeDrawer: (props: { node: { name: string } }) => {
|
||||
nodeDrawerMock(props.node);
|
||||
NodeDrawer: (props: { node: { name: string }; temperatureThresholds?: unknown }) => {
|
||||
nodeDrawerMock(props);
|
||||
return <div data-testid="node-drawer">{props.node.name}</div>;
|
||||
},
|
||||
}));
|
||||
|
|
@ -304,7 +304,12 @@ describe('ProxmoxNodesTable', () => {
|
|||
|
||||
expect(row).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByTestId('node-drawer')).toHaveTextContent('pve-node-1');
|
||||
expect(nodeDrawerMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'pve-node-1' }));
|
||||
expect(nodeDrawerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
node: expect.objectContaining({ name: 'pve-node-1' }),
|
||||
temperatureThresholds: { warning: 80, critical: 85 },
|
||||
}),
|
||||
);
|
||||
|
||||
await fireEvent.click(row!);
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import {
|
|||
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
|
||||
import type { Disk } from '@/types/api';
|
||||
import type { Resource, ResourceAvailabilityMeta } from '@/types/resource';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
import { getActionableAgentIdFromResource } from '@/utils/agentResources';
|
||||
import { formatBytes, formatSpeed, normalizeDiskArray } from '@/utils/format';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
|
|
@ -88,6 +89,7 @@ import {
|
|||
getAgentMachineRaidSummary,
|
||||
getAgentMachineTemperatureCelsius,
|
||||
getAgentMachineTemperatureDetailSections,
|
||||
getAgentMachineTemperatureMetric,
|
||||
getAgentMachineTemperatureTitle,
|
||||
getAgentMachineThermalPressurePresentation,
|
||||
getNextAgentMachineSortState,
|
||||
|
|
@ -99,6 +101,7 @@ import {
|
|||
type AgentMachineRaidArrayDetail,
|
||||
type AgentMachineSortKey,
|
||||
type AgentMachineTemperatureDetailSection,
|
||||
type AgentMachineTemperatureMetric,
|
||||
} from './agentMachineTableModel';
|
||||
|
||||
type MetricFallbackReason = {
|
||||
|
|
@ -171,7 +174,9 @@ const AgentMachineMetricTooltip: Component<{
|
|||
|
||||
const AgentMachineTemperatureCell: Component<{
|
||||
celsius: number | undefined;
|
||||
metric: AgentMachineTemperatureMetric;
|
||||
sections: AgentMachineTemperatureDetailSection[];
|
||||
thresholds?: MetricDisplayThresholds | null;
|
||||
title: string;
|
||||
thermalPressure?: {
|
||||
label: string;
|
||||
|
|
@ -207,7 +212,9 @@ const AgentMachineTemperatureCell: Component<{
|
|||
</Show>
|
||||
}
|
||||
>
|
||||
{(value) => <TemperatureGauge value={value()} />}
|
||||
{(value) => (
|
||||
<TemperatureGauge value={value()} metric={props.metric} thresholds={props.thresholds} />
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
|
|
@ -1479,6 +1486,13 @@ export const AgentsMachinesTable: Component<{
|
|||
const raidArrays = () => getAgentMachineRaidArrayDetails(machine);
|
||||
const raidSummary = () => getAgentMachineRaidSummary(machine);
|
||||
const temperature = () => getAgentMachineTemperatureCelsius(machine);
|
||||
const temperatureMetric = () => getAgentMachineTemperatureMetric(machine);
|
||||
const temperatureThresholds = () =>
|
||||
alertsActivation.getMetricThresholds(
|
||||
temperatureMetric() === 'diskTemperature' ? 'agent' : 'node',
|
||||
temperatureMetric(),
|
||||
alertResourceIds(),
|
||||
);
|
||||
const temperatureSections = () =>
|
||||
getAgentMachineTemperatureDetailSections(machine);
|
||||
const temperatureTitle = () => getAgentMachineTemperatureTitle(machine);
|
||||
|
|
@ -1672,7 +1686,9 @@ export const AgentsMachinesTable: Component<{
|
|||
>
|
||||
<AgentMachineTemperatureCell
|
||||
celsius={temperature()}
|
||||
metric={temperatureMetric()}
|
||||
sections={temperatureSections()}
|
||||
thresholds={temperatureThresholds()}
|
||||
title={temperatureTitle()}
|
||||
thermalPressure={thermalPressure()}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getAgentMachineNetworkInterfaceDetails,
|
||||
getAgentMachineRaidArrayDetails,
|
||||
getAgentMachineTemperatureCelsius,
|
||||
getAgentMachineTemperatureMetric,
|
||||
getAgentMachineTemperatureTitle,
|
||||
matchesAgentMachineSearch,
|
||||
sortAgentMachines,
|
||||
|
|
@ -182,35 +183,32 @@ describe('agentMachineTableModel', () => {
|
|||
});
|
||||
|
||||
expect(getAgentMachineTemperatureCelsius(machine)).toBe(44);
|
||||
expect(getAgentMachineTemperatureMetric(machine)).toBe('diskTemperature');
|
||||
});
|
||||
|
||||
it('prefers direct and sensor temperatures over SMART fallback temperatures', () => {
|
||||
expect(
|
||||
getAgentMachineTemperatureCelsius(
|
||||
resource({
|
||||
temperature: 55,
|
||||
agent: {
|
||||
sensors: {
|
||||
temperatureCelsius: { 'cpu.package': 61 },
|
||||
smart: [{ device: '/dev/nvme0', temperature: 72 }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe(55);
|
||||
const direct = resource({
|
||||
temperature: 55,
|
||||
agent: {
|
||||
sensors: {
|
||||
temperatureCelsius: { 'cpu.package': 61 },
|
||||
smart: [{ device: '/dev/nvme0', temperature: 72 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getAgentMachineTemperatureCelsius(direct)).toBe(55);
|
||||
expect(getAgentMachineTemperatureMetric(direct)).toBe('temperature');
|
||||
|
||||
expect(
|
||||
getAgentMachineTemperatureCelsius(
|
||||
resource({
|
||||
agent: {
|
||||
sensors: {
|
||||
temperatureCelsius: { 'cpu.package': 61, 'cpu.core0': 64 },
|
||||
smart: [{ device: '/dev/nvme0', temperature: 72 }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe(64);
|
||||
const sensor = resource({
|
||||
agent: {
|
||||
sensors: {
|
||||
temperatureCelsius: { 'cpu.package': 61, 'cpu.core0': 64 },
|
||||
smart: [{ device: '/dev/nvme0', temperature: 72 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getAgentMachineTemperatureCelsius(sensor)).toBe(64);
|
||||
expect(getAgentMachineTemperatureMetric(sensor)).toBe('temperature');
|
||||
});
|
||||
|
||||
it('includes agent sensor, SMART, fan, and additional readings in the temperature title', () => {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,8 @@ export type AgentMachineThermalPressurePresentation = {
|
|||
className: string;
|
||||
};
|
||||
|
||||
export type AgentMachineTemperatureMetric = 'temperature' | 'diskTemperature';
|
||||
|
||||
export type AgentMachineNetworkInterfaceDetail = {
|
||||
name: string;
|
||||
mac?: string;
|
||||
|
|
@ -458,6 +460,19 @@ export const getAgentMachineTemperatureCelsius = (machine: Resource): number | u
|
|||
);
|
||||
};
|
||||
|
||||
export const getAgentMachineTemperatureMetric = (
|
||||
machine: Resource,
|
||||
): AgentMachineTemperatureMetric => {
|
||||
if (positiveTemperature(machine.temperature) !== undefined) return 'temperature';
|
||||
if (maxTemperatureReading(getSensorTemperatureReadings(machine)) !== undefined) {
|
||||
return 'temperature';
|
||||
}
|
||||
if (maxTemperatureReading(getActiveSmartTemperatureReadings(machine)) !== undefined) {
|
||||
return 'diskTemperature';
|
||||
}
|
||||
return 'temperature';
|
||||
};
|
||||
|
||||
export const getAgentMachineTemperatureDetailSections = (
|
||||
machine: Resource,
|
||||
): AgentMachineTemperatureDetailSection[] => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import nodeDrawerOverviewSource from '@/components/Workloads/NodeDrawerOverview.tsx?raw';
|
||||
import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw';
|
||||
import nodeGroupHeaderSource from '@/components/shared/NodeGroupHeader.tsx?raw';
|
||||
import temperatureGaugeSource from '@/components/shared/TemperatureGauge.tsx?raw';
|
||||
import dockerHostDrawerOverviewSource from '@/features/docker/DockerHostDrawerOverview.tsx?raw';
|
||||
import agentsMachinesTableSource from '@/features/standalone/AgentsMachinesTable.tsx?raw';
|
||||
|
||||
describe('temperature display guardrails', () => {
|
||||
it('keeps colored temperature displays threshold-aware', () => {
|
||||
expect(nodeGroupHeaderSource).toContain('temperatureThresholds');
|
||||
expect(workloadPanelSource).toContain('getNodeTemperatureThresholds(node)');
|
||||
expect(nodeDrawerOverviewSource).toContain('getTemperatureTextClass(value, thresholds)');
|
||||
expect(dockerHostDrawerOverviewSource).toContain('temperatureThresholds()');
|
||||
expect(agentsMachinesTableSource).toContain('getAgentMachineTemperatureMetric');
|
||||
expect(agentsMachinesTableSource).toContain('thresholds={temperatureThresholds()}');
|
||||
expect(temperatureGaugeSource).toContain('props.metric');
|
||||
});
|
||||
|
||||
it('does not reintroduce the previous local temperature severity calls', () => {
|
||||
expect(nodeGroupHeaderSource).not.toContain('getTemperatureTextClass(cpuTemperature())');
|
||||
expect(workloadPanelSource).not.toContain('getTemperatureTextClass(temperature)}');
|
||||
expect(nodeDrawerOverviewSource).not.toContain('getTemperatureTextClass(value)');
|
||||
expect(dockerHostDrawerOverviewSource).not.toContain('getTemperatureTextClass(temperature)');
|
||||
expect(agentsMachinesTableSource).not.toContain('<TemperatureGauge value={value()} />');
|
||||
});
|
||||
});
|
||||
|
|
@ -104,7 +104,10 @@ const TEMPERATURE_TEXT_CLASSES = {
|
|||
normal: 'text-green-600 dark:text-green-400',
|
||||
} as const;
|
||||
|
||||
type TemperatureDisplayMetric = Extract<DisplayMetricType, 'temperature' | 'diskTemperature'>;
|
||||
export type TemperatureDisplayMetric = Extract<
|
||||
DisplayMetricType,
|
||||
'temperature' | 'diskTemperature'
|
||||
>;
|
||||
|
||||
export const getTemperatureTextClass = (
|
||||
celsius: number | null | undefined,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue