mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 16:42:15 +00:00
Bind alert schedule form labels
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user