Extract alert resource table row owner

This commit is contained in:
rcourtman
2026-03-22 11:16:14 +00:00
parent 7fa55dc129
commit 67b723a28d
6 changed files with 681 additions and 859 deletions
@@ -124,7 +124,9 @@ controller logic should land in those hooks 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
`frontend-modern/src/components/Alerts/alertResourceTableModel.ts`, render-heavy
desktop row ownership lives in
`frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx`, and
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
@@ -0,0 +1,613 @@
import { For, Show } from 'solid-js';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import { TogglePrimitive } from '@/components/shared/Toggle';
import { StatusBadge } from '@/components/shared/StatusBadge';
import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider';
import { TableCell, TableRow } from '@/components/shared/Table';
import {
getAlertResourceTableCustomBadgeLabel,
getAlertResourceTableEditMetricTitle,
getAlertResourceTableMetricInputTitle,
getAlertResourceTableMetricPlaceholder,
getAlertResourceTableOfflineStateOrder,
getAlertResourceTableOfflineStatePresentation,
getAlertResourceTableOverrideNotePlaceholder,
getAlertResourceTableRevertToDefaultsLabel,
} from '@/utils/alertResourceTablePresentation';
import {
ALERT_RESOURCE_TABLE_SLIDER_METRICS,
alertResourceSupportsMetric,
getAlertResourceLabel,
getAlertResourceMetricBounds,
getAlertResourceMetricDisplayValue,
getAlertResourceMetricStep,
isAlertResourceMetricOverridden,
normalizeAlertResourceMetricKey,
type AlertResourceThresholdMap,
} from './alertResourceTableModel';
import type { Alert } from '@/types/api';
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
type OfflineState = 'off' | 'warning' | 'critical';
interface AlertResourceTableRowProps {
resource: Resource;
columns: string[];
activeAlerts?: Record<string, Alert>;
editingId: () => string | null;
editingThresholds: () => Record<string, number | undefined>;
setEditingThresholds: (value: Record<string, number | undefined>) => void;
formatMetricValue: (metric: string, value: number | undefined) => string;
hasActiveAlert: (resourceId: string, metric: string) => boolean;
onEdit: (
resourceId: string,
thresholds: Record<string, number | undefined>,
defaults: Record<string, number | undefined>,
note: string | undefined,
) => void;
onSaveEdit: (resourceId: string) => void;
onCancelEdit: () => void;
onRemoveOverride: (resourceId: string) => void;
onToggleDisabled?: (resourceId: string) => void;
onToggleNodeConnectivity?: (nodeId: string) => void;
showOfflineAlertsColumn?: boolean;
globalOfflineSeverity?: 'warning' | 'critical';
onSetOfflineState?: (resourceId: string, state: OfflineState) => void;
onToggleBackup?: (resourceId: string, forceState?: boolean) => void;
onToggleSnapshot?: (resourceId: string, forceState?: boolean) => void;
globalDisableFlag?: () => boolean;
globalDisableOfflineFlag?: () => boolean;
editingNote: () => string;
setEditingNote: (value: string) => void;
activeMetricInput: () => { resourceId: string; metric: string } | null;
setActiveMetricInput: (value: { resourceId: string; metric: string } | null) => void;
showBulkSelection?: boolean;
selected?: boolean;
onToggleSelection?: (checked: boolean) => void;
}
export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
const isEditing = () => props.editingId() === props.resource.id;
const thresholds = () => (isEditing() ? props.editingThresholds() : (props.resource.thresholds ?? {}));
const resourceLabel = () => getAlertResourceLabel(props.resource);
const displayValue = (metric: string) =>
getAlertResourceMetricDisplayValue(
props.resource,
metric,
props.editingThresholds(),
isEditing(),
);
const isOverridden = (metric: string) =>
isAlertResourceMetricOverridden(props.resource, metric);
const getThresholds = (): AlertResourceThresholdMap => thresholds();
const startEditing = (metric?: string, event?: MouseEvent) => {
event?.stopPropagation();
if (props.resource.editable === false) {
return;
}
if (metric) {
props.setActiveMetricInput({ resourceId: props.resource.id, metric });
}
props.onEdit(
props.resource.id,
props.resource.thresholds ? { ...props.resource.thresholds } : {},
props.resource.defaults ? { ...props.resource.defaults } : {},
typeof props.resource.note === 'string' ? props.resource.note : undefined,
);
};
const cancelEditing = () => {
props.onCancelEdit();
props.setActiveMetricInput(null);
};
const saveEditing = () => {
props.onSaveEdit(props.resource.id);
props.setActiveMetricInput(null);
};
const updateEditingThreshold = (metric: string, value: number | undefined) => {
props.setEditingThresholds({
...props.editingThresholds(),
[metric]: value,
});
};
const MetricValueWithHeat = (metricProps: {
resourceId: string;
metric: string;
value: number;
isOverridden: boolean;
}) => {
const isDisabledMetric = metricProps.value <= 0;
const displayText = isDisabledMetric
? 'Off'
: props.formatMetricValue(metricProps.metric, metricProps.value);
return (
<div
class={`flex items-center justify-center gap-1 ${isDisabledMetric ? 'opacity-60' : ''}`.trim()}
title={isDisabledMetric ? 'Disabled (no alerts for this metric)' : ''}
>
<span
class={`text-sm ${
isDisabledMetric
? 'text-muted italic'
: metricProps.isOverridden
? 'text-base-content font-bold'
: 'text-base-content'
}`}
>
{displayText}
</span>
<Show when={props.hasActiveAlert(metricProps.resourceId, metricProps.metric)}>
<div class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" title="Active alert" />
</Show>
</div>
);
};
const renderToggleBadge = (config: {
isEnabled: boolean;
disabled?: boolean;
size?: 'sm' | 'md';
onToggle?: () => void;
labelEnabled?: string;
labelDisabled?: string;
titleEnabled?: string;
titleDisabled?: string;
titleWhenDisabled?: string;
}) => <StatusBadge {...config} />;
const nextOfflineState = (state: OfflineState): OfflineState => {
const order = getAlertResourceTableOfflineStateOrder();
const idx = order.indexOf(state);
return order[(idx + 1) % order.length];
};
const renderOfflineStateButton = (
state: OfflineState,
disabled: boolean,
onToggle: () => void,
) => {
const config = getAlertResourceTableOfflineStatePresentation(state);
return (
<button
type="button"
class={`inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium rounded transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 ${config.className} ${disabled ? 'opacity-60 cursor-not-allowed pointer-events-none' : ''}`.trim()}
disabled={disabled}
onClick={() => {
if (disabled) return;
onToggle();
}}
title={config.title}
>
{config.label}
</button>
);
};
return (
<TableRow
class={`transition-colors ${props.resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''} ${props.resource.hasOverride ? 'bg-sky-50/50 hover:bg-sky-50/80 dark:bg-sky-900/20 dark:hover:bg-sky-900/30 border-l-[3px] border-l-sky-400 dark:border-l-sky-500' : 'hover:bg-surface-hover'}`}
>
<Show when={props.showBulkSelection}>
<TableCell class="p-1 px-2 text-center align-middle border-r border-border">
<input
type="checkbox"
checked={props.selected}
onChange={(e) => props.onToggleSelection?.(e.currentTarget.checked)}
class="rounded border-border text-sky-600 focus:ring-sky-500 transition-shadow cursor-pointer"
aria-label={`Select ${resourceLabel()}`}
/>
</TableCell>
</Show>
<TableCell class="p-1 px-2 text-center align-middle">
<Show when={props.onToggleDisabled}>
{(() => {
const globallyDisabled = props.globalDisableFlag?.() ?? false;
const isChecked = !globallyDisabled && !props.resource.disabled;
return (
<div class="flex items-center justify-center">
<TogglePrimitive
size="sm"
checked={isChecked}
disabled={globallyDisabled}
onToggle={() => !globallyDisabled && props.onToggleDisabled?.(props.resource.id)}
class="my-[1px]"
title={
globallyDisabled
? 'Alerts disabled globally'
: props.resource.disabled
? 'Click to enable alerts'
: 'Click to disable alerts'
}
ariaLabel={
isChecked
? 'Alerts enabled for this resource'
: 'Alerts disabled for this resource'
}
/>
</div>
);
})()}
</Show>
</TableCell>
<TableCell class="p-1 px-2">
<div class="flex items-center gap-2 min-w-0">
<Show
when={props.resource.type === 'agent'}
fallback={
<span
class={`text-sm font-medium truncate flex-nowrap ${props.resource.disabled ? 'text-slate-500 ' : 'text-base-content'}`}
>
{resourceLabel()}
</span>
}
>
<div class="flex items-center gap-3 min-w-0" title={props.resource.status || undefined}>
<Show
when={props.resource.host}
fallback={
<span
class={`text-sm font-medium truncate flex-nowrap ${props.resource.disabled ? 'text-slate-500 ' : 'text-base-content'}`}
>
{resourceLabel()}
</span>
}
>
{(host) => (
<a
href={host() as string}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${
props.resource.disabled
? 'text-slate-500 '
: 'text-base-content hover:text-sky-600 dark:hover:text-sky-400'
}`}
title={`Open ${resourceLabel()} web interface`}
>
{resourceLabel()}
</a>
)}
</Show>
<Show when={props.resource.clusterName}>
<span class="rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{props.resource.clusterName}
</span>
</Show>
</div>
</Show>
<Show when={props.resource.type === 'storage' && props.resource.node}>
<span class="rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
{props.resource.node}
</span>
</Show>
<Show when={props.resource.subtitle}>
<span class="text-xs text-muted">{props.resource.subtitle as string}</span>
</Show>
<Show when={props.resource.hasOverride || props.resource.disableConnectivity}>
<span class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
{getAlertResourceTableCustomBadgeLabel()}
</span>
</Show>
</div>
<Show when={isEditing()}>
<div class="mt-2 w-full">
<label class="sr-only" for={`note-${props.resource.id}`}>
Override note
</label>
<textarea
id={`note-${props.resource.id}`}
class="w-full rounded border border-border bg-surface px-2 py-1 text-xs text-base-content focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
rows={2}
placeholder={getAlertResourceTableOverrideNotePlaceholder()}
value={props.editingNote()}
onInput={(e) => props.setEditingNote(e.currentTarget.value)}
/>
</div>
</Show>
<Show when={!isEditing() && props.resource.note}>
<p class="mt-2 text-xs italic text-muted break-words">{props.resource.note as string}</p>
</Show>
</TableCell>
<For each={props.columns}>
{(column) => {
const metric = normalizeAlertResourceMetricKey(column);
const bounds = getAlertResourceMetricBounds(metric);
const isDisabled = () => getThresholds()?.[metric] === -1;
const isSpecialToggle = metric === 'backup' || metric === 'snapshot';
if (isSpecialToggle) {
const config = metric === 'backup' ? props.resource.backup : props.resource.snapshot;
const isEnabled = config?.enabled ?? true;
const onToggle = metric === 'backup' ? props.onToggleBackup : props.onToggleSnapshot;
const titlePrefix = metric === 'backup' ? 'Backup' : 'Snapshot';
return (
<TableCell class="p-1 px-2 text-center align-middle">
<Show
when={onToggle}
fallback={<span class="text-sm text-slate-400">-</span>}
>
<div class="flex items-center justify-center">
<StatusBadge
isEnabled={isEnabled}
onToggle={() => onToggle?.(props.resource.id)}
titleEnabled={`${titlePrefix} alerts enabled. Click to disable for this resource.`}
titleDisabled={`${titlePrefix} alerts disabled. Click to enable for this resource.`}
/>
</div>
</Show>
</TableCell>
);
}
const openMetricEditor = (e: MouseEvent) => {
startEditing(metric, e);
};
return (
<TableCell class="p-1 px-2 text-center align-middle">
<Show
when={alertResourceSupportsMetric(props.resource.type, metric)}
fallback={<span class="text-sm text-muted">-</span>}
>
<Show
when={isEditing()}
fallback={
<div
onClick={openMetricEditor}
class="cursor-pointer hover:bg-surface-hover rounded px-1 py-0.5 transition-colors"
title={getAlertResourceTableEditMetricTitle()}
>
<MetricValueWithHeat
resourceId={props.resource.id}
metric={metric}
value={displayValue(metric)}
isOverridden={isOverridden(metric)}
/>
</div>
}
>
<div class="flex w-full items-center justify-center gap-3">
<Show when={ALERT_RESOURCE_TABLE_SLIDER_METRICS.has(metric)}>
{(() => {
const isTemperatureMetric =
metric === 'temperature' || metric === 'diskTemperature';
const sliderMin = Math.max(0, bounds.min);
const sliderMax = isTemperatureMetric
? Math.max(sliderMin, bounds.max > 0 ? bounds.max : 120)
: bounds.max;
const defaultSliderValue = () => {
if (metric === 'disk') return 90;
if (metric === 'memory') return 85;
if (metric === 'temperature') return 80;
if (metric === 'diskTemperature') return 55;
return 80;
};
const currentSliderValue = () => {
const editingVal = props.editingThresholds()?.[metric];
if (typeof editingVal === 'number' && editingVal >= 0) {
return Math.round(editingVal);
}
const currentDisplayValue = displayValue(metric);
if (
typeof currentDisplayValue === 'number' &&
currentDisplayValue >= 0
) {
return Math.round(currentDisplayValue);
}
return defaultSliderValue();
};
return (
<div class="w-36">
<ThresholdSlider
value={Math.max(
sliderMin,
Math.min(sliderMax, currentSliderValue()),
)}
onChange={(val) => {
props.setEditingThresholds({
...props.editingThresholds(),
[metric]: val,
});
}}
type={
isTemperatureMetric
? 'temperature'
: (metric as 'cpu' | 'memory' | 'disk')
}
min={sliderMin}
max={sliderMax}
disabled={isDisabled()}
/>
</div>
);
})()}
</Show>
<div class="flex items-center justify-center">
<input
type="number"
min={bounds.min}
max={bounds.max}
step={getAlertResourceMetricStep(metric)}
value={getThresholds()?.[metric] ?? ''}
placeholder={getAlertResourceTableMetricPlaceholder(isDisabled())}
title={getAlertResourceTableMetricInputTitle(isDisabled())}
ref={(el) => {
if (
isEditing() &&
props.activeMetricInput()?.resourceId === props.resource.id &&
props.activeMetricInput()?.metric === metric
) {
queueMicrotask(() => {
el.focus();
el.select();
});
}
}}
onInput={(e) => {
const raw = e.currentTarget.value;
if (raw === '') {
updateEditingThreshold(metric, undefined);
return;
}
const val = parseFloat(raw);
if (!Number.isNaN(val)) {
updateEditingThreshold(metric, val);
}
}}
onBlur={() => {
if (props.editingId() === props.resource.id) {
saveEditing();
}
}}
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${
isDisabled()
? 'bg-surface-alt text-slate-400 border-border'
: ' text-base-content border-border'
}`}
/>
</div>
</div>
</Show>
</Show>
</TableCell>
);
}}
</For>
<Show when={props.showOfflineAlertsColumn}>
<TableCell class="p-1 px-2 text-center align-middle">
{(() => {
const disabledGlobally = props.globalDisableFlag?.() ?? false;
const supportsTriState =
typeof props.onSetOfflineState === 'function' &&
(props.resource.type === 'guest' || props.resource.type === 'dockerContainer');
if (supportsTriState) {
const defaultDisabled = props.globalDisableOfflineFlag?.() ?? false;
const defaultSeverity = props.globalOfflineSeverity ?? 'warning';
let state: OfflineState;
if (props.resource.disableConnectivity) {
state = 'off';
} else if (props.resource.poweredOffSeverity) {
state = props.resource.poweredOffSeverity;
} else if (defaultDisabled) {
state = 'off';
} else {
state = defaultSeverity === 'critical' ? 'critical' : 'warning';
}
return renderOfflineStateButton(state, disabledGlobally, () => {
if (disabledGlobally) return;
const next = nextOfflineState(state);
props.onSetOfflineState?.(props.resource.id, next);
});
}
if (!props.onToggleNodeConnectivity) {
return <span class="text-sm text-slate-400">-</span>;
}
const globalOfflineDisabled = props.globalDisableOfflineFlag?.() ?? false;
return renderToggleBadge({
isEnabled: !globalOfflineDisabled && !props.resource.disableConnectivity,
disabled: disabledGlobally,
onToggle: () => {
if (disabledGlobally) return;
props.onToggleNodeConnectivity?.(props.resource.id);
},
titleEnabled:
'Offline alerts enabled. Click to disable for this resource.',
titleDisabled:
'Offline alerts disabled. Click to enable for this resource.',
titleWhenDisabled: 'Offline alerts controlled globally',
});
})()}
</TableCell>
</Show>
<TableCell class="p-1 px-2">
<div class="flex items-center justify-center gap-1">
<Show
when={!isEditing()}
fallback={
<button
type="button"
onClick={cancelEditing}
class="p-1 hover:text-muted"
title="Cancel editing"
aria-label="Cancel editing"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
}
>
{(() => {
const showEdit =
props.resource.type !== 'dockerHost' && props.resource.editable !== false;
const showRevert =
props.resource.hasOverride ||
((props.resource.type === 'agent' || props.resource.type === 'dockerHost') &&
props.resource.disableConnectivity);
return (
<Show when={showEdit || showRevert} fallback={<span class="text-xs text-muted">—</span>}>
<Show when={showEdit}>
<button
type="button"
onClick={() => startEditing()}
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 ${resourceLabel()}`}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
</Show>
<Show when={showRevert}>
<button
type="button"
onClick={() => props.onRemoveOverride(props.resource.id)}
class="p-1 hover:text-base-content transition-colors"
title={getAlertResourceTableRevertToDefaultsLabel()}
aria-label={`Revert to defaults for ${resourceLabel()}`}
>
<RotateCcw class="w-4 h-4" />
</button>
</Show>
</Show>
);
})()}
</Show>
</div>
</TableCell>
</TableRow>
);
}
@@ -2,9 +2,11 @@ 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 alertResourceTableRowSource from '@/components/Alerts/AlertResourceTableRow.tsx?raw';
import alertResourceTableModelSource from '@/components/Alerts/alertResourceTableModel.ts?raw';
import alertResourceTableStateSource from '@/components/Alerts/useAlertResourceTableState.ts?raw';
import {
alertResourceSupportsMetric,
buildAlertResourceEditPayload,
getAlertResourceMetricDisplayValue,
normalizeAlertResourceMetricKey,
@@ -269,10 +271,16 @@ describe('ResourceTable', () => {
describe('table ownership model', () => {
it('keeps table state and metric rules in dedicated owners', () => {
expect(resourceTableSource).toContain('useAlertResourceTableState');
expect(resourceTableSource).toContain('AlertResourceTableRow');
expect(resourceTableSource).not.toContain('const flattenResources = (): Resource[] => {');
expect(resourceTableSource).not.toContain(
'const normalizeMetricKey = (column: string): string => {',
);
expect(resourceTableSource).not.toContain(
"if (resource.type === 'agent' && ['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(",
);
expect(alertResourceTableRowSource).toContain('export function AlertResourceTableRow');
expect(alertResourceTableRowSource).toContain('alertResourceSupportsMetric');
expect(alertResourceTableStateSource).toContain('export function useAlertResourceTableState');
expect(alertResourceTableModelSource).toContain(
'export function normalizeAlertResourceMetricKey',
@@ -280,10 +288,15 @@ describe('ResourceTable', () => {
expect(alertResourceTableModelSource).toContain(
'export function getAlertResourceMetricDisplayValue',
);
expect(alertResourceTableModelSource).toContain(
'export function alertResourceSupportsMetric',
);
});
it('resolves metric values and edit payloads through the shared model', () => {
expect(normalizeAlertResourceMetricKey('CPU %')).toBe('cpu');
expect(alertResourceSupportsMetric('agent', 'networkIn')).toBe(false);
expect(alertResourceSupportsMetric('pbs', 'disk')).toBe(false);
expect(
getAlertResourceMetricDisplayValue(
makeResource({
@@ -13,13 +13,11 @@ import {
TableCell,
} from '@/components/shared/Table';
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 {
getAlertResourceTableAlertDelayLabel,
getAlertResourceTableCustomBadgeLabel,
getAlertResourceTableEditMetricTitle,
getAlertResourceTableEditNotePlaceholder,
getAlertResourceTableEmptyState,
getAlertResourceTableMetricInputTitle,
@@ -27,7 +25,6 @@ import {
getAlertResourceTableNoResultsState,
getAlertResourceTableOfflineStateOrder,
getAlertResourceTableOfflineStatePresentation,
getAlertResourceTableOverrideNotePlaceholder,
getAlertResourceTableResetFactoryDefaultsLabel,
getAlertResourceTableRevertToDefaultsLabel,
} from '@/utils/alertResourceTablePresentation';
@@ -36,7 +33,6 @@ import {
getAlertBulkEditOpenLabel,
} from '@/utils/alertBulkEditPresentation';
import {
ALERT_RESOURCE_TABLE_SLIDER_METRICS,
alertResourceSupportsMetric,
buildAlertResourceEditPayload,
getAlertResourceColumnHeaderTooltip,
@@ -52,6 +48,7 @@ import {
} from './alertResourceTableModel';
import { useAlertResourceTableState } from './useAlertResourceTableState';
import type { GroupHeaderMeta, Resource } from '@/features/alerts/thresholds/tableTypes';
import { AlertResourceTableRow } from './AlertResourceTableRow';
export type { GroupHeaderMeta, Resource } from '@/features/alerts/thresholds/tableTypes';
@@ -195,13 +192,6 @@ export function ResourceTable(props: ResourceTableProps) {
props.onSaveEdit(resourceId);
setActiveMetricInput(null);
};
const updateEditingThreshold = (metric: string, value: number | undefined) => {
props.setEditingThresholds({
...props.editingThresholds(),
[metric]: value,
});
};
const MetricValueWithHeat = (metricProps: {
resourceId: string;
metric: string;
@@ -276,6 +266,38 @@ export function ResourceTable(props: ResourceTableProps) {
);
};
const renderDesktopRow = (resource: Resource) => (
<AlertResourceTableRow
resource={resource}
columns={props.columns}
editingId={props.editingId}
editingThresholds={props.editingThresholds}
setEditingThresholds={props.setEditingThresholds}
formatMetricValue={props.formatMetricValue}
hasActiveAlert={props.hasActiveAlert}
onEdit={props.onEdit}
onSaveEdit={props.onSaveEdit}
onCancelEdit={props.onCancelEdit}
onRemoveOverride={props.onRemoveOverride}
onToggleDisabled={props.onToggleDisabled}
onToggleNodeConnectivity={props.onToggleNodeConnectivity}
showOfflineAlertsColumn={props.showOfflineAlertsColumn}
globalOfflineSeverity={props.globalOfflineSeverity}
onSetOfflineState={props.onSetOfflineState}
onToggleBackup={props.onToggleBackup}
onToggleSnapshot={props.onToggleSnapshot}
globalDisableFlag={props.globalDisableFlag}
globalDisableOfflineFlag={props.globalDisableOfflineFlag}
editingNote={props.editingNote}
setEditingNote={props.setEditingNote}
activeMetricInput={activeMetricInput}
setActiveMetricInput={setActiveMetricInput}
showBulkSelection={Boolean(props.onBulkEdit)}
selected={selectedIds().has(resource.id)}
onToggleSelection={(checked) => toggleSelection(resource.id, checked)}
/>
);
const renderMobileView = () => (
<div class="space-y-4">
{/* Global Defaults - Mobile Card */}
@@ -959,489 +981,7 @@ export function ResourceTable(props: ResourceTableProps) {
</TableRow>
{/* Resources in this group */}
<For each={resources}>
{(resource) => {
const isEditing = () => props.editingId() === resource.id;
const thresholds = () => getThresholds(resource, isEditing());
const displayValue = (metric: string) =>
getDisplayValue(resource, metric, isEditing());
const isOverridden = (metric: string) =>
isAlertResourceMetricOverridden(resource, metric);
return (
<TableRow
class={`transition-colors ${resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''} ${resource.hasOverride ? 'bg-sky-50/50 hover:bg-sky-50/80 dark:bg-sky-900/20 dark:hover:bg-sky-900/30 border-l-[3px] border-l-sky-400 dark:border-l-sky-500' : 'hover:bg-surface-hover'}`}
>
{/* Bulk Edit Checkbox column */}
<Show when={props.onBulkEdit}>
<TableCell class="p-1 px-2 text-center align-middle border-r border-border">
<input
type="checkbox"
checked={selectedIds().has(resource.id)}
onChange={(e) =>
toggleSelection(resource.id, e.currentTarget.checked)
}
class="rounded border-border text-sky-600 focus:ring-sky-500 transition-shadow cursor-pointer"
aria-label={`Select ${getResourceLabel(resource)}`}
/>
</TableCell>
</Show>
{/* Alert toggle column */}
<TableCell class="p-1 px-2 text-center align-middle">
<Show when={props.onToggleDisabled}>
{(() => {
const globallyDisabled = props.globalDisableFlag?.() ?? false;
const isChecked = !globallyDisabled && !resource.disabled;
return (
<div class="flex items-center justify-center">
<TogglePrimitive
size="sm"
checked={isChecked}
disabled={globallyDisabled}
onToggle={() =>
!globallyDisabled &&
props.onToggleDisabled?.(resource.id)
}
class="my-[1px]"
title={
globallyDisabled
? 'Alerts disabled globally'
: resource.disabled
? 'Click to enable alerts'
: 'Click to disable alerts'
}
ariaLabel={
isChecked
? 'Alerts enabled for this resource'
: 'Alerts disabled for this resource'
}
/>
</div>
);
})()}
</Show>
</TableCell>
<TableCell class="p-1 px-2">
<div class="flex items-center gap-2 min-w-0">
<Show
when={resource.type === 'agent'}
fallback={
<span
class={`text-sm font-medium truncate flex-nowrap ${resource.disabled ? 'text-slate-500 ' : 'text-base-content'}`}
>
{getResourceLabel(resource)}
</span>
}
>
<div
class="flex items-center gap-3 min-w-0"
title={resource.status || undefined}
>
<Show
when={resource.host}
fallback={
<span
class={`text-sm font-medium truncate flex-nowrap ${resource.disabled ? 'text-slate-500 ' : 'text-base-content'}`}
>
{getResourceLabel(resource)}
</span>
}
>
{(host) => (
<a
href={host() as string}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${
resource.disabled
? 'text-slate-500 '
: 'text-base-content hover:text-sky-600 dark:hover:text-sky-400'
}`}
title={`Open ${getResourceLabel(resource)} web interface`}
>
{getResourceLabel(resource)}
</a>
)}
</Show>
<Show when={resource.clusterName}>
<span class="rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{resource.clusterName}
</span>
</Show>
<Show when={resource.type === 'storage' && resource.node}>
<span class="rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
{resource.node}
</span>
</Show>
</div>
</Show>
<Show when={resource.subtitle}>
<span class="text-xs text-muted">
{resource.subtitle as string}
</span>
</Show>
<Show
when={resource.hasOverride || resource.disableConnectivity}
>
<span class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
{getAlertResourceTableCustomBadgeLabel()}
</span>
</Show>
<Show when={isEditing()}>
<div class="mt-2 w-full">
<label class="sr-only" for={`note-${resource.id}`}>
Override note
</label>
<textarea
id={`note-${resource.id}`}
class="w-full rounded border border-border bg-surface px-2 py-1 text-xs text-base-content focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
rows={2}
placeholder={getAlertResourceTableOverrideNotePlaceholder()}
value={props.editingNote()}
onInput={(e) =>
props.setEditingNote(e.currentTarget.value)
}
/>
</div>
</Show>
<Show when={!isEditing() && resource.note}>
<p class="mt-2 text-xs italic text-muted break-words">
{resource.note as string}
</p>
</Show>
</div>
</TableCell>
{/* Metric columns - dynamically rendered based on resource type */}
<For each={props.columns}>
{(column) => {
const metric = normalizeAlertResourceMetricKey(column);
const showMetric = () =>
alertResourceSupportsMetric(resource.type, metric);
const bounds = getAlertResourceMetricBounds(metric);
const isDisabled = () => thresholds()?.[metric] === -1;
const isSpecialToggle =
metric === 'backup' || metric === 'snapshot';
if (isSpecialToggle) {
const config =
metric === 'backup' ? resource.backup : resource.snapshot;
const isEnabled = config?.enabled ?? true;
const onToggle =
metric === 'backup'
? props.onToggleBackup
: props.onToggleSnapshot;
const titlePrefix =
metric === 'backup' ? 'Backup' : 'Snapshot';
return (
<TableCell class="p-1 px-2 text-center align-middle">
<Show
when={onToggle}
fallback={<span class="text-sm text-slate-400">-</span>}
>
<div class="flex items-center justify-center">
<StatusBadge
isEnabled={isEnabled}
onToggle={() => onToggle?.(resource.id)}
titleEnabled={`${titlePrefix} alerts enabled. Click to disable for this resource.`}
titleDisabled={`${titlePrefix} alerts disabled. Click to enable for this resource.`}
/>
</div>
</Show>
</TableCell>
);
}
const openMetricEditor = (e: MouseEvent) => {
startEditing(resource, metric, e);
};
return (
<TableCell class="p-1 px-2 text-center align-middle">
<Show
when={showMetric()}
fallback={<span class="text-sm text-muted">-</span>}
>
<Show
when={isEditing()}
fallback={
<div
onClick={(event) => {
openMetricEditor(event);
}}
class="cursor-pointer hover:bg-surface-hover rounded px-1 py-0.5 transition-colors"
title={getAlertResourceTableEditMetricTitle()}
>
<MetricValueWithHeat
resourceId={resource.id}
metric={metric}
value={displayValue(metric)}
isOverridden={isOverridden(metric)}
/>
</div>
}
>
<div class="flex w-full items-center gap-3">
<Show when={ALERT_RESOURCE_TABLE_SLIDER_METRICS.has(metric)}>
{(() => {
const isTemperatureMetric =
metric === 'temperature' ||
metric === 'diskTemperature';
const sliderMin = Math.max(0, bounds.min);
const sliderMax = isTemperatureMetric
? Math.max(
sliderMin,
bounds.max > 0 ? bounds.max : 120,
)
: bounds.max;
const defaultSliderValue = () => {
if (metric === 'disk') return 90;
if (metric === 'memory') return 85;
if (metric === 'temperature') return 80;
if (metric === 'diskTemperature') return 55;
return 80;
};
const currentSliderValue = () => {
const editingVal =
props.editingThresholds()?.[metric];
if (
typeof editingVal === 'number' &&
editingVal >= 0
) {
return Math.round(editingVal);
}
const displayVal = displayValue(metric);
if (
typeof displayVal === 'number' &&
displayVal >= 0
) {
return Math.round(displayVal);
}
return defaultSliderValue();
};
return (
<div class="w-36">
<ThresholdSlider
value={Math.max(
sliderMin,
Math.min(sliderMax, currentSliderValue()),
)}
onChange={(val) => {
props.setEditingThresholds({
...props.editingThresholds(),
[metric]: val,
});
}}
type={
isTemperatureMetric
? 'temperature'
: (metric as 'cpu' | 'memory' | 'disk')
}
min={sliderMin}
max={sliderMax}
disabled={isDisabled()}
/>
</div>
);
})()}
</Show>
<div class="flex items-center justify-center">
<input
type="number"
min={bounds.min}
max={bounds.max}
step={getAlertResourceMetricStep(metric)}
value={thresholds()?.[metric] ?? ''}
placeholder={getAlertResourceTableMetricPlaceholder(
isDisabled(),
)}
title={getAlertResourceTableMetricInputTitle(
isDisabled(),
)}
ref={(el) => {
if (
isEditing() &&
activeMetricInput()?.resourceId ===
resource.id &&
activeMetricInput()?.metric === metric
) {
queueMicrotask(() => {
el.focus();
el.select();
});
}
}}
onInput={(e) => {
const raw = e.currentTarget.value;
if (raw === '') {
updateEditingThreshold(metric, undefined);
return;
}
const val = parseFloat(raw);
if (!Number.isNaN(val)) {
updateEditingThreshold(metric, val);
}
}}
onBlur={() => {
if (props.editingId() === resource.id) {
saveEditing(resource.id);
}
}}
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${
isDisabled()
? 'bg-surface-alt text-slate-400 border-border'
: ' text-base-content border-border'
}`}
/>
</div>
</div>
</Show>
</Show>
</TableCell>
);
}}
</For>
{/* Offline Alerts column - Connectivity/powered-off alerts */}
<Show when={props.showOfflineAlertsColumn}>
<TableCell class="p-1 px-2 text-center align-middle">
{(() => {
const disabledGlobally = props.globalDisableFlag?.() ?? false;
const supportsTriState =
typeof props.onSetOfflineState === 'function' &&
(resource.type === 'guest' ||
resource.type === 'dockerContainer');
if (supportsTriState) {
const defaultDisabled =
props.globalDisableOfflineFlag?.() ?? false;
const defaultSeverity =
props.globalOfflineSeverity ?? 'warning';
let state: OfflineState;
if (resource.disableConnectivity) {
state = 'off';
} else if (resource.poweredOffSeverity) {
state = resource.poweredOffSeverity;
} else if (defaultDisabled) {
state = 'off';
} else {
state =
defaultSeverity === 'critical' ? 'critical' : 'warning';
}
return renderOfflineStateButton(
state,
disabledGlobally,
() => {
if (disabledGlobally) return;
const next = nextOfflineState(state);
props.onSetOfflineState?.(resource.id, next);
},
);
}
if (!props.onToggleNodeConnectivity) {
return <span class="text-sm text-slate-400">-</span>;
}
const globalOfflineDisabled =
props.globalDisableOfflineFlag?.() ?? false;
return renderToggleBadge({
isEnabled:
!globalOfflineDisabled && !resource.disableConnectivity,
disabled: disabledGlobally,
onToggle: () => {
if (disabledGlobally) return;
props.onToggleNodeConnectivity?.(resource.id);
},
titleEnabled:
'Offline alerts enabled. Click to disable for this resource.',
titleDisabled:
'Offline alerts disabled. Click to enable for this resource.',
titleWhenDisabled: 'Offline alerts controlled globally',
});
})()}
</TableCell>
</Show>
{/* Actions column */}
<TableCell class="p-1 px-2">
<div class="flex items-center justify-center gap-1">
<Show
when={!isEditing()}
fallback={
<button
type="button"
onClick={cancelEditing}
class="p-1 hover:text-muted"
title="Cancel editing"
aria-label="Cancel editing"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
}
>
<Show when={resource.type !== 'dockerHost'}>
<button
type="button"
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)}`}
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
</Show>
<Show
when={
resource.hasOverride ||
((resource.type === 'agent' ||
resource.type === 'dockerHost') &&
resource.disableConnectivity)
}
>
<button
type="button"
onClick={() => props.onRemoveOverride(resource.id)}
class="p-1 hover:text-muted transition-colors"
title={getAlertResourceTableRevertToDefaultsLabel()}
aria-label={`Revert to defaults for ${getResourceLabel(resource)}`}
>
<RotateCcw class="w-4 h-4" />
</button>
</Show>
</Show>
</div>
</TableCell>
</TableRow>
);
}}
{(resource) => renderDesktopRow(resource)}
</For>
</>
);
@@ -1453,367 +993,7 @@ export function ResourceTable(props: ResourceTableProps) {
when={props.resources && props.resources.length === 0}
fallback={
<For each={props.resources}>
{(resource) => {
const isEditing = () => props.editingId() === resource.id;
const thresholds = () => getThresholds(resource, isEditing());
const displayValue = (metric: string) =>
getDisplayValue(resource, metric, isEditing());
const isOverridden = (metric: string) =>
isAlertResourceMetricOverridden(resource, metric);
return (
<TableRow
class={`transition-colors ${resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''} ${resource.hasOverride ? 'bg-sky-50/50 hover:bg-sky-50/80 dark:bg-sky-900/20 dark:hover:bg-sky-900/30 border-l-[3px] border-l-sky-400 dark:border-l-sky-500' : 'hover:bg-surface-hover'}`}
>
{/* Bulk Edit Checkbox column */}
<Show when={props.onBulkEdit}>
<TableCell class="p-1 px-2 text-center align-middle border-r border-border">
<input
type="checkbox"
checked={selectedIds().has(resource.id)}
onChange={(e) =>
toggleSelection(resource.id, e.currentTarget.checked)
}
class="rounded border-border text-sky-600 focus:ring-sky-500 transition-shadow cursor-pointer"
aria-label={`Select ${getResourceLabel(resource)}`}
/>
</TableCell>
</Show>
{/* Alert toggle column */}
<TableCell class="p-1 px-2 text-center align-middle">
<Show when={props.onToggleDisabled}>
{(() => {
const globallyDisabled = props.globalDisableFlag?.() ?? false;
const isChecked = !globallyDisabled && !resource.disabled;
return (
<div class="flex items-center justify-center">
<TogglePrimitive
size="sm"
checked={isChecked}
disabled={globallyDisabled}
onToggle={() =>
!globallyDisabled && props.onToggleDisabled?.(resource.id)
}
class="my-[1px]"
title={
globallyDisabled
? 'Alerts disabled globally'
: resource.disabled
? 'Click to enable alerts'
: 'Click to disable alerts'
}
ariaLabel={
isChecked
? 'Alerts enabled for this resource'
: 'Alerts disabled for this resource'
}
/>
</div>
);
})()}
</Show>
</TableCell>
<TableCell class="p-1 px-2">
<Show
when={resource.type === 'agent'}
fallback={
<div class="flex items-center gap-2 min-w-0">
<span
class={`text-sm font-medium truncate flex-nowrap ${resource.disabled ? 'text-slate-500 ' : 'text-base-content'}`}
>
{getResourceLabel(resource)}
</span>
<Show
when={resource.hasOverride || resource.disableConnectivity}
>
<span class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
{getAlertResourceTableCustomBadgeLabel()}
</span>
</Show>
</div>
}
>
<div
class="flex items-center gap-3 min-w-0"
title={resource.status || undefined}
>
<Show
when={resource.host}
fallback={
<span
class={`text-sm font-medium truncate flex-nowrap ${resource.disabled ? 'text-slate-500 ' : 'text-base-content'}`}
>
{getResourceLabel(resource)}
</span>
}
>
{(host) => (
<a
href={host() as string}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${
resource.disabled
? 'text-slate-500 '
: 'text-base-content hover:text-sky-600 dark:hover:text-sky-400'
}`}
title={`Open ${getResourceLabel(resource)} web interface`}
>
{getResourceLabel(resource)}
</a>
)}
</Show>
<Show when={resource.clusterName}>
<span class="rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{resource.clusterName}
</span>
</Show>
<Show when={resource.hasOverride || resource.disableConnectivity}>
<span class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
{getAlertResourceTableCustomBadgeLabel()}
</span>
</Show>
</div>
</Show>
</TableCell>
{/* Metric columns - dynamically rendered based on resource type */}
<For each={props.columns}>
{(column) => {
const metric = normalizeAlertResourceMetricKey(column);
// Check if this metric applies to this resource type
const showMetric = () => {
if (
resource.type === 'agent' &&
['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(
metric,
)
) {
return false;
}
if (resource.type === 'pbs') {
// PBS only has CPU and Memory metrics
return ['cpu', 'memory'].includes(metric);
}
if (resource.type === 'storage') {
return metric === 'usage';
}
return true;
};
const isDisabled = () => thresholds()?.[metric] === -1;
const openMetricEditor = (e: MouseEvent) => {
startEditing(resource, metric, e);
};
return (
<TableCell class="p-1 px-2 text-center align-middle">
<Show
when={showMetric()}
fallback={<span class="text-sm text-muted">-</span>}
>
<Show
when={isEditing()}
fallback={
<div
onClick={openMetricEditor}
class="cursor-pointer hover:bg-surface-hover rounded px-1 py-0.5 transition-colors"
title={getAlertResourceTableEditMetricTitle()}
>
<MetricValueWithHeat
resourceId={resource.id}
metric={metric}
value={displayValue(metric)}
isOverridden={isOverridden(metric)}
/>
</div>
}
>
<div class="flex items-center justify-center">
<input
type="number"
min="-1"
max={
metric.includes('disk') ||
metric.includes('memory') ||
metric.includes('cpu') ||
metric === 'usage'
? 100
: 10000
}
step={getAlertResourceMetricStep(metric)}
value={thresholds()?.[metric] ?? ''}
placeholder={getAlertResourceTableMetricPlaceholder(
isDisabled(),
)}
title={getAlertResourceTableMetricInputTitle(
isDisabled(),
)}
ref={(el) => {
if (
isEditing() &&
activeMetricInput()?.resourceId === resource.id &&
activeMetricInput()?.metric === metric
) {
queueMicrotask(() => {
el.focus();
el.select();
});
}
}}
onInput={(e) => {
const raw = e.currentTarget.value;
if (raw === '') {
updateEditingThreshold(metric, undefined);
return;
}
const val = parseFloat(raw);
if (!Number.isNaN(val)) {
updateEditingThreshold(metric, val);
}
}}
onBlur={() => {
if (props.editingId() === resource.id) {
saveEditing(resource.id);
}
}}
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${
isDisabled()
? 'bg-surface-alt text-slate-400 border-border'
: ' text-base-content border-border'
}`}
/>
</div>
</Show>
</Show>
</TableCell>
);
}}
</For>
{/* Offline Alerts column - Connectivity/powered-off alerts */}
<Show when={props.showOfflineAlertsColumn}>
<TableCell class="p-1 px-2 text-center align-middle">
<Show when={props.onToggleNodeConnectivity}>
{(() => {
const defaultOfflineDisabled =
props.globalDisableOfflineFlag?.() ?? false;
const isEnabled = !(
resource.disableConnectivity || defaultOfflineDisabled
);
const disabledGlobally = props.globalDisableFlag?.() ?? false;
return (
<StatusBadge
isEnabled={isEnabled}
disabled={disabledGlobally}
onToggle={() =>
props.onToggleNodeConnectivity?.(resource.id)
}
titleEnabled="Offline alerts enabled. Click to disable for this resource."
titleDisabled="Offline alerts disabled. Click to enable for this resource."
titleWhenDisabled="Offline alerts controlled globally"
/>
);
})()}
</Show>
</TableCell>
</Show>
{/* Actions column */}
<TableCell class="p-1 px-2">
<div class="flex items-center justify-center gap-1">
<Show when={typeof resource.toggleEnabled === 'function'}>
<StatusBadge
size="sm"
isEnabled={resource.isEnabled ?? true}
onToggle={resource.toggleEnabled}
titleEnabled={resource.toggleTitleEnabled}
titleDisabled={resource.toggleTitleDisabled}
/>
</Show>
<Show
when={
resource.editable !== false &&
typeof props.onEdit === 'function'
}
fallback={<span class="text-xs text-muted">—</span>}
>
<Show
when={!isEditing()}
fallback={
<button
type="button"
onClick={cancelEditing}
class="p-1 hover:text-muted"
title="Cancel editing"
aria-label="Cancel editing"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
}
>
<div class="flex items-center gap-1">
<button
type="button"
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)}`}
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<Show
when={
resource.hasOverride ||
(resource.type === 'agent' &&
resource.disableConnectivity)
}
>
<button
type="button"
onClick={() => props.onRemoveOverride(resource.id)}
class="p-1 hover:text-base-content transition-colors"
title={getAlertResourceTableRevertToDefaultsLabel()}
aria-label={`Revert to defaults for ${getResourceLabel(resource)}`}
>
<RotateCcw class="w-4 h-4" />
</button>
</Show>
</div>
</Show>
</Show>
</div>
</TableCell>
</TableRow>
);
}}
{(resource) => renderDesktopRow(resource)}
</For>
}
>
@@ -223,7 +223,10 @@ export function alertResourceSupportsMetric(
metric: string,
): boolean {
if (!resourceType) return true;
if (resourceType === 'node' && ['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) {
if (
['node', 'agent'].includes(resourceType) &&
['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)
) {
return false;
}
if (resourceType === 'pbs') {
@@ -336,6 +336,7 @@ import alertIncidentPresentationSource from '@/utils/alertIncidentPresentation.t
import alertHistoryPresentationSource from '@/utils/alertHistoryPresentation.ts?raw';
import bulkEditDialogSource from '@/components/Alerts/BulkEditDialog.tsx?raw';
import alertResourceTableModelSource from '@/components/Alerts/alertResourceTableModel.ts?raw';
import alertResourceTableRowSource from '@/components/Alerts/AlertResourceTableRow.tsx?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';
@@ -3765,6 +3766,7 @@ describe('frontend resource type boundaries', () => {
it('keeps alert resource table vocabulary in a shared presentation utility', () => {
expect(alertResourceTableSource).toContain('useAlertResourceTableState');
expect(alertResourceTableSource).toContain('AlertResourceTableRow');
expect(alertResourceTableSource).toContain('normalizeAlertResourceMetricKey');
expect(alertResourceTableSource).not.toContain('const flattenResources = (): Resource[] => {');
expect(alertResourceTableSource).not.toContain(
@@ -3777,11 +3779,9 @@ describe('frontend resource type boundaries', () => {
expect(alertResourceTableSource).toContain('getAlertResourceTableNoResultsState');
expect(alertResourceTableSource).toContain('getAlertResourceTableCustomBadgeLabel');
expect(alertResourceTableSource).toContain('getAlertResourceTableMetricPlaceholder');
expect(alertResourceTableSource).toContain('getAlertResourceTableOverrideNotePlaceholder');
expect(alertResourceTableSource).toContain('getAlertResourceTableResetFactoryDefaultsLabel');
expect(alertResourceTableSource).toContain('getAlertResourceTableAlertDelayLabel');
expect(alertResourceTableSource).toContain('getAlertResourceTableMetricInputTitle');
expect(alertResourceTableSource).toContain('getAlertResourceTableEditMetricTitle');
expect(alertResourceTableSource).not.toContain('No resources available.');
expect(alertResourceTableSource).not.toContain('No {props.title.toLowerCase()} found');
expect(alertResourceTableSource).not.toContain('Add a note about this override (optional)');
@@ -3790,6 +3790,9 @@ describe('frontend resource type boundaries', () => {
expect(alertResourceTableSource).not.toContain('Alert Delay (s)');
expect(alertResourceTableSource).not.toContain('Click to edit this metric');
expect(alertResourceTableSource).not.toContain('Set to -1 to disable alerts for this metric');
expect(alertResourceTableSource).not.toContain(
"if (resource.type === 'agent' && ['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(",
);
expect(alertResourceTablePresentationSource).toContain(
'export const ALERT_RESOURCE_TABLE_EMPTY_STATE',
);
@@ -3823,12 +3826,20 @@ describe('frontend resource type boundaries', () => {
expect(alertResourceTableStateSource).toContain('export function useAlertResourceTableState');
expect(alertResourceTableStateSource).toContain('toggleAll');
expect(alertResourceTableStateSource).toContain('clearSelectedIds');
expect(alertResourceTableRowSource).toContain('export function AlertResourceTableRow');
expect(alertResourceTableRowSource).toContain('alertResourceSupportsMetric');
expect(alertResourceTableRowSource).toContain('getAlertResourceTableOverrideNotePlaceholder');
expect(alertResourceTableRowSource).toContain('getAlertResourceTableMetricInputTitle');
expect(alertResourceTableRowSource).toContain('getAlertResourceTableEditMetricTitle');
expect(alertResourceTableModelSource).toContain(
'export function normalizeAlertResourceMetricKey',
);
expect(alertResourceTableModelSource).toContain(
'export function getAlertResourceMetricDisplayValue',
);
expect(alertResourceTableModelSource).toContain(
'export function alertResourceSupportsMetric',
);
expect(alertResourceTableModelSource).toContain(
'export function buildAlertResourceEditPayload',
);