From 0becfd49660bd71a7a88813db4a3f92ddbc257cb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 20 Mar 2026 19:06:38 +0000 Subject: [PATCH] Extract alerts resource table owners --- .../v6/internal/subsystems/alerts.md | 7 + .../components/Alerts/ResourceTable.test.tsx | 62 ++ .../src/components/Alerts/ResourceTable.tsx | 645 ++++-------------- .../Alerts/alertResourceTableModel.ts | 299 ++++++++ .../Alerts/useAlertResourceTableState.ts | 80 +++ .../frontendResourceTypeBoundaries.test.ts | 23 + 6 files changed, 597 insertions(+), 519 deletions(-) create mode 100644 frontend-modern/src/components/Alerts/alertResourceTableModel.ts create mode 100644 frontend-modern/src/components/Alerts/useAlertResourceTableState.ts diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 3876a6743..f323138c8 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -117,6 +117,13 @@ with `frontend-modern/src/components/Alerts/ThresholdsTable.tsx` limited to table interaction and presentation. New threshold row grouping, override-ID compatibility, and resource normalization logic should land in the hook rather than being rebuilt inside the table component. +The alert resource thresholds editor now follows the same shape: shared metric +normalization, bounds, value-resolution, and override-label logic live in +`frontend-modern/src/components/Alerts/alertResourceTableModel.ts`, while +selection state, delay-row toggling, and inline metric-input focus live in +`frontend-modern/src/components/Alerts/useAlertResourceTableState.ts`. Future +resource-table threshold semantics should land in those owners instead of +being rebuilt inline in `frontend-modern/src/components/Alerts/ResourceTable.tsx`. Alert incident timeline event cards now route through `frontend-modern/src/components/Alerts/IncidentTimelineEventCard.tsx`, diff --git a/frontend-modern/src/components/Alerts/ResourceTable.test.tsx b/frontend-modern/src/components/Alerts/ResourceTable.test.tsx index 6adf0837c..8301b1f09 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.test.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.test.tsx @@ -1,6 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { ALERT_BULK_EDIT_CLEAR_LABEL } from '@/utils/alertBulkEditPresentation'; +import resourceTableSource from '@/components/Alerts/ResourceTable.tsx?raw'; +import alertResourceTableModelSource from '@/components/Alerts/alertResourceTableModel.ts?raw'; +import alertResourceTableStateSource from '@/components/Alerts/useAlertResourceTableState.ts?raw'; +import { + buildAlertResourceEditPayload, + getAlertResourceMetricDisplayValue, + normalizeAlertResourceMetricKey, +} from '@/components/Alerts/alertResourceTableModel'; // --- Mocks (must be before component import) --- const mockIsMobile = vi.fn(() => false); @@ -258,6 +266,60 @@ describe('ResourceTable', () => { }); }); + describe('table ownership model', () => { + it('keeps table state and metric rules in dedicated owners', () => { + expect(resourceTableSource).toContain('useAlertResourceTableState'); + expect(resourceTableSource).not.toContain('const flattenResources = (): Resource[] => {'); + expect(resourceTableSource).not.toContain( + 'const normalizeMetricKey = (column: string): string => {', + ); + expect(alertResourceTableStateSource).toContain('export function useAlertResourceTableState'); + expect(alertResourceTableModelSource).toContain( + 'export function normalizeAlertResourceMetricKey', + ); + expect(alertResourceTableModelSource).toContain( + 'export function getAlertResourceMetricDisplayValue', + ); + }); + + it('resolves metric values and edit payloads through the shared model', () => { + expect(normalizeAlertResourceMetricKey('CPU %')).toBe('cpu'); + expect( + getAlertResourceMetricDisplayValue( + makeResource({ + thresholds: { cpu: 80 }, + defaults: { cpu: 75 }, + }), + 'cpu', + ), + ).toBe(80); + expect( + getAlertResourceMetricDisplayValue( + makeResource({ + thresholds: { cpu: 80 }, + defaults: { cpu: 75 }, + }), + 'cpu', + { cpu: undefined }, + true, + ), + ).toBe(75); + expect( + buildAlertResourceEditPayload( + makeResource({ + thresholds: { cpu: 85 }, + defaults: { cpu: 80 }, + note: 'Investigating bursty load', + }), + ), + ).toEqual({ + thresholds: { cpu: 85 }, + defaults: { cpu: 80 }, + note: 'Investigating bursty load', + }); + }); + }); + describe('resource display and editing', () => { it('calls onEdit with correct args when edit button is clicked', () => { const onEdit = vi.fn(); diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx index 770b1b280..23cae5d33 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx @@ -1,4 +1,4 @@ -import { For, Show, createSignal, createEffect } from 'solid-js'; +import { For, Show, createEffect } from 'solid-js'; import { useBreakpoint } from '@/hooks/useBreakpoint'; import { TogglePrimitive } from '@/components/shared/Toggle'; import { StatusBadge } from '@/components/shared/StatusBadge'; @@ -16,7 +16,6 @@ import { SectionHeader } from '@/components/shared/SectionHeader'; import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider'; import { HelpIcon } from '@/components/shared/HelpIcon'; import RotateCcw from 'lucide-solid/icons/rotate-ccw'; -import { logger } from '@/utils/logger'; import { getAlertResourceTableAlertDelayLabel, getAlertResourceTableCustomBadgeLabel, @@ -32,59 +31,31 @@ import { getAlertResourceTableResetFactoryDefaultsLabel, getAlertResourceTableRevertToDefaultsLabel, } from '@/utils/alertResourceTablePresentation'; -import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity'; import { ALERT_BULK_EDIT_CLEAR_LABEL, getAlertBulkEditOpenLabel, } from '@/utils/alertBulkEditPresentation'; -import type { Resource as UnifiedResource, ResourcePolicy } from '@/types/resource'; - -const COLUMN_TOOLTIP_LOOKUP: Record = { - 'cpu %': 'Percent CPU utilization allowed before an alert fires.', - 'memory %': 'Percent memory usage threshold for triggering alerts.', - 'disk %': 'Percent disk usage threshold for this resource.', - 'disk r mb/s': 'Maximum sustained disk read throughput before alerting.', - 'disk w mb/s': 'Maximum sustained disk write throughput before alerting.', - 'net in mb/s': 'Inbound network throughput threshold for alerts.', - 'net out mb/s': 'Outbound network throughput threshold for alerts.', - 'usage %': 'Storage capacity usage percentage that triggers an alert.', - 'temp °c': 'CPU temperature limit for node alerts.', - 'temperature °c': 'CPU temperature limit for node alerts.', - temperature: 'CPU temperature limit for node alerts.', - 'disk temp °c': 'Individual disk temperature threshold for agents.', - 'restart count': 'Maximum container restarts within the evaluation window.', - 'restart window': 'Time window used to evaluate the restart count threshold.', - 'restart window (s)': 'Time window used to evaluate the restart count threshold.', - 'memory warn %': 'Warning threshold for container memory usage.', - 'memory critical %': 'Critical threshold for container memory usage.', - // PMG (Proxmox Mail Gateway) thresholds - 'queue warn': 'Early warning when total mail queue exceeds this message count.', - 'queue crit': 'Critical alert requiring urgent action when queue reaches this size.', - 'deferred warn': - 'Early warning for messages stuck in deferred queue (waiting to retry delivery).', - 'deferred crit': 'Critical threshold for deferred messages indicating serious delivery problems.', - 'hold warn': 'Early warning when administratively held messages exceed this count.', - 'hold crit': 'Critical alert for held messages requiring immediate moderation attention.', - 'oldest warn (min)': 'Early warning when oldest queued message exceeds this age in minutes.', - 'oldest crit (min)': 'Critical alert when message queue age indicates delivery has stalled.', - 'spam warn': 'Early warning for spam messages accumulating in quarantine.', - 'spam crit': 'Critical spam quarantine level requiring urgent intervention.', - 'virus warn': 'Early warning for virus-positive messages in quarantine.', - 'virus crit': 'Critical virus quarantine threshold indicating potential outbreak.', - 'growth warn %': 'Early warning when quarantine growth rate exceeds this percentage.', - 'growth warn min': 'Minimum new messages required before growth percentage triggers warning.', - 'growth crit %': 'Critical quarantine growth rate requiring immediate investigation.', - 'growth crit min': - 'Minimum new messages required before growth percentage triggers critical alert.', - 'warning size (gib)': 'Total snapshot size in GiB that raises a warning.', - 'critical size (gib)': 'Total snapshot size in GiB that raises a critical alert.', -}; +import type { ResourcePolicy } from '@/types/resource'; +import { + ALERT_RESOURCE_TABLE_SLIDER_METRICS, + alertResourceSupportsMetric, + buildAlertResourceEditPayload, + getAlertResourceColumnHeaderTooltip, + getAlertResourceEnabledDefault, + getAlertResourceLabel, + getAlertResourceMetricBounds, + getAlertResourceMetricDelayOverride, + getAlertResourceMetricDisplayValue, + getAlertResourceMetricStep, + isAlertResourceMetricOverridden, + normalizeAlertResourceMetricKey, + type AlertResourceThresholdMap, +} from './alertResourceTableModel'; +import { useAlertResourceTableState } from './useAlertResourceTableState'; const OFFLINE_ALERTS_TOOLTIP = 'Toggle default behavior for powered-off or connectivity alerts for this resource type.'; -const SLIDER_METRICS = new Set(['cpu', 'memory', 'disk', 'temperature', 'diskTemperature']); - export interface Resource { id: string; name: string; @@ -98,8 +69,8 @@ export interface Resource { type?: string; resourceType?: string; subtitle?: string; - thresholds?: Record; - defaults?: Record; + thresholds?: AlertResourceThresholdMap; + defaults?: AlertResourceThresholdMap; disabled?: boolean; disableConnectivity?: boolean; poweredOffSeverity?: 'warning' | 'critical'; @@ -190,235 +161,29 @@ type OfflineState = 'off' | 'warning' | 'critical'; export function ResourceTable(props: ResourceTableProps) { const { isMobile } = useBreakpoint(); - - const flattenResources = (): Resource[] => { - if (props.groupedResources) { - return Object.values(props.groupedResources).flat(); - } - return props.resources ?? []; - }; - - const hasRows = () => { - if (flattenResources().length > 0) { - return true; - } - if (props.groupedResources && Object.keys(props.groupedResources).length > 0) { - return true; - } - return Boolean(props.globalDefaults); - }; - - const [activeMetricInput, setActiveMetricInput] = createSignal<{ - resourceId: string; - metric: string; - } | null>(null); - const [showDelayRow, setShowDelayRow] = createSignal(false); - const [selectedIds, setSelectedIds] = createSignal>(new Set()); - - const toggleSelection = (id: string, checked: boolean) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (checked) next.add(id); - else next.delete(id); - return next; - }); - }; - - const toggleAll = (checked: boolean) => { - if (checked) { - setSelectedIds(new Set(flattenResources().map((r) => r.id))); - } else { - setSelectedIds(new Set()); - } - }; - - const allSelected = () => { - const total = flattenResources().length; - return total > 0 && selectedIds().size === total; - }; - - const someSelected = () => { - return selectedIds().size > 0 && selectedIds().size < flattenResources().length; - }; - - // Track changes to global defaults and factory defaults for debugging - createEffect(() => { - logger.debug('[ResourceTable] props changed', { - title: props.title, - globalDefaults: props.globalDefaults, - factoryDefaults: props.factoryDefaults, - onResetDefaults: !!props.onResetDefaults, - }); + const { + activeMetricInput, + setActiveMetricInput, + showDelayRow, + setShowDelayRow, + selectedIds, + hasRows, + hasCustomGlobalDefaults, + toggleSelection, + toggleAll, + allSelected, + someSelected, + clearSelectedIds, + } = useAlertResourceTableState({ + resources: props.resources, + groupedResources: props.groupedResources, + globalDefaults: props.globalDefaults, + factoryDefaults: props.factoryDefaults, }); - // Check if global defaults have been customized from factory defaults - const hasCustomGlobalDefaults = () => { - logger.debug('[ResourceTable] hasCustomGlobalDefaults check', { - globalDefaults: props.globalDefaults, - factoryDefaults: props.factoryDefaults, - title: props.title, - }); - if (!props.globalDefaults || !props.factoryDefaults) { - logger.debug('[ResourceTable] Missing props, returning false'); - return false; - } - const result = Object.keys(props.factoryDefaults).some((key) => { - const current = props.globalDefaults?.[key]; - const factory = props.factoryDefaults?.[key]; - const differs = current !== undefined && current !== factory; - if (differs) { - logger.debug('[ResourceTable] Difference found', { - key, - current, - factory, - }); - } - return differs; - }); - logger.debug('[ResourceTable] hasCustomGlobalDefaults result', { result }); - return result; - }; - - const normalizeMetricKey = (column: string): string => { - const key = column.trim().toLowerCase(); - const mapped = new Map([ - ['cpu %', 'cpu'], - ['memory %', 'memory'], - ['disk %', 'disk'], - ['disk r mb/s', 'diskRead'], - ['disk w mb/s', 'diskWrite'], - ['net in mb/s', 'networkIn'], - ['net out mb/s', 'networkOut'], - ['usage %', 'usage'], - ['temp °c', 'temperature'], - ['temperature °c', 'temperature'], - ['temperature', 'temperature'], - ['restart count', 'restartCount'], - ['restart window', 'restartWindow'], - ['restart window (s)', 'restartWindow'], - ['memory warn %', 'memoryWarnPct'], - ['memory critical %', 'memoryCriticalPct'], - ['warning size (gib)', 'warningSizeGiB'], - ['critical size (gib)', 'criticalSizeGiB'], - ['disk temp °c', 'diskTemperature'], - ['backup', 'backup'], - ['snapshot', 'snapshot'], - ]).get(key); - if (mapped) { - return mapped; - } - - return key - .replace(' %', '') - .replace(' °c', '') - .replace(' mb/s', '') - .replace('disk r', 'diskRead') - .replace('disk w', 'diskWrite') - .replace('net in', 'networkIn') - .replace('net out', 'networkOut'); - }; - - const metricBounds = (metric: string): { min: number; max: number } => { - if (metric === 'temperature' || metric === 'diskTemperature') { - return { min: -1, max: 150 }; - } - if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) { - return { min: -1, max: 10000 }; - } - if (['cpu', 'memory', 'disk', 'usage', 'memoryWarnPct', 'memoryCriticalPct'].includes(metric)) { - return { min: -1, max: 100 }; - } - if (['warningSizeGiB', 'criticalSizeGiB'].includes(metric)) { - return { min: -1, max: 100000 }; - } - if (metric === 'restartCount') { - return { min: -1, max: 50 }; - } - if (metric === 'restartWindow') { - return { min: -1, max: 86400 }; - } - return { min: -1, max: 10000 }; - }; - - const metricStep = (metric: string): string | number => { - if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) { - return 'any'; - } - if (['warningSizeGiB', 'criticalSizeGiB'].includes(metric)) { - return 'any'; - } - return 1; - }; - - const getEnabledDefaultValue = (metric: string): number => { - if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) { - return 100; - } - if (metric === 'temperature') { - return 80; - } - if (metric === 'diskTemperature') { - return 55; - } - if (metric === 'restartCount') { - return 3; - } - if (metric === 'restartWindow') { - return 300; - } - if (metric === 'memoryWarnPct') { - return 90; - } - if (metric === 'memoryCriticalPct') { - return 95; - } - return 80; - }; - - const metricDelayOverride = (metric: string): number | undefined => { - const normalized = metric.trim().toLowerCase(); - const value = props.metricDelaySeconds?.[normalized] ?? props.metricDelaySeconds?.[metric]; - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined; - } - return value; - }; - const totalColumnCount = () => props.columns.length + 3 + (props.showOfflineAlertsColumn ? 1 : 0) + (props.onBulkEdit ? 1 : 0); - const getColumnHeaderTooltip = (column: string): string | undefined => { - const normalized = column.trim().toLowerCase(); - return COLUMN_TOOLTIP_LOOKUP[column] ?? COLUMN_TOOLTIP_LOOKUP[normalized]; - }; - - const resourceSupportsMetric = (resourceType: string | undefined, metric: string): boolean => { - if (!resourceType) return true; - if ( - resourceType === 'node' && - ['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric) - ) { - return false; - } - if (resourceType === 'pbs') { - return ['cpu', 'memory'].includes(metric); - } - if (resourceType === 'storage') { - return metric === 'usage'; - } - if (resourceType === 'dockerContainer') { - return [ - 'cpu', - 'memory', - 'restartCount', - 'restartWindow', - 'memoryWarnPct', - 'memoryCriticalPct', - ].includes(metric); - } - return true; - }; - const renderGroupHeader = (groupKey: string, meta?: GroupHeaderMeta) => { const groupLabel = meta?.displayName || meta?.rawName || groupKey; @@ -453,9 +218,35 @@ export function ResourceTable(props: ResourceTableProps) { ); }; - - const getResourceLabel = (resource: Resource): string => { - return getPreferredResourceDisplayName(resource as unknown as UnifiedResource); + const getThresholds = (resource: Resource, isEditing: boolean): AlertResourceThresholdMap => + isEditing ? props.editingThresholds() : (resource.thresholds ?? {}); + const getResourceLabel = (resource: Resource) => getAlertResourceLabel(resource); + const getDisplayValue = (resource: Resource, metric: string, isEditing: boolean) => + getAlertResourceMetricDisplayValue(resource, metric, props.editingThresholds(), isEditing); + const startEditing = (resource: Resource, metric?: string, event?: MouseEvent) => { + event?.stopPropagation(); + if (resource.editable === false) { + return; + } + if (metric) { + setActiveMetricInput({ resourceId: resource.id, metric }); + } + const payload = buildAlertResourceEditPayload(resource); + props.onEdit(resource.id, payload.thresholds, payload.defaults, payload.note); + }; + const cancelEditing = () => { + props.onCancelEdit(); + setActiveMetricInput(null); + }; + const saveEditing = (resourceId: string) => { + props.onSaveEdit(resourceId); + setActiveMetricInput(null); + }; + const updateEditingThreshold = (metric: string, value: number | undefined) => { + props.setEditingThresholds({ + ...props.editingThresholds(), + [metric]: value, + }); }; const MetricValueWithHeat = (metricProps: { @@ -564,13 +355,13 @@ export function ResourceTable(props: ResourceTableProps) {
{ - const m = normalizeMetricKey(c); + const m = normalizeAlertResourceMetricKey(c); return m !== 'backup' && m !== 'snapshot'; })} > {(column) => { - const metric = normalizeMetricKey(column); - const bounds = metricBounds(metric); + const metric = normalizeAlertResourceMetricKey(column); + const bounds = getAlertResourceMetricBounds(metric); const val = () => props.globalDefaults?.[metric] ?? 0; const isOff = () => val() === -1; return ( @@ -581,7 +372,7 @@ export function ResourceTable(props: ResourceTableProps) { type="number" min={bounds.min} max={bounds.max} - step={metricStep(metric)} + step={getAlertResourceMetricStep(metric)} value={isOff() ? '' : val()} placeholder={getAlertResourceTableMetricPlaceholder(isOff())} disabled={isOff()} @@ -605,7 +396,7 @@ export function ResourceTable(props: ResourceTableProps) { if (!props.setGlobalDefaults) return; props.setGlobalDefaults((prev) => ({ ...prev, - [metric]: getEnabledDefaultValue(metric), + [metric]: getAlertResourceEnabledDefault(metric), })); props.setHasUnsavedChanges?.(true); }} @@ -638,39 +429,11 @@ export function ResourceTable(props: ResourceTableProps) { {(resource) => { const isEditing = () => props.editingId() === resource.id; - const thresholds = () => - isEditing() ? props.editingThresholds() : (resource.thresholds ?? {}); - - // Helper logic duplicated/adapted for scope access - const displayValue = (metric: string): number => { - const parseNumeric = (value: unknown): number | undefined => { - if (value === undefined || value === null) return undefined; - if (typeof value === 'number') return value; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; - }; - const extract = (source: Record | undefined) => - parseNumeric(source?.[metric]); - const defaults = resource.defaults as Record | undefined; - - if (isEditing()) { - const edited = extract(thresholds() as Record); - if (edited !== undefined) return edited; - const fallback = extract(defaults); - return fallback !== undefined ? fallback : 0; - } - - const liveValue = extract( - resource.thresholds as Record | undefined, - ); - if (liveValue !== undefined) return liveValue; - const fallback = extract(defaults); - return fallback !== undefined ? fallback : 0; - }; - + const thresholds = () => getThresholds(resource, isEditing()); + const displayValue = (metric: string) => + getDisplayValue(resource, metric, isEditing()); const isOverridden = (metric: string) => - resource.thresholds?.[metric] !== undefined && - resource.thresholds?.[metric] !== null; + isAlertResourceMetricOverridden(resource, metric); return (
)} - +
{getAlertResourceTableEmptyState(props.emptyMessage)}
@@ -936,7 +677,7 @@ export function ResourceTable(props: ResourceTableProps) { {(column) => ( {column} @@ -993,8 +734,8 @@ export function ResourceTable(props: ResourceTableProps) { {(column) => { - const metric = normalizeMetricKey(column); - const bounds = metricBounds(metric); + const metric = normalizeAlertResourceMetricKey(column); + const bounds = getAlertResourceMetricBounds(metric); const val = () => props.globalDefaults?.[metric] ?? 0; const isOff = () => val() === -1; @@ -1005,7 +746,7 @@ export function ResourceTable(props: ResourceTableProps) { type="number" min={bounds.min} max={bounds.max} - step={metricStep(metric)} + step={getAlertResourceMetricStep(metric)} value={isOff() ? '' : val()} placeholder={getAlertResourceTableMetricPlaceholder(isOff())} disabled={isOff()} @@ -1036,7 +777,7 @@ export function ResourceTable(props: ResourceTableProps) { if (!props.setGlobalDefaults) return; props.setGlobalDefaults((prev) => ({ ...prev, - [metric]: getEnabledDefaultValue(metric), + [metric]: getAlertResourceEnabledDefault(metric), })); props.setHasUnsavedChanges?.(true); }} @@ -1189,9 +930,12 @@ export function ResourceTable(props: ResourceTableProps) { {(column) => { - const metric = normalizeMetricKey(column); + const metric = normalizeAlertResourceMetricKey(column); const typeDefaultDelay = props.globalDelaySeconds ?? 5; - const overrideDelay = metricDelayOverride(metric); + const overrideDelay = getAlertResourceMetricDelayOverride( + props.metricDelaySeconds, + metric, + ); return ( @@ -1264,52 +1008,11 @@ export function ResourceTable(props: ResourceTableProps) { {(resource) => { const isEditing = () => props.editingId() === resource.id; - const thresholds = (): Record => { - if (isEditing()) { - return props.editingThresholds(); - } - return resource.thresholds ?? {}; - }; - const displayValue = (metric: string): number => { - const parseNumeric = (value: unknown): number | undefined => { - if (value === undefined || value === null) return undefined; - if (typeof value === 'number') return value; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; - }; - - const extract = (source: Record | undefined) => - parseNumeric(source?.[metric]); - - const defaults = resource.defaults as - | Record - | undefined; - - if (isEditing()) { - const edited = extract(thresholds() as Record); - if (edited !== undefined) { - return edited; - } - const fallback = extract(defaults); - return fallback !== undefined ? fallback : 0; - } - - const liveValue = extract( - resource.thresholds as Record | undefined, - ); - if (liveValue !== undefined) { - return liveValue; - } - - const fallback = extract(defaults); - return fallback !== undefined ? fallback : 0; - }; - const isOverridden = (metric: string) => { - return ( - resource.thresholds?.[metric] !== undefined && - resource.thresholds?.[metric] !== null - ); - }; + const thresholds = () => getThresholds(resource, isEditing()); + const displayValue = (metric: string) => + getDisplayValue(resource, metric, isEditing()); + const isOverridden = (metric: string) => + isAlertResourceMetricOverridden(resource, metric); return ( {(column) => { - const metric = normalizeMetricKey(column); + const metric = normalizeAlertResourceMetricKey(column); const showMetric = () => - resourceSupportsMetric(resource.type, metric); - const bounds = metricBounds(metric); + alertResourceSupportsMetric(resource.type, metric); + const bounds = getAlertResourceMetricBounds(metric); const isDisabled = () => thresholds()?.[metric] === -1; const isSpecialToggle = metric === 'backup' || metric === 'snapshot'; @@ -1498,16 +1201,7 @@ export function ResourceTable(props: ResourceTableProps) { } const openMetricEditor = (e: MouseEvent) => { - e.stopPropagation(); - setActiveMetricInput({ resourceId: resource.id, metric }); - props.onEdit( - resource.id, - resource.thresholds ? { ...resource.thresholds } : {}, - resource.defaults ? { ...resource.defaults } : {}, - typeof resource.note === 'string' - ? resource.note - : undefined, - ); + startEditing(resource, metric, e); }; return ( @@ -1536,7 +1230,7 @@ export function ResourceTable(props: ResourceTableProps) { } >
- + {(() => { const isTemperatureMetric = metric === 'temperature' || @@ -1604,7 +1298,7 @@ export function ResourceTable(props: ResourceTableProps) { type="number" min={bounds.min} max={bounds.max} - step={metricStep(metric)} + step={getAlertResourceMetricStep(metric)} value={thresholds()?.[metric] ?? ''} placeholder={getAlertResourceTableMetricPlaceholder( isDisabled(), @@ -1628,25 +1322,18 @@ export function ResourceTable(props: ResourceTableProps) { onInput={(e) => { const raw = e.currentTarget.value; if (raw === '') { - props.setEditingThresholds({ - ...props.editingThresholds(), - [metric]: undefined, - }); + updateEditingThreshold(metric, undefined); return; } const val = parseFloat(raw); if (!Number.isNaN(val)) { - props.setEditingThresholds({ - ...props.editingThresholds(), - [metric]: val, - }); + updateEditingThreshold(metric, val); } }} onBlur={() => { if (props.editingId() === resource.id) { - props.onSaveEdit(resource.id); + saveEditing(resource.id); } - setActiveMetricInput(null); }} class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${ isDisabled() @@ -1734,10 +1421,7 @@ export function ResourceTable(props: ResourceTableProps) { fallback={