Extract alerts resource table owners

This commit is contained in:
rcourtman
2026-03-20 19:06:38 +00:00
parent b48b258e98
commit 0becfd4966
6 changed files with 597 additions and 519 deletions
@@ -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`,
@@ -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();
@@ -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<string, string> = {
'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<string, number | undefined>;
defaults?: Record<string, number | undefined>;
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<Set<string>>(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<string>(flattenResources().map((r) => r.id)));
} else {
setSelectedIds(new Set<string>());
}
};
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<string, string>([
['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) {
</div>
);
};
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) {
<div class="grid grid-cols-2 gap-2">
<For
each={props.columns.filter((c) => {
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) {
<For each={resources as Resource[]}>
{(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<string, unknown> | undefined) =>
parseNumeric(source?.[metric]);
const defaults = resource.defaults as Record<string, unknown> | undefined;
if (isEditing()) {
const edited = extract(thresholds() as Record<string, unknown>);
if (edited !== undefined) return edited;
const fallback = extract(defaults);
return fallback !== undefined ? fallback : 0;
}
const liveValue = extract(
resource.thresholds as Record<string, unknown> | 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 (
<Card
@@ -713,14 +476,7 @@ export function ResourceTable(props: ResourceTableProps) {
<Show when={!isEditing() && resource.type !== 'dockerHost'}>
<button
type="button"
onClick={() =>
props.onEdit(
resource.id,
resource.thresholds ? { ...resource.thresholds } : {},
resource.defaults ? { ...resource.defaults } : {},
resource.note,
)
}
onClick={() => startEditing(resource)}
class="p-1.5 bg-blue-50 dark:bg-blue-900 text-blue-600 rounded"
aria-label={`Edit thresholds for ${getResourceLabel(resource)}`}
>
@@ -742,10 +498,7 @@ export function ResourceTable(props: ResourceTableProps) {
<Show when={isEditing()}>
<button
type="button"
onClick={() => {
props.onCancelEdit();
setActiveMetricInput(null);
}}
onClick={cancelEditing}
class="p-1.5 bg-surface-hover text-slate-600 rounded"
aria-label="Cancel threshold edits"
>
@@ -765,10 +518,7 @@ export function ResourceTable(props: ResourceTableProps) {
</button>
<button
type="button"
onClick={() => {
props.onSaveEdit(resource.id);
setActiveMetricInput(null);
}}
onClick={() => saveEditing(resource.id)}
class="p-1.5 bg-green-50 dark:bg-green-900 text-green-600 rounded"
aria-label={`Save threshold edits for ${getResourceLabel(resource)}`}
>
@@ -821,12 +571,12 @@ export function ResourceTable(props: ResourceTableProps) {
<div class="grid grid-cols-2 gap-2 text-sm border-t pt-2">
<For each={props.columns}>
{(column) => {
const metric = normalizeMetricKey(column);
const metric = normalizeAlertResourceMetricKey(column);
// Check support
if (!resourceSupportsMetric(resource.type, metric)) return null;
if (!alertResourceSupportsMetric(resource.type, metric)) return null;
const isDisabled = () => thresholds()?.[metric] === -1;
const bounds = metricBounds(metric);
const bounds = getAlertResourceMetricBounds(metric);
return (
<div class="flex justify-between items-center p-1.5 bg-surface-alt rounded">
@@ -839,16 +589,7 @@ export function ResourceTable(props: ResourceTableProps) {
fallback={
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setActiveMetricInput({ resourceId: resource.id, metric });
props.onEdit(
resource.id,
resource.thresholds ? { ...resource.thresholds } : {},
resource.defaults ? { ...resource.defaults } : {},
resource.note,
);
}}
onClick={(e) => startEditing(resource, metric, e)}
class="font-mono text-xs font-medium cursor-pointer rounded px-1 -mx-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
aria-label={`Edit ${column} threshold for ${getResourceLabel(resource)}`}
>
@@ -891,7 +632,7 @@ export function ResourceTable(props: ResourceTableProps) {
</div>
)}
</For>
<Show when={!hasRows()}>
<Show when={hasRows() === false}>
<div class="text-center p-8 text-slate-500 text-sm italic bg-surface-alt rounded-md">
{getAlertResourceTableEmptyState(props.emptyMessage)}
</div>
@@ -936,7 +677,7 @@ export function ResourceTable(props: ResourceTableProps) {
{(column) => (
<TableHead
class="text-center whitespace-normal break-words"
title={getColumnHeaderTooltip(column)}
title={getAlertResourceColumnHeaderTooltip(column)}
>
{column}
</TableHead>
@@ -993,8 +734,8 @@ export function ResourceTable(props: ResourceTableProps) {
</TableCell>
<For each={props.columns}>
{(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) {
</TableCell>
<For each={props.columns}>
{(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 (
<TableCell class="p-1 px-2 text-center align-middle">
@@ -1264,52 +1008,11 @@ export function ResourceTable(props: ResourceTableProps) {
<For each={resources}>
{(resource) => {
const isEditing = () => props.editingId() === resource.id;
const thresholds = (): Record<string, number | undefined> => {
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<string, unknown> | undefined) =>
parseNumeric(source?.[metric]);
const defaults = resource.defaults as
| Record<string, unknown>
| undefined;
if (isEditing()) {
const edited = extract(thresholds() as Record<string, unknown>);
if (edited !== undefined) {
return edited;
}
const fallback = extract(defaults);
return fallback !== undefined ? fallback : 0;
}
const liveValue = extract(
resource.thresholds as Record<string, unknown> | 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 (
<TableRow
@@ -1459,10 +1162,10 @@ export function ResourceTable(props: ResourceTableProps) {
{/* Metric columns - dynamically rendered based on resource type */}
<For each={props.columns}>
{(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) {
}
>
<div class="flex w-full items-center gap-3">
<Show when={SLIDER_METRICS.has(metric)}>
<Show when={ALERT_RESOURCE_TABLE_SLIDER_METRICS.has(metric)}>
{(() => {
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={
<button
type="button"
onClick={() => {
props.onCancelEdit();
setActiveMetricInput(null);
}}
onClick={cancelEditing}
class="p-1 hover:text-muted"
title="Cancel editing"
aria-label="Cancel editing"
@@ -1761,16 +1445,7 @@ export function ResourceTable(props: ResourceTableProps) {
<Show when={resource.type !== 'dockerHost'}>
<button
type="button"
onClick={() =>
props.onEdit(
resource.id,
resource.thresholds ? { ...resource.thresholds } : {},
resource.defaults ? { ...resource.defaults } : {},
typeof resource.note === 'string'
? resource.note
: undefined,
)
}
onClick={() => startEditing(resource)}
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
title="Edit thresholds"
aria-label={`Edit thresholds for ${getResourceLabel(resource)}`}
@@ -1827,50 +1502,11 @@ export function ResourceTable(props: ResourceTableProps) {
<For each={props.resources}>
{(resource) => {
const isEditing = () => props.editingId() === resource.id;
const thresholds = (): Record<string, number | undefined> => {
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<string, unknown> | undefined) =>
parseNumeric(source?.[metric]);
const defaults = resource.defaults as Record<string, unknown> | undefined;
if (isEditing()) {
const edited = extract(thresholds() as Record<string, unknown>);
if (edited !== undefined) {
return edited;
}
const fallback = extract(defaults);
return fallback !== undefined ? fallback : 0;
}
const liveValue = extract(
resource.thresholds as Record<string, unknown> | 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 (
<TableRow
@@ -1992,7 +1628,7 @@ export function ResourceTable(props: ResourceTableProps) {
{/* Metric columns - dynamically rendered based on resource type */}
<For each={props.columns}>
{(column) => {
const metric = normalizeMetricKey(column);
const metric = normalizeAlertResourceMetricKey(column);
// Check if this metric applies to this resource type
const showMetric = () => {
@@ -2017,17 +1653,7 @@ export function ResourceTable(props: ResourceTableProps) {
const isDisabled = () => thresholds()?.[metric] === -1;
const openMetricEditor = (e: MouseEvent) => {
e.stopPropagation();
if (resource.editable === false) {
return;
}
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 (
@@ -2065,7 +1691,7 @@ export function ResourceTable(props: ResourceTableProps) {
? 100
: 10000
}
step={metricStep(metric)}
step={getAlertResourceMetricStep(metric)}
value={thresholds()?.[metric] ?? ''}
placeholder={getAlertResourceTableMetricPlaceholder(
isDisabled(),
@@ -2088,25 +1714,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()
@@ -2174,10 +1793,7 @@ export function ResourceTable(props: ResourceTableProps) {
fallback={
<button
type="button"
onClick={() => {
props.onCancelEdit();
setActiveMetricInput(null);
}}
onClick={cancelEditing}
class="p-1 hover:text-muted"
title="Cancel editing"
aria-label="Cancel editing"
@@ -2201,16 +1817,7 @@ export function ResourceTable(props: ResourceTableProps) {
<div class="flex items-center gap-1">
<button
type="button"
onClick={() =>
props.onEdit(
resource.id,
resource.thresholds ? { ...resource.thresholds } : {},
resource.defaults ? { ...resource.defaults } : {},
typeof resource.note === 'string'
? resource.note
: undefined,
)
}
onClick={() => startEditing(resource)}
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
title="Edit thresholds"
aria-label={`Edit thresholds for ${getResourceLabel(resource)}`}
@@ -2267,7 +1874,7 @@ export function ResourceTable(props: ResourceTableProps) {
</TableRow>
</Show>
</Show>
<Show when={!hasRows()}>
<Show when={hasRows() === false}>
<TableRow>
<TableCell
colspan={totalColumnCount()}
@@ -2294,7 +1901,7 @@ export function ResourceTable(props: ResourceTableProps) {
onClick={() => {
if (props.onBulkEdit) {
props.onBulkEdit(Array.from(selectedIds()));
setSelectedIds(new Set<string>());
clearSelectedIds();
}
}}
>
@@ -2303,7 +1910,7 @@ export function ResourceTable(props: ResourceTableProps) {
<button
type="button"
class="text-slate-400 hover:text-white bg-surface hover:bg-slate-700 rounded-full p-1.5 transition-colors focus:outline-none"
onClick={() => setSelectedIds(new Set<string>())}
onClick={clearSelectedIds}
aria-label={ALERT_BULK_EDIT_CLEAR_LABEL}
title={ALERT_BULK_EDIT_CLEAR_LABEL}
>
@@ -0,0 +1,299 @@
import type { Resource as UnifiedResource, ResourcePolicy } from '@/types/resource';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
const COLUMN_TOOLTIP_LOOKUP: Record<string, string> = {
'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.',
'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.',
};
export const ALERT_RESOURCE_TABLE_SLIDER_METRICS = new Set([
'cpu',
'memory',
'disk',
'temperature',
'diskTemperature',
]);
export type AlertResourceThresholdMap = Record<string, number | undefined>;
export interface AlertResourceTableResourceLike {
id: string;
name: string;
displayName?: string;
policy?: ResourcePolicy;
aiSafeSummary?: string;
rawName?: string;
type?: string;
thresholds?: AlertResourceThresholdMap;
defaults?: AlertResourceThresholdMap;
note?: string;
[key: string]: unknown;
}
export function flattenAlertResourceTableResources<T extends AlertResourceTableResourceLike>(
resources?: T[],
groupedResources?: Record<string, T[]>,
): T[] {
if (groupedResources) {
return Object.values(groupedResources).flat();
}
return resources ?? [];
}
export function hasAlertResourceTableRows<T extends AlertResourceTableResourceLike>(
resources?: T[],
groupedResources?: Record<string, T[]>,
globalDefaults?: AlertResourceThresholdMap,
): boolean {
if (flattenAlertResourceTableResources(resources, groupedResources).length > 0) {
return true;
}
if (groupedResources && Object.keys(groupedResources).length > 0) {
return true;
}
return Boolean(globalDefaults);
}
export function hasCustomAlertResourceGlobalDefaults(
globalDefaults?: AlertResourceThresholdMap,
factoryDefaults?: AlertResourceThresholdMap,
): boolean {
if (!globalDefaults || !factoryDefaults) {
return false;
}
return Object.keys(factoryDefaults).some((key) => {
const current = globalDefaults[key];
const factory = factoryDefaults[key];
return current !== undefined && current !== factory;
});
}
export function normalizeAlertResourceMetricKey(column: string): string {
const key = column.trim().toLowerCase();
const mapped = new Map<string, string>([
['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');
}
export function getAlertResourceMetricBounds(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 };
}
export function getAlertResourceMetricStep(metric: string): string | number {
if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) {
return 'any';
}
if (['warningSizeGiB', 'criticalSizeGiB'].includes(metric)) {
return 'any';
}
return 1;
}
export function getAlertResourceEnabledDefault(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;
}
export function getAlertResourceMetricDelayOverride(
metricDelaySeconds: Record<string, number> | undefined,
metric: string,
): number | undefined {
const normalized = metric.trim().toLowerCase();
const value = metricDelaySeconds?.[normalized] ?? metricDelaySeconds?.[metric];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined;
}
return value;
}
export function getAlertResourceColumnHeaderTooltip(column: string): string | undefined {
const normalized = column.trim().toLowerCase();
return COLUMN_TOOLTIP_LOOKUP[column] ?? COLUMN_TOOLTIP_LOOKUP[normalized];
}
export function alertResourceSupportsMetric(
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;
}
export function getAlertResourceLabel(resource: AlertResourceTableResourceLike): string {
return getPreferredResourceDisplayName(resource as unknown as UnifiedResource);
}
function parseAlertMetricNumber(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;
}
export function getAlertResourceMetricDisplayValue(
resource: AlertResourceTableResourceLike,
metric: string,
editingThresholds?: AlertResourceThresholdMap,
isEditing = false,
): number {
const extract = (source: Record<string, unknown> | undefined) => parseAlertMetricNumber(source?.[metric]);
const defaults = resource.defaults as Record<string, unknown> | undefined;
if (isEditing) {
const edited = extract(editingThresholds as Record<string, unknown> | undefined);
if (edited !== undefined) {
return edited;
}
const fallback = extract(defaults);
return fallback !== undefined ? fallback : 0;
}
const liveValue = extract(resource.thresholds as Record<string, unknown> | undefined);
if (liveValue !== undefined) {
return liveValue;
}
const fallback = extract(defaults);
return fallback !== undefined ? fallback : 0;
}
export function isAlertResourceMetricOverridden(
resource: AlertResourceTableResourceLike,
metric: string,
): boolean {
return resource.thresholds?.[metric] !== undefined && resource.thresholds?.[metric] !== null;
}
export function buildAlertResourceEditPayload(resource: AlertResourceTableResourceLike) {
return {
thresholds: resource.thresholds ? { ...resource.thresholds } : {},
defaults: resource.defaults ? { ...resource.defaults } : {},
note: typeof resource.note === 'string' ? resource.note : undefined,
};
}
@@ -0,0 +1,80 @@
import { createMemo, createSignal } from 'solid-js';
import {
flattenAlertResourceTableResources,
hasAlertResourceTableRows,
hasCustomAlertResourceGlobalDefaults,
type AlertResourceTableResourceLike,
type AlertResourceThresholdMap,
} from './alertResourceTableModel';
interface UseAlertResourceTableStateOptions<T extends AlertResourceTableResourceLike> {
resources?: T[];
groupedResources?: Record<string, T[]>;
globalDefaults?: AlertResourceThresholdMap;
factoryDefaults?: AlertResourceThresholdMap;
}
export function useAlertResourceTableState<T extends AlertResourceTableResourceLike>(
options: UseAlertResourceTableStateOptions<T>,
) {
const [activeMetricInput, setActiveMetricInput] = createSignal<{
resourceId: string;
metric: string;
} | null>(null);
const [showDelayRow, setShowDelayRow] = createSignal(false);
const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());
const flatResources = createMemo(() =>
flattenAlertResourceTableResources(options.resources, options.groupedResources),
);
const hasRows = createMemo(() =>
hasAlertResourceTableRows(options.resources, options.groupedResources, options.globalDefaults),
);
const hasCustomGlobalDefaults = createMemo(() =>
hasCustomAlertResourceGlobalDefaults(options.globalDefaults, options.factoryDefaults),
);
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<string>(flatResources().map((resource) => resource.id)));
return;
}
setSelectedIds(new Set<string>());
};
const allSelected = createMemo(() => {
const total = flatResources().length;
return total > 0 && selectedIds().size === total;
});
const someSelected = createMemo(() => {
return selectedIds().size > 0 && selectedIds().size < flatResources().length;
});
const clearSelectedIds = () => setSelectedIds(new Set<string>());
return {
activeMetricInput,
setActiveMetricInput,
showDelayRow,
setShowDelayRow,
selectedIds,
setSelectedIds,
hasRows,
hasCustomGlobalDefaults,
toggleSelection,
toggleAll,
allSelected,
someSelected,
clearSelectedIds,
};
}
@@ -241,7 +241,9 @@ import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThre
import alertIncidentPresentationSource from '@/utils/alertIncidentPresentation.ts?raw';
import alertHistoryPresentationSource from '@/utils/alertHistoryPresentation.ts?raw';
import bulkEditDialogSource from '@/components/Alerts/BulkEditDialog.tsx?raw';
import alertResourceTableModelSource from '@/components/Alerts/alertResourceTableModel.ts?raw';
import alertResourceTableSource from '@/components/Alerts/ResourceTable.tsx?raw';
import alertResourceTableStateSource from '@/components/Alerts/useAlertResourceTableState.ts?raw';
import emailProviderSelectSource from '@/components/Alerts/EmailProviderSelect.tsx?raw';
import webhookConfigSource from '@/components/Alerts/WebhookConfig.tsx?raw';
import alertActivationPresentationSource from '@/utils/alertActivationPresentation.ts?raw';
@@ -3073,6 +3075,15 @@ describe('frontend resource type boundaries', () => {
});
it('keeps alert resource table vocabulary in a shared presentation utility', () => {
expect(alertResourceTableSource).toContain('useAlertResourceTableState');
expect(alertResourceTableSource).toContain('normalizeAlertResourceMetricKey');
expect(alertResourceTableSource).not.toContain('const flattenResources = (): Resource[] => {');
expect(alertResourceTableSource).not.toContain(
'const normalizeMetricKey = (column: string): string => {',
);
expect(alertResourceTableSource).not.toContain(
'const metricBounds = (metric: string): { min: number; max: number } => {',
);
expect(alertResourceTableSource).toContain('getAlertResourceTableEmptyState');
expect(alertResourceTableSource).toContain('getAlertResourceTableNoResultsState');
expect(alertResourceTableSource).toContain('getAlertResourceTableCustomBadgeLabel');
@@ -3120,6 +3131,18 @@ describe('frontend resource type boundaries', () => {
expect(alertResourceTablePresentationSource).toContain(
'export function getAlertResourceTableEditMetricTitle',
);
expect(alertResourceTableStateSource).toContain('export function useAlertResourceTableState');
expect(alertResourceTableStateSource).toContain('toggleAll');
expect(alertResourceTableStateSource).toContain('clearSelectedIds');
expect(alertResourceTableModelSource).toContain(
'export function normalizeAlertResourceMetricKey',
);
expect(alertResourceTableModelSource).toContain(
'export function getAlertResourceMetricDisplayValue',
);
expect(alertResourceTableModelSource).toContain(
'export function buildAlertResourceEditPayload',
);
});
it('keeps alert email provider vocabulary and placeholders in a shared presentation utility', () => {