Expose per-resource alert delay controls (#1497)

This commit is contained in:
courtmanr@gmail.com
2026-07-30 10:27:39 +01:00
parent cb1768d484
commit 69cfce92d3
24 changed files with 215 additions and 6 deletions
@@ -1401,6 +1401,15 @@ coverage, duplicate and delayed reports, wall-clock changes, suppression
reset, migration/restart continuity, backup hard-cap behavior, preview
immutability, and first-match lifecycle identity.
The thresholds resource rows expose that same policy owner directly through a
per-resource alert-delay action. The action carries the canonical resource id
and the first supported CPU, memory, or disk signal into the versioned
intent-policy editor, expands it, and leaves persistence and inheritance with
`/api/alerts/intent-policies`. Resources without a supported metric open on
`state.offline`. Threshold rows must not create a parallel delay field in the
legacy threshold override document or save intent changes through the
threshold-config endpoint.
### Provider-observed storage and workload incident lifecycle
Provider observations use one stable alert identity composed from provider,
@@ -4758,6 +4758,14 @@ rendering remains in
`frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx`, and shared
metric normalization remains in
`frontend-modern/src/components/Alerts/alertResourceTableModel.ts`.
Desktop rows and mobile resource cards also share one alert-delay handoff:
their timer action emits the canonical resource id plus the first supported
CPU, memory, or disk signal, while `ThresholdsTab.tsx` owns selection state and
`AlertIntentPolicyPanel.tsx` owns expansion, scrolling, field inheritance, and
API persistence. The action remains available for non-threshold-editable rows
and falls back to `state.offline` when no supported metric exists. New
platform threshold sections must forward this shared handoff rather than add a
platform-local delay editor or a second intent-policy transport.
`frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts` now owns
destination test actions, retry orchestration, and delivery-health loading while
`frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx` stays the
@@ -519,6 +519,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
onSetOfflineState={props.table.onSetOfflineState}
onToggleBackup={props.table.onToggleBackup}
onToggleSnapshot={props.table.onToggleSnapshot}
onConfigureResourceIntent={props.table.onConfigureResourceIntent}
globalDisableFlag={props.table.globalDisableFlag}
globalDisableOfflineFlag={props.table.globalDisableOfflineFlag}
editingNote={props.table.editingNote}
@@ -560,6 +561,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
onSetOfflineState={props.table.onSetOfflineState}
onToggleBackup={props.table.onToggleBackup}
onToggleSnapshot={props.table.onToggleSnapshot}
onConfigureResourceIntent={props.table.onConfigureResourceIntent}
globalDisableFlag={props.table.globalDisableFlag}
globalDisableOfflineFlag={props.table.globalDisableOfflineFlag}
editingNote={props.table.editingNote}
@@ -2,6 +2,7 @@ import { For, Show } from 'solid-js';
import Check from 'lucide-solid/icons/check';
import Pencil from 'lucide-solid/icons/pencil';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import Timer from 'lucide-solid/icons/timer';
import X from 'lucide-solid/icons/x';
import { ActionIconButton } from '@/components/shared/Button';
@@ -296,6 +297,28 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
<Pencil class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show when={!isEditing() && props.table.onConfigureResourceIntent}>
<ActionIconButton
onClick={() => {
const preferredMetric = ['cpu', 'memory', 'disk'].find(
(metric) =>
props.table.columns.some(
(column) => normalizeAlertResourceMetricKey(column) === metric,
) && alertResourceSupportsMetric(resource.type, metric),
);
props.table.onConfigureResourceIntent?.(
resource.id,
preferredMetric ? `metric.${preferredMetric}` : 'state.offline',
);
}}
label={`Configure alert delay for ${getAlertResourceLabel(resource)}`}
title="Configure individual alert delay"
tone="neutral"
size="sm"
>
<Timer class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show when={isEditing()}>
<ActionIconButton
onClick={cancelEditing}
@@ -1,6 +1,7 @@
import { For, Show } from 'solid-js';
import Pencil from 'lucide-solid/icons/pencil';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import Timer from 'lucide-solid/icons/timer';
import X from 'lucide-solid/icons/x';
import { ActionIconButton } from '@/components/shared/Button';
@@ -38,6 +39,7 @@ import {
type AlertResourceThresholdMap,
} from './alertResourceTableModel';
import type { Alert } from '@/types/api';
import type { AlertIntentSignal } from '@/api/alertIntentPolicies';
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
type OfflineState = 'off' | 'warning' | 'critical';
@@ -67,6 +69,7 @@ interface AlertResourceTableRowProps {
onSetOfflineState?: (resourceId: string, state: OfflineState) => void;
onToggleBackup?: (resourceId: string, forceState?: boolean) => void;
onToggleSnapshot?: (resourceId: string, forceState?: boolean) => void;
onConfigureResourceIntent?: (resourceId: string, signal: AlertIntentSignal) => void;
globalDisableFlag?: () => boolean;
globalDisableOfflineFlag?: () => boolean;
editingNote: () => string;
@@ -91,6 +94,17 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
isEditing(),
);
const isOverridden = (metric: string) => isAlertResourceMetricOverridden(props.resource, metric);
const preferredIntentSignal = (): AlertIntentSignal => {
for (const metric of ['cpu', 'memory', 'disk'] as const) {
if (
props.columns.some((column) => normalizeAlertResourceMetricKey(column) === metric) &&
alertResourceSupportsMetric(props.resource.type, metric)
) {
return `metric.${metric}`;
}
}
return 'state.offline';
};
const getThresholds = (): AlertResourceThresholdMap => thresholds();
@@ -595,10 +609,11 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
props.resource.hasOverride ||
((props.resource.type === 'agent' || props.resource.type === 'dockerHost') &&
props.resource.disableConnectivity);
const showIntent = typeof props.onConfigureResourceIntent === 'function';
return (
<Show
when={showEdit || showRevert}
when={showEdit || showRevert || showIntent}
fallback={
<span class="text-xs text-muted" aria-hidden="true">
—
@@ -616,6 +631,22 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
<Pencil class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show when={showIntent}>
<ActionIconButton
onClick={() =>
props.onConfigureResourceIntent?.(
props.resource.id,
preferredIntentSignal(),
)
}
label={`Configure alert delay for ${resourceLabel()}`}
title="Configure individual alert delay"
tone="neutral"
size="xs"
>
<Timer class="w-4 h-4" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show when={showRevert}>
<ActionIconButton
onClick={() => props.onRemoveOverride(props.resource.id)}
@@ -1472,6 +1472,34 @@ describe('ResourceTable', () => {
});
describe('delay settings', () => {
it('opens the canonical per-resource delay editor from the resource row', () => {
const onConfigureResourceIntent = vi.fn();
const props = makeProps({
resources: [makeResource({ id: 'vm-1', name: 'Database VM' })],
columns: ['CPU %', 'Memory %'],
onConfigureResourceIntent,
});
render(() => <ResourceTable {...props} />);
fireEvent.click(screen.getByLabelText('Configure alert delay for Database VM'));
expect(onConfigureResourceIntent).toHaveBeenCalledWith('vm-1', 'metric.cpu');
});
it('falls back to the offline signal when the row has no supported metric', () => {
const onConfigureResourceIntent = vi.fn();
const props = makeProps({
resources: [makeResource({ id: 'docker-host-1', name: 'Docker Host', type: 'dockerHost' })],
columns: [],
onConfigureResourceIntent,
});
render(() => <ResourceTable {...props} />);
fireEvent.click(screen.getByLabelText('Configure alert delay for Docker Host'));
expect(onConfigureResourceIntent).toHaveBeenCalledWith('docker-host-1', 'state.offline');
});
it('shows delay toggle button when showDelayColumn and onMetricDelayChange are provided', () => {
const props = makeProps({
showDelayColumn: true,
@@ -2,6 +2,7 @@ import { Show } from 'solid-js';
import X from 'lucide-solid/icons/x';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import type { Alert } from '@/types/api';
import type { AlertIntentSignal } from '@/api/alertIntentPolicies';
import {
ALERT_BULK_EDIT_CLEAR_LABEL,
getAlertBulkEditOpenLabel,
@@ -16,6 +17,7 @@ export type { GroupHeaderMeta, Resource } from '@/features/alerts/thresholds/tab
export interface ResourceTableProps {
title: string;
onConfigureResourceIntent?: (resourceId: string, signal: AlertIntentSignal) => void;
resources?: Resource[];
groupedResources?: Record<string, Resource[]>;
columns: string[];
@@ -24,6 +24,7 @@ export function ThresholdsTableAgentDisksSection(props: ThresholdsTableSectionPr
<div ref={state.registerSection('agentDisks')} class="scroll-mt-24">
<ResourceTable
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
groupedResources={state.agentDisksGroupedByAgent()}
groupHeaderMeta={state.agentGroupHeaderMeta()}
columns={['Disk %']}
@@ -19,6 +19,7 @@ export function ThresholdsTableAgentsResourcesSection(props: ThresholdsTableSect
<div ref={state.registerSection('agents')} class="scroll-mt-24">
<ResourceTable
title={state.sectionTitles.agents}
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={state.agentsWithOverrides()}
columns={['CPU %', 'Memory %', 'Disk %', 'Disk Temp °C']}
activeAlerts={tableProps.activeAlerts}
@@ -12,6 +12,7 @@ export function ThresholdsTableDockerContainersSection(props: ThresholdsTableSec
<div ref={state.registerSection('dockerContainers')} class="scroll-mt-24">
<ResourceTable
title={state.sectionTitles.dockerContainers}
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
groupedResources={state.dockerContainersGroupedByHost()}
groupHeaderMeta={state.dockerHostGroupMeta()}
columns={[
@@ -12,6 +12,7 @@ export function ThresholdsTableDockerHostsSection(props: ThresholdsTableSectionP
<div ref={state.registerSection('dockerHosts')} class="scroll-mt-24">
<ResourceTable
title={state.sectionTitles.dockerHosts}
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={state.dockerHostsWithOverrides()}
columns={[]}
activeAlerts={tableProps.activeAlerts}
@@ -50,6 +50,7 @@ function KubernetesResourceSection(
<div ref={state.registerSection(props.id)} class="scroll-mt-24">
<ResourceTable
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={props.resources()}
columns={props.columns}
activeAlerts={tableProps.activeAlerts}
@@ -24,6 +24,7 @@ export function ThresholdsTableProxmoxGuestsSection(props: ThresholdsTableSectio
<div ref={state.registerSection('guests')} class="scroll-mt-24">
<ResourceTable
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
groupedResources={state.guestsGroupedByNode()}
groupHeaderMeta={state.guestGroupHeaderMeta()}
columns={[
@@ -24,6 +24,7 @@ export function ThresholdsTableProxmoxNodesSection(props: ThresholdsTableSection
<div ref={state.registerSection('nodes')} class="scroll-mt-24">
<ResourceTable
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={state.nodesWithOverrides()}
columns={['CPU %', 'Memory %', 'Disk %', 'Temp °C']}
activeAlerts={tableProps.activeAlerts}
@@ -24,6 +24,7 @@ export function ThresholdsTableProxmoxPBSSection(props: ThresholdsTableSectionPr
<div id="threshold-section-pbs" ref={state.registerSection('pbs')} class="scroll-mt-24">
<ResourceTable
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={state.pbsServersWithOverrides()}
columns={['CPU %', 'Memory %']}
activeAlerts={tableProps.activeAlerts}
@@ -24,6 +24,7 @@ export function ThresholdsTableProxmoxPMGSection(props: ThresholdsTableSectionPr
<div id="threshold-section-pmg" ref={state.registerSection('pmg')} class="scroll-mt-24">
<ResourceTable
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={state.pmgServersWithOverrides()}
columns={[
'Queue Warn',
@@ -24,6 +24,7 @@ export function ThresholdsTableProxmoxStorageSection(props: ThresholdsTableSecti
<div ref={state.registerSection('storage')} class="scroll-mt-24">
<ResourceTable
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
groupedResources={state.storageGroupedByNode()}
groupHeaderMeta={state.guestGroupHeaderMeta()}
columns={['Usage %']}
@@ -57,6 +57,7 @@ function TrueNASResourceSection(
<div ref={state.registerSection(props.id)} class="scroll-mt-24">
<ResourceTable
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={props.resources()}
columns={props.columns}
activeAlerts={tableProps.activeAlerts}
@@ -64,6 +64,7 @@ function VMwareResourceSection(
<div ref={state.registerSection(props.id)} class="scroll-mt-24">
<ResourceTable
title=""
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
resources={props.resources()}
columns={props.columns}
activeAlerts={tableProps.activeAlerts}
@@ -60,7 +60,16 @@ const ruleFor = (
signal: AlertIntentSignal,
): AlertIntentRule => document?.resources?.[resourceId]?.[signal] ?? {};
export function AlertIntentPolicyPanel(props: { resources: readonly Resource[] }) {
export interface AlertIntentPolicySelectionTarget {
resourceId: string;
signal: AlertIntentSignal;
requestId: number;
}
export function AlertIntentPolicyPanel(props: {
resources: readonly Resource[];
selectionTarget?: AlertIntentPolicySelectionTarget | null;
}) {
const [expanded, setExpanded] = createSignal(false);
const [document, setDocument] = createSignal<AlertIntentPolicyDocument | null>(null);
const [loading, setLoading] = createSignal(false);
@@ -98,6 +107,7 @@ export function AlertIntentPolicyPanel(props: { resources: readonly Resource[] }
const [previewBackupActive, setPreviewBackupActive] = createSignal(false);
const [previewing, setPreviewing] = createSignal(false);
const [preview, setPreview] = createSignal<AlertIntentPolicyPreview | null>(null);
let panelElement: HTMLDivElement | undefined;
const selectedResource = createMemo(() =>
sortedResources().find((resource) => resource.id === resourceId()),
@@ -144,6 +154,19 @@ export function AlertIntentPolicyPanel(props: { resources: readonly Resource[] }
}
});
createEffect(() => {
const target = props.selectionTarget;
if (!target) return;
const requestId = target.requestId;
setResourceId(target.resourceId);
setSignal(target.signal);
setExpanded(true);
queueMicrotask(() => {
if (props.selectionTarget?.requestId !== requestId) return;
panelElement?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
});
});
createEffect(() => {
const current = ruleFor(document(), resourceId(), signal());
setOverrideGrace(current.graceSeconds === undefined ? '' : String(current.graceSeconds));
@@ -340,7 +363,12 @@ export function AlertIntentPolicyPanel(props: { resources: readonly Resource[] }
);
return (
<Card padding="md" class="space-y-4">
<Card
id="alert-intent-policy-panel"
ref={panelElement}
padding="md"
class="space-y-4 scroll-mt-24"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 class="text-base font-semibold text-base-content">Alert intent & grace</h2>
@@ -79,6 +79,23 @@ describe('AlertIntentPolicyPanel', () => {
expect(await screen.findByLabelText('Resource')).toHaveValue(resource.id);
});
it('opens directly on a row-selected resource and signal', async () => {
render(() => (
<AlertIntentPolicyPanel
resources={[resource]}
selectionTarget={{
resourceId: resource.id,
signal: 'metric.memory',
requestId: 1,
}}
/>
));
expect(await screen.findByRole('button', { name: 'Hide policies' })).toBeInTheDocument();
expect(screen.getByLabelText('Resource')).toHaveValue(resource.id);
expect(screen.getByLabelText('Signal')).toHaveValue('metric.memory');
});
it('saves field-level resource overrides without disabling inherited policy', async () => {
render(() => <AlertIntentPolicyPanel resources={[resource]} />);
@@ -1,4 +1,4 @@
import { render, cleanup } from '@solidjs/testing-library';
import { render, cleanup, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ThresholdsTab } from '../tabs/ThresholdsTab';
@@ -271,6 +271,7 @@ const buildProps = (): ThresholdsTabProps =>
describe('ThresholdsTab', () => {
beforeEach(() => {
captureThresholdsTableProps.mockReset();
captureIntentPolicyProps.mockReset();
});
afterEach(() => {
@@ -290,4 +291,28 @@ describe('ThresholdsTab', () => {
expect(typeof props.guestDefaults).toBe('object');
expect(typeof props.dockerDefaults).toBe('object');
});
it('routes a resource-row delay action into the canonical intent policy panel', async () => {
render(() => <ThresholdsTab {...buildProps()} />);
const tableProps = captureThresholdsTableProps.mock.calls[0][0] as {
onConfigureResourceIntent: (resourceId: string, signal: string) => void;
};
tableProps.onConfigureResourceIntent('vm-canonical', 'metric.memory');
await waitFor(() => {
const panelProps = captureIntentPolicyProps.mock.calls.at(-1)?.[0] as {
selectionTarget?: {
resourceId: string;
signal: string;
requestId: number;
};
};
expect(panelProps.selectionTarget).toEqual({
resourceId: 'vm-canonical',
signal: 'metric.memory',
requestId: 1,
});
});
});
});
@@ -1,13 +1,35 @@
import { createSignal } from 'solid-js';
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
import { AlertIntentPolicyPanel } from '../AlertIntentPolicyPanel';
import {
AlertIntentPolicyPanel,
type AlertIntentPolicySelectionTarget,
} from '../AlertIntentPolicyPanel';
import type { AlertIntentSignal } from '@/api/alertIntentPolicies';
import type { ThresholdsTabProps } from '../thresholds/thresholdsTabModel';
export function ThresholdsTab(props: ThresholdsTabProps) {
const [intentSelectionTarget, setIntentSelectionTarget] =
createSignal<AlertIntentPolicySelectionTarget | null>(null);
let intentSelectionRequest = 0;
const configureResourceIntent = (resourceId: string, signal: AlertIntentSignal) => {
intentSelectionRequest += 1;
setIntentSelectionTarget({
resourceId,
signal,
requestId: intentSelectionRequest,
});
};
return (
<div class="space-y-4">
<AlertIntentPolicyPanel resources={props.allResources} />
<AlertIntentPolicyPanel
resources={props.allResources}
selectionTarget={intentSelectionTarget()}
/>
<ThresholdsTable
onConfigureResourceIntent={configureResourceIntent}
overrides={props.overrides}
setOverrides={props.setOverrides}
rawOverridesConfig={props.rawOverridesConfig}
@@ -6,6 +6,7 @@ import type {
BackupAlertConfig,
} from '@/types/alerts';
import type { Resource } from '@/types/resource';
import type { AlertIntentSignal } from '@/api/alertIntentPolicies';
export type OverrideType =
| 'guest'
@@ -73,6 +74,7 @@ export interface SimpleThresholds {
}
export interface ThresholdsTableProps {
onConfigureResourceIntent?: (resourceId: string, signal: AlertIntentSignal) => void;
overrides: () => Override[];
setOverrides: (overrides: Override[]) => void;
rawOverridesConfig: () => Record<string, RawOverrideConfig>;