Bind alert schedule form labels

This commit is contained in:
rcourtman
2026-05-20 14:35:14 +01:00
parent bc5275b8d8
commit 69263ff68b
5 changed files with 232 additions and 73 deletions
@@ -1,11 +1,6 @@
import { Show } from 'solid-js';
import { Show, createUniqueId } from 'solid-js';
import {
controlClass,
formHelpText,
formField,
labelClass,
} from '@/components/shared/Form';
import { controlClass, formHelpText, formField, labelClass } from '@/components/shared/Form';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
import {
@@ -30,6 +25,12 @@ interface AlertCooldownSectionProps {
}
export function AlertCooldownSection(props: AlertCooldownSectionProps) {
const fieldIdPrefix = `alert-cooldown-${createUniqueId()}`;
const fieldIds = {
minutes: `${fieldIdPrefix}-minutes`,
maxAlerts: `${fieldIdPrefix}-max-alerts`,
};
return (
<SettingsPanel
title={ALERT_CONFIG_COOLDOWN_TITLE}
@@ -53,11 +54,15 @@ export function AlertCooldownSection(props: AlertCooldownSectionProps) {
<div class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label
for={fieldIds.minutes}
class={labelClass('text-xs uppercase tracking-[0.08em]')}
>
{ALERT_CONFIG_COOLDOWN_PERIOD_LABEL}
</label>
<div class="relative">
<input
id={fieldIds.minutes}
type="number"
min="5"
max="120"
@@ -73,11 +78,15 @@ export function AlertCooldownSection(props: AlertCooldownSectionProps) {
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label
for={fieldIds.maxAlerts}
class={labelClass('text-xs uppercase tracking-[0.08em]')}
>
{ALERT_CONFIG_COOLDOWN_MAX_ALERTS_LABEL}
</label>
<div class="relative">
<input
id={fieldIds.maxAlerts}
type="number"
min="1"
max="10"
@@ -1,4 +1,4 @@
import { For, Show } from 'solid-js';
import { For, Show, createUniqueId } from 'solid-js';
import { controlClass, formHelpText } from '@/components/shared/Form';
import { FormSelect } from '@/components/shared/FormSelect';
@@ -29,6 +29,8 @@ interface AlertEscalationSectionProps {
}
export function AlertEscalationSection(props: AlertEscalationSectionProps) {
const fieldIdPrefix = `alert-escalation-${createUniqueId()}`;
return (
<SettingsPanel
title={ALERT_CONFIG_ESCALATION_TITLE}
@@ -52,64 +54,73 @@ export function AlertEscalationSection(props: AlertEscalationSectionProps) {
<div class="space-y-3">
<p class={formHelpText}>{getAlertConfigEscalationHelp()}</p>
<For each={props.escalation.levels}>
{(level, index) => (
<div class="flex items-center gap-3 rounded-md border border-border bg-surface-hover p-3">
<div class="flex flex-1 flex-col gap-3 sm:grid sm:grid-cols-2 sm:items-center sm:gap-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-muted">
{ALERT_CONFIG_ESCALATION_AFTER_LABEL}
</span>
<input
type="number"
min="5"
max="180"
value={level.after}
{(level, index) => {
const afterId = () => `${fieldIdPrefix}-after-${index()}`;
const notifyId = () => `${fieldIdPrefix}-notify-${index()}`;
return (
<div class="flex items-center gap-3 rounded-md border border-border bg-surface-hover p-3">
<div class="flex flex-1 flex-col gap-3 sm:grid sm:grid-cols-2 sm:items-center sm:gap-2">
<div class="flex items-center gap-2">
<label for={afterId()} class="text-xs font-medium text-muted">
{ALERT_CONFIG_ESCALATION_AFTER_LABEL}
</label>
<input
id={afterId()}
type="number"
min="5"
max="180"
value={level.after}
onChange={(event) =>
props.setEscalationAfter(index(), event.currentTarget.value)
}
class={`${controlClass('px-2 py-1 text-sm')} w-20`}
/>
<span class="text-xs text-muted">
{ALERT_CONFIG_ESCALATION_MINUTES_SUFFIX}
</span>
</div>
<FormSelect
id={notifyId()}
label={ALERT_CONFIG_ESCALATION_NOTIFY_LABEL}
value={level.notify}
onChange={(event) =>
props.setEscalationAfter(index(), event.currentTarget.value)
props.setEscalationNotify(
index(),
event.currentTarget.value as EscalationNotifyTarget,
)
}
class={`${controlClass('px-2 py-1 text-sm')} w-20`}
/>
<span class="text-xs text-muted">{ALERT_CONFIG_ESCALATION_MINUTES_SUFFIX}</span>
fieldBaseClass="flex"
fieldClass="items-center gap-2"
labelClass="text-xs font-medium text-muted"
selectBaseClass={`${controlClass('px-2 py-1 text-sm')} flex-1`}
>
<option value="email">{getAlertConfigEscalationNotifyLabel('email')}</option>
<option value="webhook">
{getAlertConfigEscalationNotifyLabel('webhook')}
</option>
<option value="all">{getAlertConfigEscalationNotifyLabel('all')}</option>
</FormSelect>
</div>
<FormSelect
id={`alert-escalation-notify-${index()}`}
label={ALERT_CONFIG_ESCALATION_NOTIFY_LABEL}
value={level.notify}
onChange={(event) =>
props.setEscalationNotify(
index(),
event.currentTarget.value as EscalationNotifyTarget,
)
}
fieldBaseClass="flex"
fieldClass="items-center gap-2"
labelClass="text-xs font-medium text-muted"
selectBaseClass={`${controlClass('px-2 py-1 text-sm')} flex-1`}
<button
type="button"
onClick={() => props.removeEscalationLevel(index())}
class="rounded-md p-1.5 text-red-600 transition-colors hover:bg-red-100 dark:hover:bg-red-900"
title={ALERT_CONFIG_ESCALATION_REMOVE_TITLE}
aria-label={ALERT_CONFIG_ESCALATION_REMOVE_TITLE}
>
<option value="email">{getAlertConfigEscalationNotifyLabel('email')}</option>
<option value="webhook">
{getAlertConfigEscalationNotifyLabel('webhook')}
</option>
<option value="all">{getAlertConfigEscalationNotifyLabel('all')}</option>
</FormSelect>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<button
type="button"
onClick={() => props.removeEscalationLevel(index())}
class="rounded-md p-1.5 text-red-600 transition-colors hover:bg-red-100 dark:hover:bg-red-900"
title={ALERT_CONFIG_ESCALATION_REMOVE_TITLE}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
);
}}
</For>
<button
@@ -1,10 +1,6 @@
import { Show } from 'solid-js';
import { Show, createUniqueId } from 'solid-js';
import {
formHelpText,
formField,
labelClass,
} from '@/components/shared/Form';
import { formHelpText, formField, labelClass } from '@/components/shared/Form';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
import {
@@ -33,6 +29,11 @@ interface AlertGroupingSectionProps {
}
export function AlertGroupingSection(props: AlertGroupingSectionProps) {
const fieldIdPrefix = `alert-grouping-${createUniqueId()}`;
const fieldIds = {
window: `${fieldIdPrefix}-window`,
};
return (
<SettingsPanel
title={ALERT_CONFIG_GROUPING_TITLE}
@@ -55,15 +56,17 @@ export function AlertGroupingSection(props: AlertGroupingSectionProps) {
<Show when={props.grouping.enabled}>
<div class="space-y-4">
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label for={fieldIds.window} class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_CONFIG_GROUPING_WINDOW_LABEL}
</label>
<div class="flex items-center gap-3">
<input
id={fieldIds.window}
type="range"
min="0"
max="30"
value={props.grouping.window}
aria-valuetext={`${props.grouping.window} minutes`}
onChange={(event) => props.setGroupingWindow(event.currentTarget.value)}
class="flex-1"
/>
@@ -1,4 +1,4 @@
import { For, Show } from 'solid-js';
import { For, Show, createUniqueId } from 'solid-js';
import { controlClass, formField, labelClass } from '@/components/shared/Form';
import { FormSelect } from '@/components/shared/FormSelect';
@@ -40,6 +40,12 @@ interface AlertQuietHoursSectionProps {
}
export function AlertQuietHoursSection(props: AlertQuietHoursSectionProps) {
const fieldIdPrefix = `alert-quiet-hours-${createUniqueId()}`;
const fieldIds = {
start: `${fieldIdPrefix}-start`,
end: `${fieldIdPrefix}-end`,
};
return (
<SettingsPanel
title={ALERT_CONFIG_QUIET_HOURS_TITLE}
@@ -63,10 +69,11 @@ export function AlertQuietHoursSection(props: AlertQuietHoursSectionProps) {
<div class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label for={fieldIds.start} class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_CONFIG_QUIET_HOURS_START_TIME_LABEL}
</label>
<input
id={fieldIds.start}
type="time"
value={props.quietHours.start}
onChange={(event) => props.setQuietHoursStart(event.currentTarget.value)}
@@ -74,10 +81,11 @@ export function AlertQuietHoursSection(props: AlertQuietHoursSectionProps) {
/>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label for={fieldIds.end} class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_CONFIG_QUIET_HOURS_END_TIME_LABEL}
</label>
<input
id={fieldIds.end}
type="time"
value={props.quietHours.end}
onChange={(event) => props.setQuietHoursEnd(event.currentTarget.value)}
@@ -109,6 +117,8 @@ export function AlertQuietHoursSection(props: AlertQuietHoursSectionProps) {
type="button"
onClick={() => props.toggleQuietDay(day.id)}
title={day.fullLabel}
aria-label={day.fullLabel}
aria-pressed={props.quietHours.days[day.id]}
class={getAlertQuietDayButtonClass(props.quietHours.days[day.id])}
>
{day.label}
@@ -0,0 +1,126 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AlertCooldownSection } from './AlertCooldownSection';
import { AlertEscalationSection } from './AlertEscalationSection';
import { AlertGroupingSection } from './AlertGroupingSection';
import { AlertQuietHoursSection } from './AlertQuietHoursSection';
import type { CooldownConfig, EscalationConfig, GroupingConfig, QuietHoursConfig } from './types';
function makeQuietHours(overrides: Partial<QuietHoursConfig> = {}): QuietHoursConfig {
return {
enabled: true,
start: '22:00',
end: '07:00',
timezone: 'UTC',
days: {
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
sunday: false,
},
suppress: {
performance: true,
storage: true,
offline: false,
},
...overrides,
};
}
describe('Alert schedule sections', () => {
afterEach(() => {
cleanup();
});
it('associates visible labels with quiet-hours controls', () => {
render(() => (
<AlertQuietHoursSection
quietHours={makeQuietHours()}
quietHourSuppressOptions={[
{ key: 'performance', label: 'Performance', description: 'CPU and memory alerts' },
{ key: 'storage', label: 'Storage', description: 'Disk alerts' },
{ key: 'offline', label: 'Offline', description: 'Connectivity alerts' },
]}
weekdaysOnly={true}
weekendsOnly={false}
setQuietHoursEnabled={vi.fn()}
setQuietHoursStart={vi.fn()}
setQuietHoursEnd={vi.fn()}
setQuietHoursTimezone={vi.fn()}
toggleQuietDay={vi.fn()}
setQuietSuppressCategory={vi.fn()}
/>
));
expect(screen.getByLabelText('Start time')).toHaveAttribute('type', 'time');
expect(screen.getByLabelText('End time')).toHaveAttribute('type', 'time');
expect(screen.getByRole('combobox', { name: 'Timezone' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Monday' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Saturday' })).toHaveAttribute(
'aria-pressed',
'false',
);
});
it('associates visible labels with cooldown controls', () => {
const cooldown: CooldownConfig = { enabled: true, minutes: 30, maxAlerts: 3 };
render(() => (
<AlertCooldownSection
cooldown={cooldown}
setCooldownEnabled={vi.fn()}
setCooldownMinutes={vi.fn()}
setCooldownMaxAlerts={vi.fn()}
/>
));
expect(screen.getByRole('spinbutton', { name: 'Cooldown period' })).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: 'Max alerts / hour' })).toBeInTheDocument();
});
it('associates visible labels with grouping controls', () => {
const grouping: GroupingConfig = { enabled: true, window: 10, byNode: true, byGuest: false };
render(() => (
<AlertGroupingSection
grouping={grouping}
setGroupingEnabled={vi.fn()}
setGroupingWindow={vi.fn()}
setGroupingByNode={vi.fn()}
setGroupingByGuest={vi.fn()}
/>
));
expect(screen.getByRole('slider', { name: 'Grouping window' })).toHaveAttribute(
'aria-valuetext',
'10 minutes',
);
expect(screen.getByRole('checkbox', { name: 'By node' })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: 'By workload' })).toBeInTheDocument();
});
it('associates visible labels with escalation controls', () => {
const escalation: EscalationConfig = {
enabled: true,
levels: [{ after: 15, notify: 'email' }],
};
render(() => (
<AlertEscalationSection
escalation={escalation}
setEscalationEnabled={vi.fn()}
setEscalationAfter={vi.fn()}
setEscalationNotify={vi.fn()}
removeEscalationLevel={vi.fn()}
addEscalationLevel={vi.fn()}
/>
));
expect(screen.getByRole('spinbutton', { name: 'After' })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Notify' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove escalation level' })).toBeInTheDocument();
});
});