4348 lines
168 KiB
TypeScript
4348 lines
168 KiB
TypeScript
import { createSignal, Show, For, createMemo, createEffect, onMount, onCleanup } from 'solid-js';
|
||
import type { JSX } from 'solid-js';
|
||
import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
|
||
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
|
||
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
|
||
import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
|
||
import { Card } from '@/components/shared/Card';
|
||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||
import { SettingsPanel } from '@/components/shared/SettingsPanel';
|
||
import { Toggle } from '@/components/shared/Toggle';
|
||
import { formField, formControl, formHelpText, labelClass, controlClass } from '@/components/shared/Form';
|
||
import { useWebSocket } from '@/App';
|
||
import { showSuccess, showError } from '@/utils/toast';
|
||
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
|
||
import { AlertsAPI } from '@/api/alerts';
|
||
import { NotificationsAPI, Webhook } from '@/api/notifications';
|
||
import type { EmailConfig, AppriseConfig } from '@/api/notifications';
|
||
import type { HysteresisThreshold } from '@/types/alerts';
|
||
import type { Alert, State, VM, Container, DockerHost, DockerContainer } from '@/types/api';
|
||
import { useNavigate, useLocation } from '@solidjs/router';
|
||
import LayoutDashboard from 'lucide-solid/icons/layout-dashboard';
|
||
import History from 'lucide-solid/icons/history';
|
||
import Gauge from 'lucide-solid/icons/gauge';
|
||
import Send from 'lucide-solid/icons/send';
|
||
import Calendar from 'lucide-solid/icons/calendar';
|
||
|
||
type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history';
|
||
|
||
const ALERT_HEADER_META: Record<AlertTab, { title: string; description: string }> = {
|
||
overview: {
|
||
title: 'Alerts Overview',
|
||
description: 'Monitor active alerts, acknowledgements, and recent status changes across platforms.',
|
||
},
|
||
thresholds: {
|
||
title: 'Alert Thresholds',
|
||
description: 'Tune resource thresholds and override rules for nodes, guests, and containers.',
|
||
},
|
||
destinations: {
|
||
title: 'Notification Destinations',
|
||
description: 'Configure email, webhooks, and escalation paths for alert delivery.',
|
||
},
|
||
schedule: {
|
||
title: 'Maintenance Schedule',
|
||
description: 'Set quiet hours and maintenance windows to suppress alerts when expected changes occur.',
|
||
},
|
||
history: {
|
||
title: 'Alert History',
|
||
description: 'Review previously triggered alerts and their resolution timeline.',
|
||
},
|
||
};
|
||
|
||
export const ALERT_TAB_SEGMENTS: Record<AlertTab, string> = {
|
||
overview: 'overview',
|
||
thresholds: 'thresholds',
|
||
destinations: 'destinations',
|
||
schedule: 'schedule',
|
||
history: 'history',
|
||
};
|
||
|
||
export const pathForTab = (
|
||
tab: AlertTab,
|
||
segments: Record<AlertTab, string> = ALERT_TAB_SEGMENTS,
|
||
): string => {
|
||
const segment = segments[tab];
|
||
return segment ? `/alerts/${segment}` : '/alerts';
|
||
};
|
||
|
||
export const tabFromPath = (
|
||
pathname: string,
|
||
segments: Record<AlertTab, string> = ALERT_TAB_SEGMENTS,
|
||
): AlertTab => {
|
||
const normalizedPath = pathname.replace(/\/+$/, '') || '/alerts';
|
||
const parts = normalizedPath.split('/').filter(Boolean);
|
||
|
||
if (parts[0] !== 'alerts') {
|
||
return 'overview';
|
||
}
|
||
|
||
const segment = parts[1] ?? '';
|
||
if (!segment) {
|
||
return 'overview';
|
||
}
|
||
|
||
const entry = (Object.entries(segments) as [AlertTab, string][])
|
||
.find(([, value]) => value === segment);
|
||
|
||
if (entry) {
|
||
return entry[0];
|
||
}
|
||
|
||
if (segment === 'custom-rules') {
|
||
return 'thresholds';
|
||
}
|
||
|
||
return 'overview';
|
||
};
|
||
|
||
// Store reference interfaces
|
||
interface DestinationsRef {
|
||
emailConfig?: () => EmailConfig;
|
||
appriseConfig?: () => AppriseConfig;
|
||
}
|
||
|
||
// Override interface for both guests and nodes
|
||
type OverrideType =
|
||
| 'guest'
|
||
| 'node'
|
||
| 'storage'
|
||
| 'pbs'
|
||
| 'pmg'
|
||
| 'dockerHost'
|
||
| 'dockerContainer';
|
||
|
||
interface Override {
|
||
id: string; // Full ID (e.g. "Main-node1-105" for guest, "node-node1" for node, "pbs-name" for PBS)
|
||
name: string; // Display name
|
||
type: OverrideType;
|
||
resourceType?: string; // VM, CT, Node, Storage, or PBS
|
||
vmid?: number; // Only for guests
|
||
node?: string; // Node name (for guests and storage), undefined for nodes themselves
|
||
instance?: string;
|
||
disabled?: boolean; // Completely disable alerts for this guest/storage
|
||
disableConnectivity?: boolean; // For nodes - disable offline/connectivity alerts
|
||
poweredOffSeverity?: 'warning' | 'critical';
|
||
thresholds: {
|
||
cpu?: number;
|
||
memory?: number;
|
||
disk?: number;
|
||
diskRead?: number;
|
||
diskWrite?: number;
|
||
networkIn?: number;
|
||
networkOut?: number;
|
||
usage?: number; // For storage devices
|
||
};
|
||
}
|
||
|
||
// Local email config with UI-specific fields
|
||
interface UIEmailConfig {
|
||
enabled: boolean;
|
||
provider: string;
|
||
server: string; // Fixed: use 'server' not 'smtpHost'
|
||
port: number; // Fixed: use 'port' not 'smtpPort'
|
||
username: string;
|
||
password: string;
|
||
from: string;
|
||
to: string[];
|
||
tls: boolean;
|
||
startTLS: boolean;
|
||
replyTo: string;
|
||
maxRetries: number;
|
||
retryDelay: number;
|
||
rateLimit: number;
|
||
}
|
||
|
||
interface UIAppriseConfig {
|
||
enabled: boolean;
|
||
targetsText: string;
|
||
cliPath: string;
|
||
timeoutSeconds: number;
|
||
}
|
||
|
||
interface QuietHoursConfig {
|
||
enabled: boolean;
|
||
start: string;
|
||
end: string;
|
||
timezone: string;
|
||
days: Record<string, boolean>;
|
||
suppress: {
|
||
performance: boolean;
|
||
storage: boolean;
|
||
offline: boolean;
|
||
};
|
||
}
|
||
|
||
interface CooldownConfig {
|
||
enabled: boolean;
|
||
minutes: number;
|
||
maxAlerts: number;
|
||
}
|
||
|
||
interface GroupingConfig {
|
||
enabled: boolean;
|
||
window: number;
|
||
maxGroupSize?: number;
|
||
byNode?: boolean;
|
||
byGuest?: boolean;
|
||
}
|
||
|
||
type EscalationNotifyTarget = 'email' | 'webhook' | 'all';
|
||
|
||
interface EscalationLevel {
|
||
after: number;
|
||
notify: EscalationNotifyTarget;
|
||
}
|
||
|
||
interface EscalationConfig {
|
||
enabled: boolean;
|
||
levels: EscalationLevel[];
|
||
}
|
||
|
||
const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||
|
||
export const createDefaultQuietHours = (): QuietHoursConfig => ({
|
||
enabled: false,
|
||
start: '22:00',
|
||
end: '08:00',
|
||
timezone: getLocalTimezone(),
|
||
days: {
|
||
monday: true,
|
||
tuesday: true,
|
||
wednesday: true,
|
||
thursday: true,
|
||
friday: true,
|
||
saturday: false,
|
||
sunday: false,
|
||
},
|
||
suppress: {
|
||
performance: false,
|
||
storage: false,
|
||
offline: false,
|
||
},
|
||
});
|
||
|
||
export const createDefaultCooldown = (): CooldownConfig => ({
|
||
enabled: true,
|
||
minutes: 30,
|
||
maxAlerts: 3,
|
||
});
|
||
|
||
export const createDefaultGrouping = (): GroupingConfig => ({
|
||
enabled: true,
|
||
window: 5,
|
||
byNode: true,
|
||
byGuest: false,
|
||
});
|
||
|
||
const createDefaultAppriseConfig = (): UIAppriseConfig => ({
|
||
enabled: false,
|
||
targetsText: '',
|
||
cliPath: 'apprise',
|
||
timeoutSeconds: 15,
|
||
});
|
||
|
||
const parseAppriseTargets = (value: string): string[] =>
|
||
value
|
||
.split(/\r?\n|,/)
|
||
.map((entry) => entry.trim())
|
||
.filter((entry, index, arr) => entry.length > 0 && arr.indexOf(entry) === index);
|
||
|
||
const formatAppriseTargets = (targets: string[] | undefined | null): string =>
|
||
targets && targets.length > 0 ? targets.join('\n') : '';
|
||
|
||
export const normalizeMetricDelayMap = (
|
||
input: Record<string, Record<string, number>> | undefined | null,
|
||
): Record<string, Record<string, number>> => {
|
||
if (!input) return {};
|
||
const normalized: Record<string, Record<string, number>> = {};
|
||
|
||
Object.entries(input).forEach(([rawType, metrics]) => {
|
||
if (!metrics) return;
|
||
const typeKey = rawType.trim().toLowerCase();
|
||
if (!typeKey) return;
|
||
|
||
const entries: Record<string, number> = {};
|
||
Object.entries(metrics).forEach(([rawMetric, value]) => {
|
||
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) return;
|
||
const metricKey = rawMetric.trim().toLowerCase();
|
||
if (!metricKey) return;
|
||
entries[metricKey] = Math.round(value);
|
||
});
|
||
|
||
if (Object.keys(entries).length > 0) {
|
||
normalized[typeKey] = entries;
|
||
}
|
||
});
|
||
|
||
return normalized;
|
||
};
|
||
|
||
export const createDefaultEscalation = (): EscalationConfig => ({
|
||
enabled: false,
|
||
levels: [],
|
||
});
|
||
|
||
export const getTriggerValue = (
|
||
threshold: number | boolean | HysteresisThreshold | undefined,
|
||
): number => {
|
||
if (typeof threshold === 'number') {
|
||
return threshold; // Legacy format
|
||
}
|
||
if (typeof threshold === 'boolean') {
|
||
return 0;
|
||
}
|
||
if (threshold && typeof threshold === 'object' && 'trigger' in threshold) {
|
||
return threshold.trigger; // New hysteresis format
|
||
}
|
||
return 0; // Default fallback
|
||
};
|
||
|
||
export const extractTriggerValues = (
|
||
thresholds: RawOverrideConfig,
|
||
): Record<string, number> => {
|
||
const result: Record<string, number> = {};
|
||
Object.entries(thresholds).forEach(([key, value]) => {
|
||
// Skip non-threshold fields
|
||
if (key === 'disabled' || key === 'disableConnectivity' || key === 'poweredOffSeverity') return;
|
||
if (typeof value === 'string') return;
|
||
result[key] = getTriggerValue(value);
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const DEFAULT_DELAY_SECONDS = 5;
|
||
|
||
export function Alerts() {
|
||
const { state, activeAlerts, updateAlert, removeAlerts } = useWebSocket();
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
|
||
const [activeTab, setActiveTab] = createSignal<AlertTab>(tabFromPath(location.pathname));
|
||
|
||
const headerMeta = () =>
|
||
ALERT_HEADER_META[activeTab()] ?? {
|
||
title: 'Alerts',
|
||
description: 'Manage alerting configuration.',
|
||
};
|
||
|
||
createEffect(() => {
|
||
const currentPath = location.pathname;
|
||
const tab = tabFromPath(currentPath);
|
||
|
||
if (tab !== activeTab()) {
|
||
setActiveTab(tab);
|
||
}
|
||
|
||
const expectedPath = pathForTab(tab);
|
||
|
||
// Allow sub-paths for thresholds tab (e.g., /alerts/thresholds/proxmox)
|
||
const isThresholdsSubPath = tab === 'thresholds' && currentPath.startsWith('/alerts/thresholds/');
|
||
|
||
if (currentPath !== expectedPath && !isThresholdsSubPath) {
|
||
navigate(expectedPath, { replace: true });
|
||
}
|
||
});
|
||
|
||
const handleTabChange = (tab: AlertTab) => {
|
||
const targetPath = pathForTab(tab);
|
||
if (location.pathname !== targetPath) {
|
||
navigate(targetPath);
|
||
}
|
||
};
|
||
|
||
const [hasUnsavedChanges, setHasUnsavedChanges] = createSignal(false);
|
||
const [isReloadingConfig, setIsReloadingConfig] = createSignal(false);
|
||
const [showAcknowledged, setShowAcknowledged] = createSignal(true);
|
||
// Quick tip visibility state
|
||
const [showQuickTip, setShowQuickTip] = createSignal(
|
||
localStorage.getItem('hideAlertsQuickTip') !== 'true',
|
||
);
|
||
|
||
const dismissQuickTip = () => {
|
||
setShowQuickTip(false);
|
||
localStorage.setItem('hideAlertsQuickTip', 'true');
|
||
};
|
||
|
||
// Store references to child component data
|
||
let destinationsRef: DestinationsRef = {};
|
||
|
||
const [overrides, setOverrides] = createSignal<Override[]>([]);
|
||
const [rawOverridesConfig, setRawOverridesConfig] = createSignal<
|
||
Record<string, RawOverrideConfig>
|
||
>({}); // Store raw config
|
||
|
||
// Email configuration state moved to parent to persist across tab changes
|
||
const [emailConfig, setEmailConfig] = createSignal<UIEmailConfig>({
|
||
enabled: false,
|
||
provider: '',
|
||
server: '', // Fixed: use 'server' not 'smtpHost'
|
||
port: 587, // Fixed: use 'port' not 'smtpPort'
|
||
username: '',
|
||
password: '',
|
||
from: '',
|
||
to: [] as string[],
|
||
tls: true,
|
||
startTLS: false,
|
||
replyTo: '',
|
||
maxRetries: 3,
|
||
retryDelay: 5,
|
||
rateLimit: 60,
|
||
});
|
||
|
||
const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
||
createDefaultAppriseConfig(),
|
||
);
|
||
|
||
// Schedule configuration state moved to parent to persist across tab changes
|
||
const [scheduleQuietHours, setScheduleQuietHours] =
|
||
createSignal<QuietHoursConfig>(createDefaultQuietHours());
|
||
|
||
const [scheduleCooldown, setScheduleCooldown] =
|
||
createSignal<CooldownConfig>(createDefaultCooldown());
|
||
|
||
const [scheduleGrouping, setScheduleGrouping] =
|
||
createSignal<GroupingConfig>(createDefaultGrouping());
|
||
|
||
const [scheduleEscalation, setScheduleEscalation] =
|
||
createSignal<EscalationConfig>(createDefaultEscalation());
|
||
|
||
// Set up destinationsRef.emailConfig function immediately
|
||
destinationsRef.emailConfig = () => {
|
||
const config = emailConfig();
|
||
return {
|
||
enabled: config.enabled,
|
||
provider: config.provider,
|
||
server: config.server, // Fixed: use correct property name
|
||
port: config.port, // Fixed: use correct property name
|
||
username: config.username,
|
||
password: config.password,
|
||
from: config.from,
|
||
to: config.to,
|
||
tls: config.tls,
|
||
startTLS: config.startTLS,
|
||
} as EmailConfig;
|
||
};
|
||
|
||
destinationsRef.appriseConfig = () => {
|
||
const config = appriseConfig();
|
||
return {
|
||
enabled: config.enabled,
|
||
targets: parseAppriseTargets(config.targetsText),
|
||
cliPath: config.cliPath,
|
||
timeoutSeconds: config.timeoutSeconds,
|
||
} as AppriseConfig;
|
||
};
|
||
|
||
// Process raw overrides config when state changes
|
||
createEffect(() => {
|
||
// Skip this effect if there are unsaved changes to prevent losing focus
|
||
if (hasUnsavedChanges()) {
|
||
return;
|
||
}
|
||
|
||
const rawConfig = rawOverridesConfig();
|
||
if (
|
||
Object.keys(rawConfig).length > 0 &&
|
||
state.nodes &&
|
||
state.vms &&
|
||
state.containers &&
|
||
state.storage
|
||
) {
|
||
// Convert overrides object to array format
|
||
const overridesList: Override[] = [];
|
||
|
||
const dockerHostsList: DockerHost[] = state.dockerHosts || [];
|
||
const dockerHostMap = new Map<string, DockerHost>();
|
||
const dockerContainerMap = new Map<string, { host: DockerHost; container: DockerContainer }>();
|
||
|
||
dockerHostsList.forEach((host) => {
|
||
dockerHostMap.set(host.id, host);
|
||
(host.containers || []).forEach((container) => {
|
||
const resourceId = `docker:${host.id}/${container.id}`;
|
||
dockerContainerMap.set(resourceId, { host, container });
|
||
});
|
||
});
|
||
|
||
Object.entries(rawConfig).forEach(([key, thresholds]) => {
|
||
// Docker host override stored by host ID
|
||
const dockerHost = dockerHostMap.get(key);
|
||
if (dockerHost) {
|
||
overridesList.push({
|
||
id: key,
|
||
name: dockerHost.displayName?.trim() || dockerHost.hostname || dockerHost.id,
|
||
type: 'dockerHost',
|
||
resourceType: 'Docker Host',
|
||
disableConnectivity: thresholds.disableConnectivity || false,
|
||
thresholds: extractTriggerValues(thresholds),
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Docker container override stored as docker:hostId/containerId
|
||
const dockerContainer = dockerContainerMap.get(key);
|
||
if (dockerContainer) {
|
||
const { host, container } = dockerContainer;
|
||
const containerName = container.name?.replace(/^\/+/, '') || container.id;
|
||
overridesList.push({
|
||
id: key,
|
||
name: containerName,
|
||
type: 'dockerContainer',
|
||
resourceType: 'Docker Container',
|
||
node: host.hostname,
|
||
instance: host.displayName,
|
||
disabled: thresholds.disabled || false,
|
||
disableConnectivity: thresholds.disableConnectivity || false,
|
||
poweredOffSeverity:
|
||
thresholds.poweredOffSeverity === 'critical'
|
||
? 'critical'
|
||
: thresholds.poweredOffSeverity === 'warning'
|
||
? 'warning'
|
||
: undefined,
|
||
thresholds: extractTriggerValues(thresholds),
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (key.startsWith('docker:')) {
|
||
// Handle docker overrides where the host/container is no longer reporting
|
||
const [, rest] = key.split(':', 2);
|
||
const [hostId, containerId] = (rest || '').split('/', 2);
|
||
|
||
if (containerId) {
|
||
overridesList.push({
|
||
id: key,
|
||
name: containerId,
|
||
type: 'dockerContainer',
|
||
resourceType: 'Docker Container',
|
||
node: hostId,
|
||
disabled: thresholds.disabled || false,
|
||
disableConnectivity: thresholds.disableConnectivity || false,
|
||
poweredOffSeverity:
|
||
thresholds.poweredOffSeverity === 'critical'
|
||
? 'critical'
|
||
: thresholds.poweredOffSeverity === 'warning'
|
||
? 'warning'
|
||
: undefined,
|
||
thresholds: extractTriggerValues(thresholds),
|
||
});
|
||
return;
|
||
}
|
||
|
||
overridesList.push({
|
||
id: hostId || key,
|
||
name: hostId || key,
|
||
type: 'dockerHost',
|
||
resourceType: 'Docker Host',
|
||
disableConnectivity: thresholds.disableConnectivity || false,
|
||
thresholds: extractTriggerValues(thresholds),
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Check if it's a PBS server override (starts with "pbs-")
|
||
if (key.startsWith('pbs-')) {
|
||
const pbs = (state.pbs || []).find((p) => p.id === key);
|
||
if (pbs) {
|
||
overridesList.push({
|
||
id: key,
|
||
name: pbs.name,
|
||
type: 'pbs',
|
||
resourceType: 'PBS',
|
||
disableConnectivity: thresholds.disableConnectivity || false,
|
||
thresholds: extractTriggerValues(thresholds),
|
||
});
|
||
}
|
||
} else {
|
||
// Check if it's a node override by looking for matching node
|
||
const node = (state.nodes || []).find((n) => n.id === key);
|
||
if (node) {
|
||
overridesList.push({
|
||
id: key,
|
||
name: node.name,
|
||
type: 'node',
|
||
resourceType: 'Node',
|
||
disableConnectivity: thresholds.disableConnectivity || false,
|
||
thresholds: extractTriggerValues(thresholds),
|
||
});
|
||
} else {
|
||
// Check if it's a storage device
|
||
const storage = (state.storage || []).find((s) => s.id === key);
|
||
if (storage) {
|
||
overridesList.push({
|
||
id: key,
|
||
name: storage.name,
|
||
type: 'storage',
|
||
resourceType: 'Storage',
|
||
node: storage.node,
|
||
instance: storage.instance,
|
||
disabled: thresholds.disabled || false,
|
||
thresholds: extractTriggerValues(thresholds),
|
||
});
|
||
} else {
|
||
// Find the guest by matching the full ID
|
||
const vm = (state.vms || []).find((g) => g.id === key);
|
||
const container = (state.containers || []).find((g) => g.id === key);
|
||
const guest = vm || container;
|
||
if (guest) {
|
||
overridesList.push({
|
||
id: key,
|
||
name: guest.name,
|
||
type: 'guest',
|
||
resourceType: guest.type === 'qemu' ? 'VM' : 'CT',
|
||
vmid: guest.vmid,
|
||
node: guest.node,
|
||
instance: guest.instance,
|
||
disabled: thresholds.disabled || false,
|
||
poweredOffSeverity:
|
||
thresholds.poweredOffSeverity === 'critical'
|
||
? 'critical'
|
||
: thresholds.poweredOffSeverity === 'warning'
|
||
? 'warning'
|
||
: undefined,
|
||
thresholds: extractTriggerValues(thresholds),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Only update if there's an actual change to prevent losing edit state
|
||
const currentOverrides = overrides();
|
||
const hasChanged =
|
||
overridesList.length !== currentOverrides.length ||
|
||
overridesList.some((newOverride) => {
|
||
const existing = currentOverrides.find((o) => o.id === newOverride.id);
|
||
if (!existing) return true;
|
||
// Check both thresholds and disableConnectivity for nodes/PBS
|
||
const thresholdsChanged =
|
||
JSON.stringify(newOverride.thresholds) !== JSON.stringify(existing.thresholds);
|
||
const connectivityChanged =
|
||
(newOverride.type === 'node' ||
|
||
newOverride.type === 'pbs' ||
|
||
newOverride.type === 'dockerContainer') &&
|
||
newOverride.disableConnectivity !== existing.disableConnectivity;
|
||
const disabledChanged =
|
||
(newOverride.type === 'guest' || newOverride.type === 'storage') &&
|
||
newOverride.disabled !== existing.disabled;
|
||
const severityChanged =
|
||
(newOverride.type === 'guest' || newOverride.type === 'dockerContainer') &&
|
||
(newOverride.poweredOffSeverity ?? null) !==
|
||
(existing.poweredOffSeverity ?? null);
|
||
return (
|
||
thresholdsChanged ||
|
||
connectivityChanged ||
|
||
disabledChanged ||
|
||
severityChanged
|
||
);
|
||
});
|
||
|
||
if (hasChanged) {
|
||
setOverrides(overridesList);
|
||
}
|
||
}
|
||
});
|
||
|
||
const loadAlertConfiguration = async (options: { notify?: boolean } = {}) => {
|
||
setIsReloadingConfig(true);
|
||
setHasUnsavedChanges(false);
|
||
|
||
// Reset to defaults before applying server state
|
||
setGuestDefaults({
|
||
cpu: 80,
|
||
memory: 85,
|
||
disk: 90,
|
||
diskRead: -1,
|
||
diskWrite: -1,
|
||
networkIn: -1,
|
||
networkOut: -1,
|
||
});
|
||
setGuestDisableConnectivity(false);
|
||
setGuestPoweredOffSeverity('warning');
|
||
setNodeDefaults({
|
||
cpu: 80,
|
||
memory: 85,
|
||
disk: 90,
|
||
temperature: 80,
|
||
});
|
||
setStorageDefault(85);
|
||
setTimeThreshold(DEFAULT_DELAY_SECONDS);
|
||
setTimeThresholds({
|
||
guest: DEFAULT_DELAY_SECONDS,
|
||
node: DEFAULT_DELAY_SECONDS,
|
||
storage: DEFAULT_DELAY_SECONDS,
|
||
pbs: DEFAULT_DELAY_SECONDS,
|
||
});
|
||
setMetricTimeThresholds({});
|
||
setScheduleQuietHours(createDefaultQuietHours());
|
||
setScheduleCooldown(createDefaultCooldown());
|
||
setScheduleGrouping(createDefaultGrouping());
|
||
setScheduleEscalation(createDefaultEscalation());
|
||
|
||
setEmailConfig({
|
||
enabled: false,
|
||
provider: '',
|
||
server: '',
|
||
port: 587,
|
||
username: '',
|
||
password: '',
|
||
from: '',
|
||
to: [],
|
||
tls: true,
|
||
startTLS: false,
|
||
replyTo: '',
|
||
maxRetries: 3,
|
||
retryDelay: 5,
|
||
rateLimit: 60,
|
||
});
|
||
|
||
setAppriseConfig(createDefaultAppriseConfig());
|
||
|
||
try {
|
||
const config = await AlertsAPI.getConfig();
|
||
|
||
if (config.guestDefaults) {
|
||
setGuestDefaults({
|
||
cpu: getTriggerValue(config.guestDefaults.cpu) ?? 80,
|
||
memory: getTriggerValue(config.guestDefaults.memory) ?? 85,
|
||
disk: getTriggerValue(config.guestDefaults.disk) ?? 90,
|
||
diskRead: getTriggerValue(config.guestDefaults.diskRead) ?? -1,
|
||
diskWrite: getTriggerValue(config.guestDefaults.diskWrite) ?? -1,
|
||
networkIn: getTriggerValue(config.guestDefaults.networkIn) ?? -1,
|
||
networkOut: getTriggerValue(config.guestDefaults.networkOut) ?? -1,
|
||
});
|
||
setGuestDisableConnectivity(Boolean(config.guestDefaults.disableConnectivity));
|
||
setGuestPoweredOffSeverity(
|
||
config.guestDefaults.poweredOffSeverity === 'critical' ? 'critical' : 'warning',
|
||
);
|
||
} else {
|
||
setGuestDisableConnectivity(false);
|
||
setGuestPoweredOffSeverity('warning');
|
||
}
|
||
|
||
if (config.nodeDefaults) {
|
||
setNodeDefaults({
|
||
cpu: getTriggerValue(config.nodeDefaults.cpu) ?? 80,
|
||
memory: getTriggerValue(config.nodeDefaults.memory) ?? 85,
|
||
disk: getTriggerValue(config.nodeDefaults.disk) ?? 90,
|
||
temperature: getTriggerValue(config.nodeDefaults.temperature) ?? 80,
|
||
});
|
||
}
|
||
|
||
if (config.dockerDefaults) {
|
||
setDockerDefaults({
|
||
cpu: getTriggerValue(config.dockerDefaults.cpu) ?? 80,
|
||
memory: getTriggerValue(config.dockerDefaults.memory) ?? 85,
|
||
restartCount: config.dockerDefaults.restartCount ?? 3,
|
||
restartWindow: config.dockerDefaults.restartWindow ?? 300,
|
||
memoryWarnPct: config.dockerDefaults.memoryWarnPct ?? 90,
|
||
memoryCriticalPct: config.dockerDefaults.memoryCriticalPct ?? 95,
|
||
});
|
||
}
|
||
setDockerIgnoredPrefixes(config.dockerIgnoredContainerPrefixes ?? []);
|
||
|
||
if (config.storageDefault) {
|
||
setStorageDefault(getTriggerValue(config.storageDefault) ?? 85);
|
||
}
|
||
if (config.timeThreshold !== undefined) {
|
||
setTimeThreshold(config.timeThreshold);
|
||
}
|
||
if (config.timeThresholds) {
|
||
setTimeThresholds({
|
||
guest: config.timeThresholds.guest ?? DEFAULT_DELAY_SECONDS,
|
||
node: config.timeThresholds.node ?? DEFAULT_DELAY_SECONDS,
|
||
storage: config.timeThresholds.storage ?? DEFAULT_DELAY_SECONDS,
|
||
pbs: config.timeThresholds.pbs ?? DEFAULT_DELAY_SECONDS,
|
||
});
|
||
} else {
|
||
const fallback = config.timeThreshold && config.timeThreshold > 0 ? config.timeThreshold : DEFAULT_DELAY_SECONDS;
|
||
setTimeThresholds({
|
||
guest: fallback,
|
||
node: fallback,
|
||
storage: fallback,
|
||
pbs: fallback,
|
||
});
|
||
}
|
||
if (config.metricTimeThresholds) {
|
||
setMetricTimeThresholds(normalizeMetricDelayMap(config.metricTimeThresholds));
|
||
} else {
|
||
setMetricTimeThresholds({});
|
||
}
|
||
|
||
// Load backup thresholds
|
||
if (config.backupDefaults) {
|
||
const enabled = Boolean(config.backupDefaults.enabled);
|
||
const rawWarning = config.backupDefaults.warningDays ?? FACTORY_BACKUP_DEFAULTS.warningDays;
|
||
const rawCritical = config.backupDefaults.criticalDays ?? FACTORY_BACKUP_DEFAULTS.criticalDays;
|
||
const safeCritical = Math.max(0, rawCritical);
|
||
const normalizedWarning = Math.max(0, rawWarning);
|
||
const warningDays =
|
||
safeCritical > 0 && normalizedWarning > safeCritical ? safeCritical : normalizedWarning;
|
||
const criticalDays = Math.max(safeCritical, warningDays);
|
||
setBackupDefaults({
|
||
enabled,
|
||
warningDays,
|
||
criticalDays,
|
||
});
|
||
} else {
|
||
setBackupDefaults({ ...FACTORY_BACKUP_DEFAULTS });
|
||
}
|
||
|
||
// Load snapshot thresholds
|
||
if (config.snapshotDefaults) {
|
||
const enabled = Boolean(config.snapshotDefaults.enabled);
|
||
const rawWarning = config.snapshotDefaults.warningDays ?? 30;
|
||
const rawCritical = config.snapshotDefaults.criticalDays ?? 45;
|
||
const safeCritical = Math.max(0, rawCritical);
|
||
const normalizedWarning = Math.max(0, rawWarning);
|
||
const warningDays =
|
||
safeCritical > 0 && normalizedWarning > safeCritical ? safeCritical : normalizedWarning;
|
||
const criticalDays = Math.max(safeCritical, warningDays);
|
||
setSnapshotDefaults({
|
||
enabled,
|
||
warningDays,
|
||
criticalDays,
|
||
});
|
||
} else {
|
||
setSnapshotDefaults({
|
||
enabled: false,
|
||
warningDays: 30,
|
||
criticalDays: 45,
|
||
});
|
||
}
|
||
|
||
// Load PMG thresholds
|
||
if (config.pmgDefaults) {
|
||
setPMGThresholds({
|
||
queueTotalWarning: config.pmgDefaults.queueTotalWarning ?? 500,
|
||
queueTotalCritical: config.pmgDefaults.queueTotalCritical ?? 1000,
|
||
oldestMessageWarnMins: config.pmgDefaults.oldestMessageWarnMins ?? 30,
|
||
oldestMessageCritMins: config.pmgDefaults.oldestMessageCritMins ?? 60,
|
||
deferredQueueWarn: config.pmgDefaults.deferredQueueWarn ?? 200,
|
||
deferredQueueCritical: config.pmgDefaults.deferredQueueCritical ?? 500,
|
||
holdQueueWarn: config.pmgDefaults.holdQueueWarn ?? 100,
|
||
holdQueueCritical: config.pmgDefaults.holdQueueCritical ?? 300,
|
||
quarantineSpamWarn: config.pmgDefaults.quarantineSpamWarn ?? 2000,
|
||
quarantineSpamCritical: config.pmgDefaults.quarantineSpamCritical ?? 5000,
|
||
quarantineVirusWarn: config.pmgDefaults.quarantineVirusWarn ?? 2000,
|
||
quarantineVirusCritical: config.pmgDefaults.quarantineVirusCritical ?? 5000,
|
||
quarantineGrowthWarnPct: config.pmgDefaults.quarantineGrowthWarnPct ?? 25,
|
||
quarantineGrowthWarnMin: config.pmgDefaults.quarantineGrowthWarnMin ?? 250,
|
||
quarantineGrowthCritPct: config.pmgDefaults.quarantineGrowthCritPct ?? 50,
|
||
quarantineGrowthCritMin: config.pmgDefaults.quarantineGrowthCritMin ?? 500,
|
||
});
|
||
}
|
||
|
||
// Load global disable flags
|
||
setDisableAllNodes(config.disableAllNodes ?? false);
|
||
setDisableAllGuests(config.disableAllGuests ?? false);
|
||
setDisableAllStorage(config.disableAllStorage ?? false);
|
||
setDisableAllPBS(config.disableAllPBS ?? false);
|
||
setDisableAllPMG(config.disableAllPMG ?? false);
|
||
setDisableAllDockerHosts(config.disableAllDockerHosts ?? false);
|
||
setDisableAllDockerContainers(config.disableAllDockerContainers ?? false);
|
||
|
||
// Load global disable offline alerts flags
|
||
setDisableAllNodesOffline(config.disableAllNodesOffline ?? false);
|
||
setDisableAllGuestsOffline(config.disableAllGuestsOffline ?? false);
|
||
setDisableAllPBSOffline(config.disableAllPBSOffline ?? false);
|
||
setDisableAllPMGOffline(config.disableAllPMGOffline ?? false);
|
||
setDisableAllDockerHostsOffline(config.disableAllDockerHostsOffline ?? false);
|
||
|
||
setRawOverridesConfig(config.overrides || {});
|
||
|
||
if (config.schedule) {
|
||
if (config.schedule.quietHours) {
|
||
const qh = config.schedule.quietHours;
|
||
let days: Record<string, boolean>;
|
||
if (Array.isArray(qh.days)) {
|
||
days = {
|
||
sunday: qh.days.includes(0),
|
||
monday: qh.days.includes(1),
|
||
tuesday: qh.days.includes(2),
|
||
wednesday: qh.days.includes(3),
|
||
thursday: qh.days.includes(4),
|
||
friday: qh.days.includes(5),
|
||
saturday: qh.days.includes(6),
|
||
};
|
||
} else {
|
||
days = (qh.days as Record<string, boolean>) || createDefaultQuietHours().days;
|
||
}
|
||
const suppress = {
|
||
performance: qh.suppress?.performance ?? false,
|
||
storage: qh.suppress?.storage ?? false,
|
||
offline: qh.suppress?.offline ?? false,
|
||
};
|
||
|
||
setScheduleQuietHours({
|
||
enabled: qh.enabled || false,
|
||
start: qh.start || '22:00',
|
||
end: qh.end || '08:00',
|
||
timezone: qh.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||
days,
|
||
suppress,
|
||
});
|
||
}
|
||
|
||
if (config.schedule.cooldown !== undefined) {
|
||
setScheduleCooldown({
|
||
enabled: config.schedule.cooldown > 0,
|
||
minutes: config.schedule.cooldown,
|
||
maxAlerts: config.schedule.maxAlertsHour || 3,
|
||
});
|
||
}
|
||
|
||
if (config.schedule.grouping) {
|
||
setScheduleGrouping({
|
||
enabled: config.schedule.grouping.enabled || false,
|
||
window: Math.floor((config.schedule.grouping.window || 300) / 60),
|
||
byNode:
|
||
config.schedule.grouping.byNode !== undefined
|
||
? config.schedule.grouping.byNode
|
||
: true,
|
||
byGuest:
|
||
config.schedule.grouping.byGuest !== undefined
|
||
? config.schedule.grouping.byGuest
|
||
: false,
|
||
});
|
||
} else if (config.schedule.groupingWindow !== undefined) {
|
||
setScheduleGrouping({
|
||
enabled: config.schedule.groupingWindow > 0,
|
||
window: Math.floor(config.schedule.groupingWindow / 60),
|
||
byNode: true,
|
||
byGuest: false,
|
||
});
|
||
}
|
||
|
||
if (config.schedule.escalation) {
|
||
const rawLevels = config.schedule.escalation.levels || [];
|
||
const levels = rawLevels.map((level) => ({
|
||
after: typeof level.after === 'number' ? level.after : 15,
|
||
notify: (level.notify as EscalationNotifyTarget) || 'all',
|
||
}));
|
||
setScheduleEscalation({
|
||
enabled: Boolean(config.schedule.escalation.enabled),
|
||
levels,
|
||
});
|
||
}
|
||
}
|
||
|
||
try {
|
||
const emailConfigData = await NotificationsAPI.getEmailConfig();
|
||
setEmailConfig({
|
||
enabled: emailConfigData.enabled,
|
||
provider: emailConfigData.provider || '',
|
||
server: emailConfigData.server || '',
|
||
port: emailConfigData.port || 587,
|
||
username: emailConfigData.username || '',
|
||
password: emailConfigData.password || '',
|
||
from: emailConfigData.from || '',
|
||
to: emailConfigData.to || [],
|
||
tls: emailConfigData.tls !== undefined ? emailConfigData.tls : true,
|
||
startTLS: emailConfigData.startTLS || false,
|
||
replyTo: '',
|
||
maxRetries: 3,
|
||
retryDelay: 5,
|
||
rateLimit: 60,
|
||
});
|
||
} catch (emailErr) {
|
||
console.error('Failed to load email configuration:', emailErr);
|
||
}
|
||
|
||
try {
|
||
const appriseData = await NotificationsAPI.getAppriseConfig();
|
||
setAppriseConfig({
|
||
enabled: appriseData.enabled ?? false,
|
||
targetsText: formatAppriseTargets(appriseData.targets),
|
||
cliPath: appriseData.cliPath || 'apprise',
|
||
timeoutSeconds:
|
||
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
||
? appriseData.timeoutSeconds
|
||
: 15,
|
||
});
|
||
} catch (appriseErr) {
|
||
console.error('Failed to load Apprise configuration:', appriseErr);
|
||
}
|
||
|
||
if (options.notify) {
|
||
showSuccess('Changes discarded');
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load alert configuration:', err);
|
||
if (options.notify) {
|
||
showError('Failed to reload configuration');
|
||
}
|
||
} finally {
|
||
setIsReloadingConfig(false);
|
||
}
|
||
};
|
||
|
||
// Load existing alert configuration on mount (only once)
|
||
onMount(() => {
|
||
void loadAlertConfiguration();
|
||
});
|
||
|
||
// Reload email config when switching to destinations tab
|
||
createEffect(() => {
|
||
if (activeTab() === 'destinations') {
|
||
// Reload email config from server when switching to destinations tab
|
||
NotificationsAPI.getEmailConfig()
|
||
.then((emailConfigData) => {
|
||
setEmailConfig({
|
||
enabled: emailConfigData.enabled,
|
||
provider: emailConfigData.provider || '',
|
||
server: emailConfigData.server || '',
|
||
port: emailConfigData.port || 587,
|
||
username: emailConfigData.username || '',
|
||
password: emailConfigData.password || '',
|
||
from: emailConfigData.from || '',
|
||
to: emailConfigData.to || [],
|
||
tls: emailConfigData.tls !== undefined ? emailConfigData.tls : true,
|
||
startTLS: emailConfigData.startTLS || false,
|
||
replyTo: '',
|
||
maxRetries: 3,
|
||
retryDelay: 5,
|
||
rateLimit: 60,
|
||
});
|
||
})
|
||
.catch((err) => {
|
||
console.error('Failed to reload email configuration:', err);
|
||
});
|
||
|
||
NotificationsAPI.getAppriseConfig()
|
||
.then((appriseData) => {
|
||
setAppriseConfig({
|
||
enabled: appriseData.enabled ?? false,
|
||
targetsText: formatAppriseTargets(appriseData.targets),
|
||
cliPath: appriseData.cliPath || 'apprise',
|
||
timeoutSeconds:
|
||
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
||
? appriseData.timeoutSeconds
|
||
: 15,
|
||
});
|
||
})
|
||
.catch((err) => {
|
||
console.error('Failed to reload Apprise configuration:', err);
|
||
});
|
||
}
|
||
});
|
||
|
||
// Get all guests from state - memoize to prevent unnecessary updates
|
||
const allGuests = createMemo(
|
||
() => {
|
||
const vms = state.vms || [];
|
||
const containers = state.containers || [];
|
||
return [...vms, ...containers];
|
||
},
|
||
[],
|
||
{
|
||
equals: (prev, next) => {
|
||
// Only update if the actual guest list changed
|
||
if (prev.length !== next.length) return false;
|
||
return prev.every((p, i) => p.vmid === next[i].vmid && p.name === next[i].name);
|
||
},
|
||
},
|
||
);
|
||
|
||
// Factory defaults - constants for reset functionality
|
||
const FACTORY_GUEST_DEFAULTS = {
|
||
cpu: 80,
|
||
memory: 85,
|
||
disk: 90,
|
||
diskRead: -1,
|
||
diskWrite: -1,
|
||
networkIn: -1,
|
||
networkOut: -1,
|
||
};
|
||
|
||
const FACTORY_NODE_DEFAULTS = {
|
||
cpu: 80,
|
||
memory: 85,
|
||
disk: 90,
|
||
temperature: 80,
|
||
};
|
||
|
||
const FACTORY_DOCKER_DEFAULTS = {
|
||
cpu: 80,
|
||
memory: 85,
|
||
restartCount: 3,
|
||
restartWindow: 300,
|
||
memoryWarnPct: 90,
|
||
memoryCriticalPct: 95,
|
||
};
|
||
|
||
const FACTORY_STORAGE_DEFAULT = 85;
|
||
const FACTORY_SNAPSHOT_DEFAULTS: SnapshotAlertConfig = {
|
||
enabled: false,
|
||
warningDays: 30,
|
||
criticalDays: 45,
|
||
};
|
||
const FACTORY_BACKUP_DEFAULTS: BackupAlertConfig = {
|
||
enabled: false,
|
||
warningDays: 7,
|
||
criticalDays: 14,
|
||
};
|
||
|
||
// Threshold states - using trigger values for display
|
||
const [guestDefaults, setGuestDefaults] = createSignal<Record<string, number | undefined>>({ ...FACTORY_GUEST_DEFAULTS });
|
||
const [guestDisableConnectivity, setGuestDisableConnectivity] = createSignal(false);
|
||
const [guestPoweredOffSeverity, setGuestPoweredOffSeverity] = createSignal<'warning' | 'critical'>('warning');
|
||
|
||
const [nodeDefaults, setNodeDefaults] = createSignal<Record<string, number | undefined>>({ ...FACTORY_NODE_DEFAULTS });
|
||
|
||
const [dockerDefaults, setDockerDefaults] = createSignal({ ...FACTORY_DOCKER_DEFAULTS });
|
||
const [dockerIgnoredPrefixes, setDockerIgnoredPrefixes] = createSignal<string[]>([]);
|
||
|
||
const [storageDefault, setStorageDefault] = createSignal(FACTORY_STORAGE_DEFAULT);
|
||
const [backupDefaults, setBackupDefaults] = createSignal<BackupAlertConfig>({
|
||
...FACTORY_BACKUP_DEFAULTS,
|
||
});
|
||
|
||
// Reset functions
|
||
const resetGuestDefaults = () => {
|
||
setGuestDefaults({ ...FACTORY_GUEST_DEFAULTS });
|
||
setHasUnsavedChanges(true);
|
||
};
|
||
|
||
const resetNodeDefaults = () => {
|
||
setNodeDefaults({ ...FACTORY_NODE_DEFAULTS });
|
||
setHasUnsavedChanges(true);
|
||
};
|
||
|
||
const resetDockerDefaults = () => {
|
||
setDockerDefaults({ ...FACTORY_DOCKER_DEFAULTS });
|
||
setHasUnsavedChanges(true);
|
||
};
|
||
|
||
const resetDockerIgnoredPrefixes = () => {
|
||
setDockerIgnoredPrefixes([]);
|
||
setHasUnsavedChanges(true);
|
||
};
|
||
|
||
const resetStorageDefault = () => {
|
||
setStorageDefault(FACTORY_STORAGE_DEFAULT);
|
||
setHasUnsavedChanges(true);
|
||
};
|
||
const resetBackupDefaults = () => {
|
||
setBackupDefaults({ ...FACTORY_BACKUP_DEFAULTS });
|
||
setHasUnsavedChanges(true);
|
||
};
|
||
const resetSnapshotDefaults = () => {
|
||
setSnapshotDefaults({ ...FACTORY_SNAPSHOT_DEFAULTS });
|
||
setHasUnsavedChanges(true);
|
||
};
|
||
const [timeThreshold, setTimeThreshold] = createSignal(DEFAULT_DELAY_SECONDS); // Legacy
|
||
const [timeThresholds, setTimeThresholds] = createSignal({
|
||
guest: DEFAULT_DELAY_SECONDS,
|
||
node: DEFAULT_DELAY_SECONDS,
|
||
storage: DEFAULT_DELAY_SECONDS,
|
||
pbs: DEFAULT_DELAY_SECONDS,
|
||
});
|
||
const [metricTimeThresholds, setMetricTimeThresholds] =
|
||
createSignal<Record<string, Record<string, number>>>({});
|
||
const [snapshotDefaults, setSnapshotDefaults] = createSignal<SnapshotAlertConfig>({
|
||
...FACTORY_SNAPSHOT_DEFAULTS,
|
||
});
|
||
|
||
const [pmgThresholds, setPMGThresholds] = createSignal({
|
||
queueTotalWarning: 500,
|
||
queueTotalCritical: 1000,
|
||
oldestMessageWarnMins: 30,
|
||
oldestMessageCritMins: 60,
|
||
deferredQueueWarn: 200,
|
||
deferredQueueCritical: 500,
|
||
holdQueueWarn: 100,
|
||
holdQueueCritical: 300,
|
||
quarantineSpamWarn: 2000,
|
||
quarantineSpamCritical: 5000,
|
||
quarantineVirusWarn: 2000,
|
||
quarantineVirusCritical: 5000,
|
||
quarantineGrowthWarnPct: 25,
|
||
quarantineGrowthWarnMin: 250,
|
||
quarantineGrowthCritPct: 50,
|
||
quarantineGrowthCritMin: 500,
|
||
});
|
||
|
||
// Global disable flags per resource type
|
||
const [disableAllNodes, setDisableAllNodes] = createSignal(false);
|
||
const [disableAllGuests, setDisableAllGuests] = createSignal(false);
|
||
const [disableAllStorage, setDisableAllStorage] = createSignal(false);
|
||
const [disableAllPBS, setDisableAllPBS] = createSignal(false);
|
||
const [disableAllPMG, setDisableAllPMG] = createSignal(false);
|
||
const [disableAllDockerHosts, setDisableAllDockerHosts] = createSignal(false);
|
||
const [disableAllDockerContainers, setDisableAllDockerContainers] = createSignal(false);
|
||
|
||
// Global disable offline alerts flags
|
||
const [disableAllNodesOffline, setDisableAllNodesOffline] = createSignal(false);
|
||
const [disableAllGuestsOffline, setDisableAllGuestsOffline] = createSignal(false);
|
||
const [disableAllPBSOffline, setDisableAllPBSOffline] = createSignal(false);
|
||
const [disableAllPMGOffline, setDisableAllPMGOffline] = createSignal(false);
|
||
const [disableAllDockerHostsOffline, setDisableAllDockerHostsOffline] = createSignal(false);
|
||
|
||
const tabGroups: {
|
||
id: 'status' | 'configuration';
|
||
label: string;
|
||
items: { id: AlertTab; label: string; icon: JSX.Element }[];
|
||
}[] = [
|
||
{
|
||
id: 'status',
|
||
label: 'Status',
|
||
items: [
|
||
{ id: 'overview', label: 'Overview', icon: <LayoutDashboard class="w-4 h-4" strokeWidth={2} /> },
|
||
{ id: 'history', label: 'History', icon: <History class="w-4 h-4" strokeWidth={2} /> },
|
||
],
|
||
},
|
||
{
|
||
id: 'configuration',
|
||
label: 'Configuration',
|
||
items: [
|
||
{ id: 'thresholds', label: 'Thresholds', icon: <Gauge class="w-4 h-4" strokeWidth={2} /> },
|
||
{ id: 'destinations', label: 'Notifications', icon: <Send class="w-4 h-4" strokeWidth={2} /> },
|
||
{ id: 'schedule', label: 'Schedule', icon: <Calendar class="w-4 h-4" strokeWidth={2} /> },
|
||
],
|
||
},
|
||
];
|
||
|
||
const flatTabs = tabGroups.flatMap((group) => group.items);
|
||
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(true);
|
||
|
||
return (
|
||
<div class="space-y-4">
|
||
{/* Header with better styling */}
|
||
<Card padding="md">
|
||
<SectionHeader
|
||
title={headerMeta().title}
|
||
description={headerMeta().description}
|
||
size="lg"
|
||
/>
|
||
</Card>
|
||
|
||
{/* Save notification bar - only show when there are unsaved changes */}
|
||
<Show when={hasUnsavedChanges() && activeTab() !== 'overview' && activeTab() !== 'history'}>
|
||
<Card tone="warning" padding="sm" class="border-yellow-200 dark:border-yellow-800 sm:p-4">
|
||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div class="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||
</svg>
|
||
<span class="text-sm font-medium">You have unsaved changes</span>
|
||
</div>
|
||
<div class="flex w-full gap-2 sm:w-auto">
|
||
<button
|
||
class="flex-1 px-4 py-2 text-sm text-white transition-colors sm:flex-initial bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed"
|
||
disabled={isReloadingConfig()}
|
||
onClick={async () => {
|
||
try {
|
||
// Save alert configuration with hysteresis format
|
||
const createHysteresisThreshold = (
|
||
trigger: number | undefined,
|
||
clearMargin: number = 5,
|
||
) => {
|
||
const normalized = typeof trigger === 'number' ? trigger : 0;
|
||
return {
|
||
trigger: normalized,
|
||
clear: Math.max(0, normalized - clearMargin),
|
||
};
|
||
};
|
||
|
||
const snapshotConfig = snapshotDefaults();
|
||
const normalizedSnapshotWarning = Math.max(0, snapshotConfig.warningDays ?? 0);
|
||
const normalizedSnapshotCritical = Math.max(0, snapshotConfig.criticalDays ?? 0);
|
||
const finalSnapshotCritical =
|
||
normalizedSnapshotCritical > 0
|
||
? Math.max(normalizedSnapshotCritical, normalizedSnapshotWarning)
|
||
: normalizedSnapshotWarning;
|
||
|
||
const backupConfig = backupDefaults();
|
||
const normalizedBackupWarning = Math.max(0, backupConfig.warningDays ?? 0);
|
||
const normalizedBackupCritical = Math.max(0, backupConfig.criticalDays ?? 0);
|
||
const finalBackupCritical =
|
||
normalizedBackupCritical > 0
|
||
? Math.max(normalizedBackupCritical, normalizedBackupWarning)
|
||
: normalizedBackupWarning;
|
||
|
||
const alertConfig = {
|
||
enabled: true,
|
||
// Global disable flags per resource type
|
||
disableAllNodes: disableAllNodes(),
|
||
disableAllGuests: disableAllGuests(),
|
||
disableAllStorage: disableAllStorage(),
|
||
disableAllPBS: disableAllPBS(),
|
||
disableAllPMG: disableAllPMG(),
|
||
disableAllDockerHosts: disableAllDockerHosts(),
|
||
disableAllDockerContainers: disableAllDockerContainers(),
|
||
// Global disable offline alerts flags
|
||
disableAllNodesOffline: disableAllNodesOffline(),
|
||
disableAllGuestsOffline: disableAllGuestsOffline(),
|
||
disableAllPBSOffline: disableAllPBSOffline(),
|
||
disableAllPMGOffline: disableAllPMGOffline(),
|
||
disableAllDockerHostsOffline: disableAllDockerHostsOffline(),
|
||
guestDefaults: {
|
||
cpu: createHysteresisThreshold(guestDefaults().cpu),
|
||
memory: createHysteresisThreshold(guestDefaults().memory),
|
||
disk: createHysteresisThreshold(guestDefaults().disk),
|
||
diskRead: createHysteresisThreshold(guestDefaults().diskRead),
|
||
diskWrite: createHysteresisThreshold(guestDefaults().diskWrite),
|
||
networkIn: createHysteresisThreshold(guestDefaults().networkIn),
|
||
networkOut: createHysteresisThreshold(guestDefaults().networkOut),
|
||
disableConnectivity: guestDisableConnectivity(),
|
||
poweredOffSeverity: guestPoweredOffSeverity(),
|
||
},
|
||
nodeDefaults: {
|
||
cpu: createHysteresisThreshold(nodeDefaults().cpu),
|
||
memory: createHysteresisThreshold(nodeDefaults().memory),
|
||
disk: createHysteresisThreshold(nodeDefaults().disk),
|
||
temperature: createHysteresisThreshold(nodeDefaults().temperature),
|
||
},
|
||
dockerDefaults: {
|
||
cpu: createHysteresisThreshold(dockerDefaults().cpu),
|
||
memory: createHysteresisThreshold(dockerDefaults().memory),
|
||
restartCount: dockerDefaults().restartCount,
|
||
restartWindow: dockerDefaults().restartWindow,
|
||
memoryWarnPct: dockerDefaults().memoryWarnPct,
|
||
memoryCriticalPct: dockerDefaults().memoryCriticalPct,
|
||
},
|
||
dockerIgnoredContainerPrefixes: dockerIgnoredPrefixes()
|
||
.map((prefix) => prefix.trim())
|
||
.filter((prefix) => prefix.length > 0),
|
||
storageDefault: createHysteresisThreshold(storageDefault()),
|
||
minimumDelta: 2.0,
|
||
suppressionWindow: 5,
|
||
hysteresisMargin: 5.0,
|
||
timeThreshold: timeThreshold() || 0, // Legacy
|
||
timeThresholds: timeThresholds(),
|
||
metricTimeThresholds: normalizeMetricDelayMap(metricTimeThresholds()),
|
||
snapshotDefaults: {
|
||
enabled: snapshotConfig.enabled,
|
||
warningDays: normalizedSnapshotWarning,
|
||
criticalDays: finalSnapshotCritical,
|
||
},
|
||
backupDefaults: {
|
||
enabled: backupConfig.enabled,
|
||
warningDays: normalizedBackupWarning,
|
||
criticalDays: finalBackupCritical,
|
||
},
|
||
pmgDefaults: pmgThresholds(),
|
||
// Use rawOverridesConfig which is already properly formatted with disabled flags
|
||
overrides: rawOverridesConfig(),
|
||
schedule: {
|
||
quietHours: scheduleQuietHours(),
|
||
cooldown: scheduleCooldown().enabled ? scheduleCooldown().minutes : 0,
|
||
groupingWindow:
|
||
scheduleGrouping().enabled && scheduleGrouping().window
|
||
? scheduleGrouping().window * 60
|
||
: 30, // Convert minutes to seconds
|
||
maxAlertsHour: scheduleCooldown().maxAlerts || 10,
|
||
escalation: scheduleEscalation(),
|
||
grouping: {
|
||
enabled: scheduleGrouping().enabled,
|
||
window: scheduleGrouping().window * 60, // Convert minutes to seconds
|
||
byNode: scheduleGrouping().byNode,
|
||
byGuest: scheduleGrouping().byGuest,
|
||
},
|
||
},
|
||
// Add missing required fields
|
||
aggregation: {
|
||
enabled: true,
|
||
timeWindow: 10,
|
||
countThreshold: 3,
|
||
similarityWindow: 5.0,
|
||
},
|
||
flapping: {
|
||
enabled: true,
|
||
threshold: 5,
|
||
window: 10,
|
||
suppressionTime: 30,
|
||
minStability: 0.8,
|
||
},
|
||
ioNormalization: {
|
||
enabled: true,
|
||
vmDiskMax: 500.0,
|
||
containerDiskMax: 300.0,
|
||
networkMax: 1000.0,
|
||
},
|
||
};
|
||
|
||
await AlertsAPI.updateConfig(alertConfig);
|
||
|
||
// Save email config if it exists (regardless of active tab)
|
||
if (destinationsRef.emailConfig) {
|
||
const emailData = destinationsRef.emailConfig();
|
||
await NotificationsAPI.updateEmailConfig(emailData);
|
||
}
|
||
|
||
if (destinationsRef.appriseConfig) {
|
||
const appriseData = destinationsRef.appriseConfig();
|
||
const updatedApprise = await NotificationsAPI.updateAppriseConfig(appriseData);
|
||
setAppriseConfig({
|
||
enabled: updatedApprise.enabled ?? false,
|
||
targetsText: formatAppriseTargets(updatedApprise.targets),
|
||
cliPath: updatedApprise.cliPath || 'apprise',
|
||
timeoutSeconds:
|
||
typeof updatedApprise.timeoutSeconds === 'number' && updatedApprise.timeoutSeconds > 0
|
||
? updatedApprise.timeoutSeconds
|
||
: 15,
|
||
});
|
||
}
|
||
|
||
setHasUnsavedChanges(false);
|
||
showSuccess('Configuration saved successfully!');
|
||
} catch (err) {
|
||
console.error('Failed to save configuration:', err);
|
||
showError(err instanceof Error ? err.message : 'Failed to save configuration');
|
||
}
|
||
}}
|
||
>
|
||
Save Changes
|
||
</button>
|
||
<button
|
||
class="flex-1 px-4 py-2 text-sm transition-colors border border-gray-300 rounded-lg text-gray-700 dark:border-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 sm:flex-initial disabled:opacity-60 disabled:cursor-not-allowed"
|
||
disabled={isReloadingConfig()}
|
||
onClick={async () => {
|
||
await loadAlertConfiguration({ notify: true });
|
||
}}
|
||
>
|
||
{isReloadingConfig() ? 'Discarding...' : 'Discard'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</Show>
|
||
|
||
<Card padding="none" class="relative lg:flex">
|
||
<div
|
||
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-gray-200 dark:border-gray-700 lg:border-b-0 lg:border-r lg:border-gray-200 dark:lg:border-gray-700 lg:align-top flex-shrink-0 transition-all duration-300`}
|
||
onMouseEnter={() => setSidebarCollapsed(false)}
|
||
onMouseLeave={() => setSidebarCollapsed(true)}
|
||
aria-label="Alerts navigation"
|
||
aria-expanded={!sidebarCollapsed()}
|
||
>
|
||
<div class={`sticky top-24 ${sidebarCollapsed() ? 'px-2' : 'px-5'} py-6 space-y-6 transition-all duration-300`}>
|
||
<div id="alerts-sidebar-menu" class="space-y-6">
|
||
<For each={tabGroups}>
|
||
{(group) => (
|
||
<div class="space-y-2">
|
||
<Show when={!sidebarCollapsed()}>
|
||
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||
{group.label}
|
||
</p>
|
||
</Show>
|
||
<div class="space-y-1.5">
|
||
<For each={group.items}>
|
||
{(item) => (
|
||
<button
|
||
type="button"
|
||
aria-current={activeTab() === item.id ? 'page' : undefined}
|
||
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'} text-sm font-medium transition-colors ${
|
||
activeTab() === item.id
|
||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
|
||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
|
||
}`}
|
||
onClick={() => handleTabChange(item.id)}
|
||
title={sidebarCollapsed() ? item.label : undefined}
|
||
>
|
||
{item.icon}
|
||
<Show when={!sidebarCollapsed()}>
|
||
<span class="truncate">{item.label}</span>
|
||
</Show>
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex-1 min-w-0">
|
||
<Show when={flatTabs.length > 0}>
|
||
<div class="lg:hidden border-b border-gray-200 dark:border-gray-700">
|
||
<div class="p-1">
|
||
<div
|
||
class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5 w-full overflow-x-auto"
|
||
style="-webkit-overflow-scrolling: touch;"
|
||
>
|
||
<For each={flatTabs}>
|
||
{(tab) => (
|
||
<button
|
||
type="button"
|
||
class={`flex-1 px-3 py-2 text-xs font-medium rounded-md transition-all whitespace-nowrap ${
|
||
activeTab() === tab.id
|
||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||
}`}
|
||
onClick={() => handleTabChange(tab.id)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Tab Content */}
|
||
<div class="p-3 sm:p-6">
|
||
<Show when={activeTab() === 'overview'}>
|
||
<OverviewTab
|
||
overrides={overrides()}
|
||
activeAlerts={activeAlerts}
|
||
updateAlert={updateAlert}
|
||
showQuickTip={showQuickTip}
|
||
dismissQuickTip={dismissQuickTip}
|
||
showAcknowledged={showAcknowledged}
|
||
setShowAcknowledged={setShowAcknowledged}
|
||
/>
|
||
</Show>
|
||
|
||
<Show when={activeTab() === 'thresholds'}>
|
||
<ThresholdsTab
|
||
overrides={overrides}
|
||
setOverrides={setOverrides}
|
||
rawOverridesConfig={rawOverridesConfig}
|
||
setRawOverridesConfig={setRawOverridesConfig}
|
||
allGuests={allGuests}
|
||
state={state}
|
||
guestDefaults={guestDefaults}
|
||
guestDisableConnectivity={guestDisableConnectivity}
|
||
setGuestDefaults={setGuestDefaults}
|
||
setGuestDisableConnectivity={setGuestDisableConnectivity}
|
||
guestPoweredOffSeverity={guestPoweredOffSeverity}
|
||
setGuestPoweredOffSeverity={setGuestPoweredOffSeverity}
|
||
nodeDefaults={nodeDefaults}
|
||
setNodeDefaults={setNodeDefaults}
|
||
dockerDefaults={dockerDefaults}
|
||
setDockerDefaults={setDockerDefaults}
|
||
dockerIgnoredPrefixes={dockerIgnoredPrefixes}
|
||
setDockerIgnoredPrefixes={setDockerIgnoredPrefixes}
|
||
storageDefault={storageDefault}
|
||
setStorageDefault={setStorageDefault}
|
||
resetGuestDefaults={resetGuestDefaults}
|
||
resetNodeDefaults={resetNodeDefaults}
|
||
resetDockerDefaults={resetDockerDefaults}
|
||
resetDockerIgnoredPrefixes={resetDockerIgnoredPrefixes}
|
||
resetStorageDefault={resetStorageDefault}
|
||
resetSnapshotDefaults={resetSnapshotDefaults}
|
||
resetBackupDefaults={resetBackupDefaults}
|
||
factoryGuestDefaults={FACTORY_GUEST_DEFAULTS}
|
||
factoryNodeDefaults={FACTORY_NODE_DEFAULTS}
|
||
factoryDockerDefaults={FACTORY_DOCKER_DEFAULTS}
|
||
factoryStorageDefault={FACTORY_STORAGE_DEFAULT}
|
||
snapshotFactoryDefaults={FACTORY_SNAPSHOT_DEFAULTS}
|
||
backupFactoryDefaults={FACTORY_BACKUP_DEFAULTS}
|
||
timeThresholds={timeThresholds}
|
||
metricTimeThresholds={metricTimeThresholds}
|
||
setMetricTimeThresholds={setMetricTimeThresholds}
|
||
backupDefaults={backupDefaults}
|
||
setBackupDefaults={setBackupDefaults}
|
||
snapshotDefaults={snapshotDefaults}
|
||
setSnapshotDefaults={setSnapshotDefaults}
|
||
pmgThresholds={pmgThresholds}
|
||
setPMGThresholds={setPMGThresholds}
|
||
activeAlerts={activeAlerts}
|
||
setHasUnsavedChanges={setHasUnsavedChanges}
|
||
hasUnsavedChanges={hasUnsavedChanges}
|
||
removeAlerts={removeAlerts}
|
||
disableAllNodes={disableAllNodes}
|
||
setDisableAllNodes={setDisableAllNodes}
|
||
disableAllGuests={disableAllGuests}
|
||
setDisableAllGuests={setDisableAllGuests}
|
||
disableAllStorage={disableAllStorage}
|
||
setDisableAllStorage={setDisableAllStorage}
|
||
disableAllPBS={disableAllPBS}
|
||
setDisableAllPBS={setDisableAllPBS}
|
||
disableAllPMG={disableAllPMG}
|
||
setDisableAllPMG={setDisableAllPMG}
|
||
disableAllDockerHosts={disableAllDockerHosts}
|
||
setDisableAllDockerHosts={setDisableAllDockerHosts}
|
||
disableAllDockerContainers={disableAllDockerContainers}
|
||
setDisableAllDockerContainers={setDisableAllDockerContainers}
|
||
disableAllNodesOffline={disableAllNodesOffline}
|
||
setDisableAllNodesOffline={setDisableAllNodesOffline}
|
||
disableAllGuestsOffline={disableAllGuestsOffline}
|
||
setDisableAllGuestsOffline={setDisableAllGuestsOffline}
|
||
disableAllPBSOffline={disableAllPBSOffline}
|
||
setDisableAllPBSOffline={setDisableAllPBSOffline}
|
||
disableAllPMGOffline={disableAllPMGOffline}
|
||
setDisableAllPMGOffline={setDisableAllPMGOffline}
|
||
disableAllDockerHostsOffline={disableAllDockerHostsOffline}
|
||
setDisableAllDockerHostsOffline={setDisableAllDockerHostsOffline}
|
||
/>
|
||
</Show>
|
||
|
||
<Show when={activeTab() === 'destinations'}>
|
||
<DestinationsTab
|
||
ref={destinationsRef}
|
||
hasUnsavedChanges={hasUnsavedChanges}
|
||
setHasUnsavedChanges={setHasUnsavedChanges}
|
||
emailConfig={emailConfig}
|
||
setEmailConfig={setEmailConfig}
|
||
appriseConfig={appriseConfig}
|
||
setAppriseConfig={setAppriseConfig}
|
||
/>
|
||
</Show>
|
||
|
||
<Show when={activeTab() === 'schedule'}>
|
||
<ScheduleTab
|
||
hasUnsavedChanges={hasUnsavedChanges}
|
||
setHasUnsavedChanges={setHasUnsavedChanges}
|
||
quietHours={scheduleQuietHours}
|
||
setQuietHours={setScheduleQuietHours}
|
||
cooldown={scheduleCooldown}
|
||
setCooldown={setScheduleCooldown}
|
||
grouping={scheduleGrouping}
|
||
setGrouping={setScheduleGrouping}
|
||
escalation={scheduleEscalation}
|
||
setEscalation={setScheduleEscalation}
|
||
/>
|
||
</Show>
|
||
|
||
<Show when={activeTab() === 'history'}>
|
||
<HistoryTab />
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Overview Tab - Shows current alert status
|
||
function OverviewTab(props: {
|
||
overrides: Override[];
|
||
activeAlerts: Record<string, Alert>;
|
||
updateAlert: (alertId: string, updates: Partial<Alert>) => void;
|
||
showQuickTip: () => boolean;
|
||
dismissQuickTip: () => void;
|
||
showAcknowledged: () => boolean;
|
||
setShowAcknowledged: (value: boolean) => void;
|
||
}) {
|
||
// Loading states for buttons
|
||
const [processingAlerts, setProcessingAlerts] = createSignal<Set<string>>(new Set());
|
||
|
||
// Get alert stats from actual active alerts
|
||
const alertStats = createMemo(() => {
|
||
// Access the store properly for reactivity
|
||
const alertIds = Object.keys(props.activeAlerts);
|
||
const alerts = alertIds.map((id) => props.activeAlerts[id]);
|
||
return {
|
||
active: alerts.filter((a) => !a.acknowledged).length,
|
||
acknowledged: alerts.filter((a) => a.acknowledged).length,
|
||
total24h: alerts.length, // In real app, would filter by time
|
||
overrides: props.overrides.length,
|
||
};
|
||
});
|
||
|
||
const filteredAlerts = createMemo(() => {
|
||
const alerts = Object.values(props.activeAlerts);
|
||
// Sort: unacknowledged first, then by start time (newest first)
|
||
return alerts
|
||
.filter((alert) => props.showAcknowledged() || !alert.acknowledged)
|
||
.sort((a, b) => {
|
||
// Acknowledged status comparison first
|
||
if (a.acknowledged !== b.acknowledged) {
|
||
return a.acknowledged ? 1 : -1; // Unacknowledged first
|
||
}
|
||
// Then by time
|
||
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
|
||
});
|
||
});
|
||
|
||
const unacknowledgedAlerts = createMemo(() =>
|
||
Object.values(props.activeAlerts).filter((alert) => !alert.acknowledged),
|
||
);
|
||
|
||
const [bulkAckProcessing, setBulkAckProcessing] = createSignal(false);
|
||
|
||
return (
|
||
<div class="space-y-6">
|
||
{/* Stats Cards */}
|
||
<div class="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
|
||
<Card padding="sm" class="sm:p-4">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Active Alerts</p>
|
||
<p class="text-xl sm:text-2xl font-semibold text-gray-600 dark:text-gray-300">
|
||
{alertStats().active}
|
||
</p>
|
||
</div>
|
||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-red-100 dark:bg-red-900/50 rounded-full flex items-center justify-center">
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
class="sm:w-5 sm:h-5 text-red-600 dark:text-red-400"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
||
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card padding="sm" class="sm:p-4">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Acknowledged</p>
|
||
<p class="text-xl sm:text-2xl font-semibold text-yellow-600 dark:text-yellow-400">
|
||
{alertStats().acknowledged}
|
||
</p>
|
||
</div>
|
||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-yellow-100 dark:bg-yellow-900/50 rounded-full flex items-center justify-center">
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
class="sm:w-5 sm:h-5 text-yellow-600 dark:text-yellow-400"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path d="M9 11L12 14L22 4"></path>
|
||
<path d="M21 12V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H16"></path>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card padding="sm" class="sm:p-4">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Last 24 Hours</p>
|
||
<p class="text-xl sm:text-2xl font-semibold text-gray-700 dark:text-gray-300">
|
||
{alertStats().total24h}
|
||
</p>
|
||
</div>
|
||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center">
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
class="sm:w-5 sm:h-5 text-gray-600 dark:text-gray-400"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
<polyline points="12 6 12 12 16 14"></polyline>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card padding="sm" class="sm:p-4">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Guest Overrides</p>
|
||
<p class="text-xl sm:text-2xl font-semibold text-blue-600 dark:text-blue-400">
|
||
{alertStats().overrides}
|
||
</p>
|
||
</div>
|
||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 dark:bg-blue-900/50 rounded-full flex items-center justify-center">
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
class="sm:w-5 sm:h-5 text-blue-600 dark:text-blue-400"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
|
||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Recent Alerts */}
|
||
<div>
|
||
<SectionHeader title="Active Alerts" size="md" class="mb-3" />
|
||
<Show
|
||
when={Object.keys(props.activeAlerts).length > 0}
|
||
fallback={
|
||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||
<div class="flex justify-center mb-3">
|
||
<svg class="w-12 h-12 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" />
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4" />
|
||
</svg>
|
||
</div>
|
||
<p class="text-sm">No active alerts</p>
|
||
<p class="text-xs mt-1">Alerts will appear here when thresholds are exceeded</p>
|
||
</div>
|
||
}
|
||
>
|
||
<Show when={alertStats().acknowledged > 0 || alertStats().active > 0}>
|
||
<div class="flex flex-wrap items-center justify-between gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-t-lg border border-gray-200 dark:border-gray-700">
|
||
<Show when={alertStats().acknowledged > 0}>
|
||
<button
|
||
onClick={() => props.setShowAcknowledged(!props.showAcknowledged())}
|
||
class="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||
>
|
||
{props.showAcknowledged() ? 'Hide' : 'Show'} acknowledged
|
||
</button>
|
||
</Show>
|
||
<Show when={alertStats().active > 0}>
|
||
<button
|
||
type="button"
|
||
class="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/40 text-blue-700 dark:text-blue-200 transition-colors hover:bg-blue-100 dark:hover:bg-blue-900/60 disabled:opacity-60 disabled:cursor-not-allowed"
|
||
disabled={bulkAckProcessing()}
|
||
onClick={async () => {
|
||
if (bulkAckProcessing()) return;
|
||
const pending = unacknowledgedAlerts();
|
||
if (pending.length === 0) {
|
||
return;
|
||
}
|
||
setBulkAckProcessing(true);
|
||
try {
|
||
const result = await AlertsAPI.bulkAcknowledge(pending.map((alert) => alert.id));
|
||
const successes = result.results.filter((r) => r.success);
|
||
const failures = result.results.filter((r) => !r.success);
|
||
|
||
successes.forEach((res) => {
|
||
props.updateAlert(res.alertId, {
|
||
acknowledged: true,
|
||
ackTime: new Date().toISOString(),
|
||
});
|
||
});
|
||
|
||
if (successes.length > 0) {
|
||
showSuccess(
|
||
`Acknowledged ${successes.length} ${successes.length === 1 ? 'alert' : 'alerts'}.`,
|
||
);
|
||
}
|
||
|
||
if (failures.length > 0) {
|
||
showError(
|
||
`Failed to acknowledge ${failures.length} ${failures.length === 1 ? 'alert' : 'alerts'}.`,
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error('Bulk acknowledge failed', error);
|
||
showError('Failed to acknowledge alerts');
|
||
} finally {
|
||
setBulkAckProcessing(false);
|
||
}
|
||
}}
|
||
>
|
||
{bulkAckProcessing()
|
||
? 'Acknowledging…'
|
||
: `Acknowledge all (${alertStats().active})`}
|
||
</button>
|
||
</Show>
|
||
</div>
|
||
</Show>
|
||
<div class="space-y-2">
|
||
<Show when={filteredAlerts().length === 0}>
|
||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||
{props.showAcknowledged() ? 'No active alerts' : 'No unacknowledged alerts'}
|
||
</div>
|
||
</Show>
|
||
<For each={filteredAlerts()}>
|
||
{(alert) => (
|
||
<div
|
||
class={`border rounded-lg p-4 transition-all ${
|
||
processingAlerts().has(alert.id) ? 'opacity-50' : ''
|
||
} ${
|
||
alert.acknowledged
|
||
? 'opacity-60 border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/20'
|
||
: alert.level === 'critical'
|
||
? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
|
||
: 'border-yellow-300 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20'
|
||
}`}
|
||
>
|
||
<div class="flex flex-col sm:flex-row sm:items-start">
|
||
<div class="flex items-start flex-1">
|
||
{/* Status icon */}
|
||
<div
|
||
class={`mr-3 mt-0.5 transition-all ${
|
||
alert.acknowledged
|
||
? 'text-green-600 dark:text-green-400'
|
||
: alert.level === 'critical'
|
||
? 'text-red-600 dark:text-red-400'
|
||
: 'text-yellow-600 dark:text-yellow-400'
|
||
}`}
|
||
>
|
||
{alert.acknowledged ? (
|
||
// Checkmark for acknowledged
|
||
<svg
|
||
class="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
) : (
|
||
// Warning/Alert icon
|
||
<svg
|
||
class="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||
/>
|
||
</svg>
|
||
)}
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span
|
||
class={`text-sm font-medium truncate ${
|
||
alert.level === 'critical'
|
||
? 'text-red-700 dark:text-red-400'
|
||
: 'text-yellow-700 dark:text-yellow-400'
|
||
}`}
|
||
>
|
||
{alert.resourceName}
|
||
</span>
|
||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||
({alert.type})
|
||
</span>
|
||
<Show when={alert.node}>
|
||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||
on {alert.node}
|
||
</span>
|
||
</Show>
|
||
<Show when={alert.acknowledged}>
|
||
<span class="px-2 py-0.5 text-xs bg-yellow-200 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200 rounded">
|
||
Acknowledged
|
||
</span>
|
||
</Show>
|
||
</div>
|
||
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1 break-words">
|
||
{alert.message}
|
||
</p>
|
||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||
Started: {new Date(alert.startTime).toLocaleString()}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 mt-3 sm:mt-0 sm:ml-4 self-end sm:self-start">
|
||
<button
|
||
class={`px-3 py-1.5 text-xs font-medium border rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed ${
|
||
alert.acknowledged
|
||
? 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||
: 'bg-white dark:bg-gray-700 text-yellow-700 dark:text-yellow-300 border-yellow-300 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-900/20'
|
||
}`}
|
||
disabled={processingAlerts().has(alert.id)}
|
||
onClick={async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
// Prevent double-clicks
|
||
if (processingAlerts().has(alert.id)) return;
|
||
|
||
setProcessingAlerts((prev) => new Set(prev).add(alert.id));
|
||
|
||
// Store current state to avoid race conditions
|
||
const wasAcknowledged = alert.acknowledged;
|
||
|
||
try {
|
||
if (wasAcknowledged) {
|
||
// Call API first, only update local state if successful
|
||
await AlertsAPI.unacknowledge(alert.id);
|
||
// Only update local state after successful API call
|
||
props.updateAlert(alert.id, {
|
||
acknowledged: false,
|
||
ackTime: undefined,
|
||
ackUser: undefined,
|
||
});
|
||
showSuccess('Alert restored');
|
||
} else {
|
||
// Call API first, only update local state if successful
|
||
await AlertsAPI.acknowledge(alert.id);
|
||
// Only update local state after successful API call
|
||
props.updateAlert(alert.id, {
|
||
acknowledged: true,
|
||
ackTime: new Date().toISOString(),
|
||
});
|
||
showSuccess('Alert acknowledged');
|
||
}
|
||
} catch (err) {
|
||
console.error(
|
||
`Failed to ${wasAcknowledged ? 'unacknowledge' : 'acknowledge'} alert:`,
|
||
err,
|
||
);
|
||
showError(
|
||
`Failed to ${wasAcknowledged ? 'restore' : 'acknowledge'} alert`,
|
||
);
|
||
// Don't update local state on error - let WebSocket keep the correct state
|
||
} finally {
|
||
// Keep button disabled for longer to prevent race conditions with WebSocket updates
|
||
setTimeout(() => {
|
||
setProcessingAlerts((prev) => {
|
||
const next = new Set(prev);
|
||
next.delete(alert.id);
|
||
return next;
|
||
});
|
||
}, 1500); // 1.5 seconds to allow server to process and WebSocket to sync
|
||
}
|
||
}}
|
||
>
|
||
{processingAlerts().has(alert.id)
|
||
? 'Processing...'
|
||
: alert.acknowledged
|
||
? 'Unacknowledge'
|
||
: 'Acknowledge'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Thresholds Tab - Improved design
|
||
interface ThresholdsTabProps {
|
||
allGuests: () => (VM | Container)[];
|
||
state: State;
|
||
guestDefaults: () => Record<string, number | undefined>;
|
||
nodeDefaults: () => Record<string, number | undefined>;
|
||
dockerDefaults: () => { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number };
|
||
dockerIgnoredPrefixes: () => string[];
|
||
storageDefault: () => number;
|
||
timeThresholds: () => { guest: number; node: number; storage: number; pbs: number };
|
||
metricTimeThresholds: () => Record<string, Record<string, number>>;
|
||
overrides: () => Override[];
|
||
rawOverridesConfig: () => Record<string, RawOverrideConfig>;
|
||
pmgThresholds: () => PMGThresholdDefaults;
|
||
setPMGThresholds: (
|
||
value:
|
||
| PMGThresholdDefaults
|
||
| ((prev: PMGThresholdDefaults) => PMGThresholdDefaults),
|
||
) => void;
|
||
setGuestDefaults: (
|
||
value:
|
||
| Record<string, number | undefined>
|
||
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
||
) => void;
|
||
guestDisableConnectivity: () => boolean;
|
||
setGuestDisableConnectivity: (value: boolean) => void;
|
||
guestPoweredOffSeverity: () => 'warning' | 'critical';
|
||
setGuestPoweredOffSeverity: (value: 'warning' | 'critical') => void;
|
||
setNodeDefaults: (
|
||
value:
|
||
| Record<string, number | undefined>
|
||
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
||
) => void;
|
||
setDockerDefaults: (
|
||
value: { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number } | ((prev: { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number }) => { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number }),
|
||
) => void;
|
||
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
|
||
setStorageDefault: (value: number) => void;
|
||
setMetricTimeThresholds: (
|
||
value:
|
||
| Record<string, Record<string, number>>
|
||
| ((prev: Record<string, Record<string, number>>) => Record<string, Record<string, number>>),
|
||
) => void;
|
||
snapshotDefaults: () => SnapshotAlertConfig;
|
||
setSnapshotDefaults: (
|
||
value:
|
||
| SnapshotAlertConfig
|
||
| ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
||
) => void;
|
||
snapshotFactoryDefaults: SnapshotAlertConfig;
|
||
resetSnapshotDefaults: () => void;
|
||
backupDefaults: () => BackupAlertConfig;
|
||
setBackupDefaults: (
|
||
value:
|
||
| BackupAlertConfig
|
||
| ((prev: BackupAlertConfig) => BackupAlertConfig),
|
||
) => void;
|
||
backupFactoryDefaults: BackupAlertConfig;
|
||
resetBackupDefaults: () => void;
|
||
setOverrides: (value: Override[]) => void;
|
||
setRawOverridesConfig: (value: Record<string, RawOverrideConfig>) => void;
|
||
activeAlerts: Record<string, Alert>;
|
||
setHasUnsavedChanges: (value: boolean) => void;
|
||
hasUnsavedChanges: () => boolean;
|
||
removeAlerts: (predicate: (alert: Alert) => boolean) => void;
|
||
// Global disable flags
|
||
disableAllNodes: () => boolean;
|
||
setDisableAllNodes: (value: boolean) => void;
|
||
disableAllGuests: () => boolean;
|
||
setDisableAllGuests: (value: boolean) => void;
|
||
disableAllStorage: () => boolean;
|
||
setDisableAllStorage: (value: boolean) => void;
|
||
disableAllPBS: () => boolean;
|
||
setDisableAllPBS: (value: boolean) => void;
|
||
disableAllPMG: () => boolean;
|
||
setDisableAllPMG: (value: boolean) => void;
|
||
disableAllDockerHosts: () => boolean;
|
||
setDisableAllDockerHosts: (value: boolean) => void;
|
||
disableAllDockerContainers: () => boolean;
|
||
setDisableAllDockerContainers: (value: boolean) => void;
|
||
// Global disable offline alerts flags
|
||
disableAllNodesOffline: () => boolean;
|
||
setDisableAllNodesOffline: (value: boolean) => void;
|
||
disableAllGuestsOffline: () => boolean;
|
||
setDisableAllGuestsOffline: (value: boolean) => void;
|
||
disableAllPBSOffline: () => boolean;
|
||
setDisableAllPBSOffline: (value: boolean) => void;
|
||
disableAllPMGOffline: () => boolean;
|
||
setDisableAllPMGOffline: (value: boolean) => void;
|
||
disableAllDockerHostsOffline: () => boolean;
|
||
setDisableAllDockerHostsOffline: (value: boolean) => void;
|
||
// Reset functions and factory defaults
|
||
resetGuestDefaults?: () => void;
|
||
resetNodeDefaults?: () => void;
|
||
resetDockerDefaults?: () => void;
|
||
resetDockerIgnoredPrefixes?: () => void;
|
||
resetStorageDefault?: () => void;
|
||
factoryGuestDefaults?: Record<string, number | undefined>;
|
||
factoryNodeDefaults?: Record<string, number | undefined>;
|
||
factoryDockerDefaults?: Record<string, number | undefined>;
|
||
factoryStorageDefault?: number;
|
||
}
|
||
|
||
function ThresholdsTab(props: ThresholdsTabProps) {
|
||
return (
|
||
<ThresholdsTable
|
||
overrides={props.overrides}
|
||
setOverrides={props.setOverrides}
|
||
rawOverridesConfig={props.rawOverridesConfig}
|
||
setRawOverridesConfig={props.setRawOverridesConfig}
|
||
allGuests={props.allGuests}
|
||
nodes={props.state.nodes || []}
|
||
storage={props.state.storage || []}
|
||
dockerHosts={props.state.dockerHosts || []}
|
||
pbsInstances={props.state.pbs || []}
|
||
pmgInstances={props.state.pmg || []}
|
||
backups={props.state.backups}
|
||
pveBackups={props.state.pveBackups}
|
||
pbsBackups={props.state.pbsBackups}
|
||
pmgBackups={props.state.pmgBackups}
|
||
pmgThresholds={props.pmgThresholds}
|
||
setPMGThresholds={props.setPMGThresholds}
|
||
guestDefaults={props.guestDefaults()}
|
||
guestDisableConnectivity={props.guestDisableConnectivity}
|
||
setGuestDefaults={props.setGuestDefaults}
|
||
setGuestDisableConnectivity={props.setGuestDisableConnectivity}
|
||
guestPoweredOffSeverity={props.guestPoweredOffSeverity}
|
||
setGuestPoweredOffSeverity={props.setGuestPoweredOffSeverity}
|
||
nodeDefaults={props.nodeDefaults()}
|
||
setNodeDefaults={props.setNodeDefaults}
|
||
dockerDefaults={props.dockerDefaults()}
|
||
setDockerDefaults={props.setDockerDefaults}
|
||
dockerIgnoredPrefixes={props.dockerIgnoredPrefixes}
|
||
setDockerIgnoredPrefixes={props.setDockerIgnoredPrefixes}
|
||
storageDefault={props.storageDefault}
|
||
setStorageDefault={props.setStorageDefault}
|
||
timeThresholds={props.timeThresholds}
|
||
metricTimeThresholds={props.metricTimeThresholds}
|
||
setMetricTimeThresholds={props.setMetricTimeThresholds}
|
||
backupDefaults={props.backupDefaults}
|
||
setBackupDefaults={props.setBackupDefaults}
|
||
backupFactoryDefaults={props.backupFactoryDefaults}
|
||
resetBackupDefaults={props.resetBackupDefaults}
|
||
snapshotDefaults={props.snapshotDefaults}
|
||
setSnapshotDefaults={props.setSnapshotDefaults}
|
||
snapshotFactoryDefaults={props.snapshotFactoryDefaults}
|
||
resetSnapshotDefaults={props.resetSnapshotDefaults}
|
||
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
||
activeAlerts={props.activeAlerts}
|
||
removeAlerts={props.removeAlerts}
|
||
disableAllNodes={props.disableAllNodes}
|
||
setDisableAllNodes={props.setDisableAllNodes}
|
||
disableAllGuests={props.disableAllGuests}
|
||
setDisableAllGuests={props.setDisableAllGuests}
|
||
disableAllStorage={props.disableAllStorage}
|
||
setDisableAllStorage={props.setDisableAllStorage}
|
||
disableAllPBS={props.disableAllPBS}
|
||
setDisableAllPBS={props.setDisableAllPBS}
|
||
disableAllPMG={props.disableAllPMG}
|
||
setDisableAllPMG={props.setDisableAllPMG}
|
||
disableAllDockerHosts={props.disableAllDockerHosts}
|
||
setDisableAllDockerHosts={props.setDisableAllDockerHosts}
|
||
disableAllDockerContainers={props.disableAllDockerContainers}
|
||
setDisableAllDockerContainers={props.setDisableAllDockerContainers}
|
||
disableAllNodesOffline={props.disableAllNodesOffline}
|
||
setDisableAllNodesOffline={props.setDisableAllNodesOffline}
|
||
disableAllGuestsOffline={props.disableAllGuestsOffline}
|
||
setDisableAllGuestsOffline={props.setDisableAllGuestsOffline}
|
||
disableAllPBSOffline={props.disableAllPBSOffline}
|
||
setDisableAllPBSOffline={props.setDisableAllPBSOffline}
|
||
disableAllPMGOffline={props.disableAllPMGOffline}
|
||
setDisableAllPMGOffline={props.setDisableAllPMGOffline}
|
||
disableAllDockerHostsOffline={props.disableAllDockerHostsOffline}
|
||
setDisableAllDockerHostsOffline={props.setDisableAllDockerHostsOffline}
|
||
resetGuestDefaults={props.resetGuestDefaults}
|
||
resetNodeDefaults={props.resetNodeDefaults}
|
||
resetDockerDefaults={props.resetDockerDefaults}
|
||
resetDockerIgnoredPrefixes={props.resetDockerIgnoredPrefixes}
|
||
resetStorageDefault={props.resetStorageDefault}
|
||
factoryGuestDefaults={props.factoryGuestDefaults}
|
||
factoryNodeDefaults={props.factoryNodeDefaults}
|
||
factoryDockerDefaults={props.factoryDockerDefaults}
|
||
factoryStorageDefault={props.factoryStorageDefault}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// Destinations Tab - Notification settings
|
||
interface DestinationsTabProps {
|
||
ref: DestinationsRef;
|
||
hasUnsavedChanges: () => boolean;
|
||
setHasUnsavedChanges: (value: boolean) => void;
|
||
emailConfig: () => UIEmailConfig;
|
||
setEmailConfig: (config: UIEmailConfig) => void;
|
||
appriseConfig: () => UIAppriseConfig;
|
||
setAppriseConfig: (config: UIAppriseConfig) => void;
|
||
}
|
||
|
||
function DestinationsTab(props: DestinationsTabProps) {
|
||
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
|
||
const [testingEmail, setTestingEmail] = createSignal(false);
|
||
const [testingWebhook, setTestingWebhook] = createSignal<string | null>(null);
|
||
const appriseState = () => props.appriseConfig();
|
||
const updateApprise = (partial: Partial<UIAppriseConfig>) => {
|
||
props.setAppriseConfig({ ...props.appriseConfig(), ...partial });
|
||
};
|
||
// Load webhooks on mount (email config is now loaded in parent)
|
||
onMount(async () => {
|
||
try {
|
||
const hooks = await NotificationsAPI.getWebhooks();
|
||
// Map to local Webhook type - preserve the service type from backend
|
||
setWebhooks(
|
||
hooks.map((h) => ({
|
||
...h,
|
||
service: h.service || 'generic', // Preserve service type or default to generic
|
||
})),
|
||
);
|
||
} catch (err) {
|
||
console.error('Failed to load webhooks:', err);
|
||
}
|
||
});
|
||
|
||
const testEmailConfig = async () => {
|
||
setTestingEmail(true);
|
||
try {
|
||
// Send the current form config for testing (including unsaved changes)
|
||
const config = props.emailConfig();
|
||
await NotificationsAPI.testNotification({
|
||
type: 'email',
|
||
config: { ...config } as Record<string, unknown>, // Send current form data, not saved config
|
||
});
|
||
showSuccess('Test email sent successfully!', 'Check your inbox.');
|
||
} catch (err) {
|
||
console.error('Failed to send test email:', err);
|
||
showError('Failed to send test email', err instanceof Error ? err.message : 'Unknown error');
|
||
} finally {
|
||
setTestingEmail(false);
|
||
}
|
||
};
|
||
|
||
const testWebhook = async (webhookId: string, webhookData?: Omit<Webhook, 'id'>) => {
|
||
setTestingWebhook(webhookId);
|
||
try {
|
||
if (webhookData) {
|
||
// Test unsaved webhook with provided configuration
|
||
await NotificationsAPI.testWebhook(webhookData);
|
||
} else {
|
||
// Test existing webhook by ID
|
||
await NotificationsAPI.testNotification({ type: 'webhook', webhookId });
|
||
}
|
||
showSuccess('Test webhook sent successfully!');
|
||
} catch (err) {
|
||
showError(
|
||
'Failed to send test webhook',
|
||
err instanceof Error ? err.message : 'Unknown error',
|
||
);
|
||
} finally {
|
||
setTestingWebhook(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div class="flex w-full max-w-full flex-col gap-6 md:gap-8">
|
||
<SettingsPanel
|
||
title="Email notifications"
|
||
description="Configure SMTP delivery for alert emails."
|
||
action={
|
||
<Toggle
|
||
checked={props.emailConfig().enabled}
|
||
onChange={(e) => {
|
||
props.setEmailConfig({ ...props.emailConfig(), enabled: e.currentTarget.checked });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
containerClass="sm:self-start"
|
||
label={
|
||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||
{props.emailConfig().enabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
}
|
||
/>
|
||
}
|
||
class="min-w-0"
|
||
bodyClass=""
|
||
>
|
||
<div
|
||
class={`${!props.emailConfig().enabled ? 'pointer-events-none opacity-50 transition-opacity' : 'transition-opacity'}`}
|
||
>
|
||
<EmailProviderSelect
|
||
config={props.emailConfig()}
|
||
onChange={(config) => {
|
||
props.setEmailConfig(config);
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
onTest={testEmailConfig}
|
||
testing={testingEmail()}
|
||
/>
|
||
</div>
|
||
</SettingsPanel>
|
||
|
||
<SettingsPanel
|
||
title="Apprise notifications"
|
||
description="Relay grouped alerts through the Apprise CLI."
|
||
action={
|
||
<Toggle
|
||
checked={appriseState().enabled}
|
||
onChange={(e) => {
|
||
updateApprise({ enabled: e.currentTarget.checked });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
containerClass="sm:self-start"
|
||
label={
|
||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||
{appriseState().enabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
}
|
||
/>
|
||
}
|
||
class="min-w-0"
|
||
bodyClass="space-y-4"
|
||
>
|
||
<div class="space-y-4">
|
||
<div class={formField}>
|
||
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>Delivery targets</label>
|
||
<textarea
|
||
rows={4}
|
||
class={`${formControl} font-mono min-h-[120px]`}
|
||
value={appriseState().targetsText}
|
||
placeholder={`discord://token\nmailto://alerts@example.com`}
|
||
onInput={(e) => {
|
||
updateApprise({ targetsText: e.currentTarget.value });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
/>
|
||
<p class={formHelpText}>Enter one Apprise URL per line. Commas are also supported.</p>
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<div class={formField}>
|
||
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>CLI path</label>
|
||
<input
|
||
type="text"
|
||
value={appriseState().cliPath}
|
||
class={formControl}
|
||
placeholder="apprise"
|
||
onInput={(e) => {
|
||
updateApprise({ cliPath: e.currentTarget.value });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
/>
|
||
<p class={formHelpText}>Leave blank to use the default `apprise` executable.</p>
|
||
</div>
|
||
<div class={formField}>
|
||
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>Timeout (seconds)</label>
|
||
<input
|
||
type="number"
|
||
min="5"
|
||
max="120"
|
||
value={appriseState().timeoutSeconds}
|
||
class={formControl}
|
||
onInput={(e) => {
|
||
const raw = e.currentTarget.valueAsNumber;
|
||
const safe = Number.isNaN(raw) ? 15 : Math.min(120, Math.max(5, Math.trunc(raw)));
|
||
updateApprise({ timeoutSeconds: safe });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
/>
|
||
<p class={formHelpText}>Maximum time to wait for the Apprise CLI to complete.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</SettingsPanel>
|
||
|
||
<SettingsPanel
|
||
title="Webhooks"
|
||
description="Push alerts to chat apps or automation systems."
|
||
action={
|
||
<span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||
{webhooks().length} configured
|
||
</span>
|
||
}
|
||
class="min-w-0"
|
||
bodyClass="space-y-4"
|
||
>
|
||
<WebhookConfig
|
||
webhooks={webhooks()}
|
||
onAdd={async (webhook) => {
|
||
try {
|
||
const created = await NotificationsAPI.createWebhook(webhook);
|
||
setWebhooks([...webhooks(), created]);
|
||
showSuccess('Webhook added successfully');
|
||
} catch (err) {
|
||
console.error('Failed to add webhook:', err);
|
||
showError(err instanceof Error ? err.message : 'Failed to add webhook');
|
||
}
|
||
}}
|
||
onUpdate={async (webhook) => {
|
||
try {
|
||
const updated = await NotificationsAPI.updateWebhook(webhook.id!, webhook);
|
||
setWebhooks(webhooks().map((w) => (w.id === webhook.id ? updated : w)));
|
||
showSuccess('Webhook updated successfully');
|
||
} catch (err) {
|
||
console.error('Failed to update webhook:', err);
|
||
showError(err instanceof Error ? err.message : 'Failed to update webhook');
|
||
}
|
||
}}
|
||
onDelete={async (id) => {
|
||
try {
|
||
await NotificationsAPI.deleteWebhook(id);
|
||
setWebhooks(webhooks().filter((w) => w.id !== id));
|
||
showSuccess('Webhook deleted successfully');
|
||
} catch (err) {
|
||
console.error('Failed to delete webhook:', err);
|
||
showError(err instanceof Error ? err.message : 'Failed to delete webhook');
|
||
}
|
||
}}
|
||
onTest={testWebhook}
|
||
testing={testingWebhook()}
|
||
/>
|
||
</SettingsPanel>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// History Tab - Alert history
|
||
|
||
// Schedule Tab - Quiet hours, cooldown, and grouping
|
||
interface ScheduleTabProps {
|
||
hasUnsavedChanges: () => boolean;
|
||
setHasUnsavedChanges: (value: boolean) => void;
|
||
quietHours: () => QuietHoursConfig;
|
||
setQuietHours: (value: QuietHoursConfig) => void;
|
||
cooldown: () => CooldownConfig;
|
||
setCooldown: (value: CooldownConfig) => void;
|
||
grouping: () => GroupingConfig;
|
||
setGrouping: (value: GroupingConfig) => void;
|
||
escalation: () => EscalationConfig;
|
||
setEscalation: (value: EscalationConfig) => void;
|
||
}
|
||
|
||
function ScheduleTab(props: ScheduleTabProps) {
|
||
// Use props instead of local state
|
||
const quietHours = props.quietHours;
|
||
const setQuietHours = props.setQuietHours;
|
||
const cooldown = props.cooldown;
|
||
const setCooldown = props.setCooldown;
|
||
const grouping = props.grouping;
|
||
const setGrouping = props.setGrouping;
|
||
const escalation = props.escalation;
|
||
const setEscalation = props.setEscalation;
|
||
const resetToDefaults = () => {
|
||
setQuietHours(createDefaultQuietHours());
|
||
setCooldown(createDefaultCooldown());
|
||
setGrouping(createDefaultGrouping());
|
||
setEscalation(createDefaultEscalation());
|
||
props.setHasUnsavedChanges(true);
|
||
};
|
||
|
||
// Comprehensive list of common IANA timezones
|
||
const timezones = [
|
||
'UTC',
|
||
// Africa
|
||
'Africa/Cairo',
|
||
'Africa/Johannesburg',
|
||
'Africa/Lagos',
|
||
'Africa/Nairobi',
|
||
// Americas
|
||
'America/Anchorage',
|
||
'America/Argentina/Buenos_Aires',
|
||
'America/Bogota',
|
||
'America/Caracas',
|
||
'America/Chicago',
|
||
'America/Denver',
|
||
'America/Halifax',
|
||
'America/Lima',
|
||
'America/Los_Angeles',
|
||
'America/Mexico_City',
|
||
'America/New_York',
|
||
'America/Phoenix',
|
||
'America/Santiago',
|
||
'America/Sao_Paulo',
|
||
'America/St_Johns',
|
||
'America/Toronto',
|
||
'America/Vancouver',
|
||
// Asia
|
||
'Asia/Bangkok',
|
||
'Asia/Dhaka',
|
||
'Asia/Dubai',
|
||
'Asia/Hong_Kong',
|
||
'Asia/Jakarta',
|
||
'Asia/Jerusalem',
|
||
'Asia/Karachi',
|
||
'Asia/Kolkata',
|
||
'Asia/Kuala_Lumpur',
|
||
'Asia/Manila',
|
||
'Asia/Riyadh',
|
||
'Asia/Seoul',
|
||
'Asia/Shanghai',
|
||
'Asia/Singapore',
|
||
'Asia/Taipei',
|
||
'Asia/Tehran',
|
||
'Asia/Tokyo',
|
||
// Australia
|
||
'Australia/Adelaide',
|
||
'Australia/Brisbane',
|
||
'Australia/Melbourne',
|
||
'Australia/Perth',
|
||
'Australia/Sydney',
|
||
// Europe
|
||
'Europe/Amsterdam',
|
||
'Europe/Athens',
|
||
'Europe/Berlin',
|
||
'Europe/Brussels',
|
||
'Europe/Budapest',
|
||
'Europe/Copenhagen',
|
||
'Europe/Dublin',
|
||
'Europe/Helsinki',
|
||
'Europe/Istanbul',
|
||
'Europe/Lisbon',
|
||
'Europe/London',
|
||
'Europe/Madrid',
|
||
'Europe/Moscow',
|
||
'Europe/Oslo',
|
||
'Europe/Paris',
|
||
'Europe/Prague',
|
||
'Europe/Rome',
|
||
'Europe/Stockholm',
|
||
'Europe/Vienna',
|
||
'Europe/Warsaw',
|
||
'Europe/Zurich',
|
||
// Pacific
|
||
'Pacific/Auckland',
|
||
'Pacific/Fiji',
|
||
'Pacific/Guam',
|
||
'Pacific/Honolulu',
|
||
];
|
||
|
||
const quietHourSuppressOptions: Array<{
|
||
key: keyof QuietHoursConfig['suppress'];
|
||
label: string;
|
||
description: string;
|
||
}> = [
|
||
{
|
||
key: 'performance',
|
||
label: 'Performance alerts',
|
||
description: 'CPU, memory, disk, and network thresholds stay quiet.',
|
||
},
|
||
{
|
||
key: 'storage',
|
||
label: 'Storage alerts',
|
||
description: 'Silence storage usage, disk health, and ZFS events.',
|
||
},
|
||
{
|
||
key: 'offline',
|
||
label: 'Offline & power state',
|
||
description: 'Skip connectivity and powered-off alerts during backups.',
|
||
},
|
||
];
|
||
|
||
const days = [
|
||
{ id: 'monday', label: 'M', fullLabel: 'Monday' },
|
||
{ id: 'tuesday', label: 'T', fullLabel: 'Tuesday' },
|
||
{ id: 'wednesday', label: 'W', fullLabel: 'Wednesday' },
|
||
{ id: 'thursday', label: 'T', fullLabel: 'Thursday' },
|
||
{ id: 'friday', label: 'F', fullLabel: 'Friday' },
|
||
{ id: 'saturday', label: 'S', fullLabel: 'Saturday' },
|
||
{ id: 'sunday', label: 'S', fullLabel: 'Sunday' },
|
||
];
|
||
|
||
return (
|
||
<div class="space-y-6">
|
||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||
Alert scheduling
|
||
</h3>
|
||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||
Configure when and how alerts are delivered
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={resetToDefaults}
|
||
class="inline-flex items-center gap-2 self-start rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||
title="Restore quiet hours, cooldown, grouping, and escalation settings to their defaults"
|
||
>
|
||
<svg
|
||
class="h-4 w-4"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||
/>
|
||
</svg>
|
||
Reset to defaults
|
||
</button>
|
||
</div>
|
||
|
||
<div class="grid gap-6 xl:grid-cols-2">
|
||
{/* Quiet Hours */}
|
||
<SettingsPanel
|
||
title="Quiet hours"
|
||
description="Pause non-critical alerts during specific times."
|
||
action={
|
||
<Toggle
|
||
checked={quietHours().enabled}
|
||
onChange={(e) => {
|
||
setQuietHours({ ...quietHours(), enabled: e.currentTarget.checked });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
containerClass="sm:self-start"
|
||
label={
|
||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||
{quietHours().enabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
}
|
||
/>
|
||
}
|
||
class="space-y-4"
|
||
>
|
||
<Show when={quietHours().enabled}>
|
||
<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]')}>
|
||
Start time
|
||
</label>
|
||
<input
|
||
type="time"
|
||
value={quietHours().start}
|
||
onChange={(e) => {
|
||
setQuietHours({ ...quietHours(), start: e.currentTarget.value });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class={controlClass('font-mono')}
|
||
/>
|
||
</div>
|
||
<div class={formField}>
|
||
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>End time</label>
|
||
<input
|
||
type="time"
|
||
value={quietHours().end}
|
||
onChange={(e) => {
|
||
setQuietHours({ ...quietHours(), end: e.currentTarget.value });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class={controlClass('font-mono')}
|
||
/>
|
||
</div>
|
||
<div class={formField}>
|
||
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>Timezone</label>
|
||
<select
|
||
value={quietHours().timezone}
|
||
onChange={(e) => {
|
||
setQuietHours({ ...quietHours(), timezone: e.currentTarget.value });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class={controlClass('pr-8')}
|
||
>
|
||
<For each={timezones}>{(tz) => <option value={tz}>{tz}</option>}</For>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<span class={`${labelClass('text-xs uppercase tracking-[0.08em]')} mb-2 block`}>
|
||
Quiet days
|
||
</span>
|
||
<div class="grid grid-cols-7 gap-1">
|
||
<For each={days}>
|
||
{(day) => (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const currentDays = quietHours().days;
|
||
setQuietHours({
|
||
...quietHours(),
|
||
days: { ...currentDays, [day.id]: !currentDays[day.id] },
|
||
});
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
title={day.fullLabel}
|
||
class={`px-2 py-2 text-xs font-medium transition-all duration-200 ${
|
||
quietHours().days[day.id]
|
||
? 'rounded-md bg-blue-500 text-white shadow-sm'
|
||
: 'rounded-md bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
|
||
}`}
|
||
>
|
||
{day.label}
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||
<Show
|
||
when={
|
||
quietHours().days.monday &&
|
||
quietHours().days.tuesday &&
|
||
quietHours().days.wednesday &&
|
||
quietHours().days.thursday &&
|
||
quietHours().days.friday &&
|
||
!quietHours().days.saturday &&
|
||
!quietHours().days.sunday
|
||
}
|
||
>
|
||
Weekdays only
|
||
</Show>
|
||
<Show
|
||
when={
|
||
!quietHours().days.monday &&
|
||
!quietHours().days.tuesday &&
|
||
!quietHours().days.wednesday &&
|
||
!quietHours().days.thursday &&
|
||
!quietHours().days.friday &&
|
||
quietHours().days.saturday &&
|
||
quietHours().days.sunday
|
||
}
|
||
>
|
||
Weekends only
|
||
</Show>
|
||
</p>
|
||
</div>
|
||
|
||
<div class="space-y-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||
<span class={`${labelClass('text-xs uppercase tracking-[0.08em]')} block`}>
|
||
Suppress categories
|
||
</span>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
Critical alerts in selected categories will stay silent during quiet hours.
|
||
</p>
|
||
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:gap-3">
|
||
<For each={quietHourSuppressOptions}>
|
||
{(option) => (
|
||
<label
|
||
class={`flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2 transition-colors ${
|
||
quietHours().suppress[option.key]
|
||
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-500/10'
|
||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700'
|
||
}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={quietHours().suppress[option.key]}
|
||
onChange={(e) => {
|
||
setQuietHours({
|
||
...quietHours(),
|
||
suppress: {
|
||
...quietHours().suppress,
|
||
[option.key]: e.currentTarget.checked,
|
||
},
|
||
});
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class="sr-only"
|
||
/>
|
||
<div
|
||
class={`mt-1 flex h-4 w-4 items-center justify-center rounded border-2 ${
|
||
quietHours().suppress[option.key]
|
||
? 'border-blue-500 bg-blue-500'
|
||
: 'border-gray-300 dark:border-gray-600'
|
||
}`}
|
||
>
|
||
<Show when={quietHours().suppress[option.key]}>
|
||
<svg
|
||
class="h-3 w-3 text-white"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="3"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
</Show>
|
||
</div>
|
||
<div>
|
||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||
{option.label}
|
||
</p>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{option.description}
|
||
</p>
|
||
</div>
|
||
</label>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</SettingsPanel>
|
||
|
||
{/* Cooldown Period */}
|
||
<SettingsPanel
|
||
title="Alert cooldown"
|
||
description="Limit alert frequency to prevent spam."
|
||
action={
|
||
<Toggle
|
||
checked={cooldown().enabled}
|
||
onChange={(e) => {
|
||
setCooldown({ ...cooldown(), enabled: e.currentTarget.checked });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
containerClass="sm:self-start"
|
||
label={
|
||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||
{cooldown().enabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
}
|
||
/>
|
||
}
|
||
class="space-y-4"
|
||
>
|
||
<Show when={cooldown().enabled}>
|
||
<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]')}>
|
||
Cooldown period
|
||
</label>
|
||
<div class="relative">
|
||
<input
|
||
type="number"
|
||
min="5"
|
||
max="120"
|
||
value={cooldown().minutes}
|
||
onChange={(e) => {
|
||
setCooldown({ ...cooldown(), minutes: parseInt(e.currentTarget.value) });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class={controlClass('pr-16')}
|
||
/>
|
||
<span class="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||
minutes
|
||
</span>
|
||
</div>
|
||
<p class={`${formHelpText} mt-1`}>
|
||
Minimum time between alerts for the same issue
|
||
</p>
|
||
</div>
|
||
|
||
<div class={formField}>
|
||
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
|
||
Max alerts / hour
|
||
</label>
|
||
<div class="relative">
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
value={cooldown().maxAlerts}
|
||
onChange={(e) => {
|
||
setCooldown({ ...cooldown(), maxAlerts: parseInt(e.currentTarget.value) });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class={controlClass('pr-16')}
|
||
/>
|
||
<span class="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||
alerts
|
||
</span>
|
||
</div>
|
||
<p class={`${formHelpText} mt-1`}>Per guest/metric combination</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</SettingsPanel>
|
||
|
||
{/* Alert Grouping */}
|
||
<SettingsPanel
|
||
title="Smart grouping"
|
||
description="Bundle similar alerts together."
|
||
action={
|
||
<Toggle
|
||
checked={grouping().enabled}
|
||
onChange={(e) => {
|
||
setGrouping({ ...grouping(), enabled: e.currentTarget.checked });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
containerClass="sm:self-start"
|
||
label={
|
||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||
{grouping().enabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
}
|
||
/>
|
||
}
|
||
class="space-y-4"
|
||
>
|
||
<Show when={grouping().enabled}>
|
||
<div class="space-y-4">
|
||
<div class={formField}>
|
||
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
|
||
Grouping window
|
||
</label>
|
||
<div class="flex items-center gap-3">
|
||
<input
|
||
type="range"
|
||
min="1"
|
||
max="30"
|
||
value={grouping().window}
|
||
onChange={(e) => {
|
||
setGrouping({ ...grouping(), window: parseInt(e.currentTarget.value) });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class="flex-1"
|
||
/>
|
||
<div class="w-16 rounded-md bg-gray-100 px-2 py-1 text-center text-sm text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||
{grouping().window} min
|
||
</div>
|
||
</div>
|
||
<p class={`${formHelpText} mt-1`}>
|
||
Alerts within this window are grouped together.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<span class={`${labelClass('text-xs uppercase tracking-[0.08em]')} mb-2 block`}>
|
||
Grouping strategy
|
||
</span>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<label
|
||
class={`relative flex items-center gap-2 rounded-lg border-2 p-3 transition-all ${
|
||
grouping().byNode
|
||
? 'border-blue-500 bg-blue-50 shadow-sm dark:bg-blue-900/20'
|
||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700'
|
||
}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={grouping().byNode}
|
||
onChange={(e) => {
|
||
setGrouping({ ...grouping(), byNode: e.currentTarget.checked });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class="sr-only"
|
||
/>
|
||
<div
|
||
class={`flex h-4 w-4 items-center justify-center rounded border-2 ${
|
||
grouping().byNode
|
||
? 'border-blue-500 bg-blue-500'
|
||
: 'border-gray-300 dark:border-gray-600'
|
||
}`}
|
||
>
|
||
<Show when={grouping().byNode}>
|
||
<svg
|
||
class="h-3 w-3 text-white"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="3"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
</Show>
|
||
</div>
|
||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
By Node
|
||
</span>
|
||
</label>
|
||
|
||
<label
|
||
class={`relative flex items-center gap-2 rounded-lg border-2 p-3 transition-all ${
|
||
grouping().byGuest
|
||
? 'border-blue-500 bg-blue-50 shadow-sm dark:bg-blue-900/20'
|
||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700'
|
||
}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={grouping().byGuest}
|
||
onChange={(e) => {
|
||
setGrouping({ ...grouping(), byGuest: e.currentTarget.checked });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class="sr-only"
|
||
/>
|
||
<div
|
||
class={`flex h-4 w-4 items-center justify-center rounded border-2 ${
|
||
grouping().byGuest
|
||
? 'border-blue-500 bg-blue-500'
|
||
: 'border-gray-300 dark:border-gray-600'
|
||
}`}
|
||
>
|
||
<Show when={grouping().byGuest}>
|
||
<svg
|
||
class="h-3 w-3 text-white"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="3"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
</Show>
|
||
</div>
|
||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
By Guest
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</SettingsPanel>
|
||
|
||
{/* Escalation Rules */}
|
||
<SettingsPanel
|
||
title="Alert escalation"
|
||
description="Notify additional contacts for persistent issues."
|
||
action={
|
||
<Toggle
|
||
checked={escalation().enabled}
|
||
onChange={(e) => {
|
||
setEscalation({ ...escalation(), enabled: e.currentTarget.checked });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
containerClass="sm:self-start"
|
||
label={
|
||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||
{escalation().enabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
}
|
||
/>
|
||
}
|
||
class="space-y-4"
|
||
>
|
||
<Show when={escalation().enabled}>
|
||
<div class="space-y-3">
|
||
<p class={formHelpText}>Define escalation levels for unresolved alerts:</p>
|
||
<For each={escalation().levels}>
|
||
{(level, index) => (
|
||
<div class="flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-700/40">
|
||
<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-gray-600 dark:text-gray-400">
|
||
After
|
||
</span>
|
||
<input
|
||
type="number"
|
||
min="5"
|
||
max="180"
|
||
value={level.after}
|
||
onChange={(e) => {
|
||
const newLevels = [...escalation().levels];
|
||
const parsed = Number.parseInt(e.currentTarget.value, 10);
|
||
newLevels[index()] = {
|
||
...level,
|
||
after: Number.isNaN(parsed) ? level.after : parsed,
|
||
};
|
||
setEscalation({ ...escalation(), levels: newLevels });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class={`${controlClass('px-2 py-1 text-sm')} w-20`}
|
||
/>
|
||
<span class="text-xs text-gray-600 dark:text-gray-400">min</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||
Notify
|
||
</span>
|
||
<select
|
||
value={level.notify}
|
||
onChange={(e) => {
|
||
const newLevels = [...escalation().levels];
|
||
newLevels[index()] = {
|
||
...level,
|
||
notify: e.currentTarget.value as EscalationNotifyTarget,
|
||
};
|
||
setEscalation({ ...escalation(), levels: newLevels });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class={`${controlClass('px-2 py-1 text-sm')} flex-1`}
|
||
>
|
||
<option value="email">Email</option>
|
||
<option value="webhook">Webhooks</option>
|
||
<option value="all">All Channels</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const newLevels = escalation().levels.filter((_, i) => i !== index());
|
||
setEscalation({ ...escalation(), levels: newLevels });
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class="rounded-md p-1.5 text-red-600 transition-colors hover:bg-red-100 dark:hover:bg-red-900/30"
|
||
title="Remove escalation level"
|
||
>
|
||
<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
|
||
type="button"
|
||
onClick={() => {
|
||
const lastLevel = escalation().levels[escalation().levels.length - 1];
|
||
const newAfter = typeof lastLevel?.after === 'number' ? lastLevel.after + 30 : 15;
|
||
setEscalation({
|
||
...escalation(),
|
||
levels: [
|
||
...escalation().levels,
|
||
{ after: newAfter, notify: 'all' as EscalationNotifyTarget },
|
||
],
|
||
});
|
||
props.setHasUnsavedChanges(true);
|
||
}}
|
||
class="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 py-2 text-sm text-gray-600 transition-all hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:bg-gray-700"
|
||
>
|
||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||
/>
|
||
</svg>
|
||
Add Escalation Level
|
||
</button>
|
||
</div>
|
||
</Show>
|
||
</SettingsPanel>
|
||
|
||
{/* Configuration Summary */}
|
||
<SettingsPanel
|
||
title="Configuration summary"
|
||
description="Preview of the active schedule settings."
|
||
tone="muted"
|
||
padding="lg"
|
||
bodyClass="space-y-1 text-sm text-blue-800 dark:text-blue-300"
|
||
class="lg:col-span-2"
|
||
>
|
||
<Show when={quietHours().enabled}>
|
||
<p>
|
||
• Quiet hours active from {quietHours().start} to {quietHours().end} (
|
||
{quietHours().timezone})
|
||
</p>
|
||
</Show>
|
||
<Show
|
||
when={
|
||
quietHours().enabled &&
|
||
(quietHours().suppress.performance ||
|
||
quietHours().suppress.storage ||
|
||
quietHours().suppress.offline)
|
||
}
|
||
>
|
||
<p>
|
||
• Suppressing{' '}
|
||
{quietHourSuppressOptions
|
||
.filter((option) => quietHours().suppress[option.key])
|
||
.map((option) => option.label)
|
||
.join(', ')}{' '}
|
||
during quiet hours
|
||
</p>
|
||
</Show>
|
||
<Show when={cooldown().enabled}>
|
||
<p>
|
||
• {cooldown().minutes} minute cooldown between alerts, max {cooldown().maxAlerts}{' '}
|
||
alerts per hour
|
||
</p>
|
||
</Show>
|
||
<Show when={grouping().enabled}>
|
||
<p>
|
||
• Grouping alerts within {grouping().window} minute windows
|
||
<Show when={grouping().byNode || grouping().byGuest}>
|
||
{' '}
|
||
by{' '}
|
||
{[grouping().byNode && 'node', grouping().byGuest && 'guest']
|
||
.filter(Boolean)
|
||
.join(' and ')}
|
||
</Show>
|
||
</p>
|
||
</Show>
|
||
<Show when={escalation().enabled && escalation().levels.length > 0}>
|
||
<p>
|
||
• {escalation().levels.length} escalation level
|
||
{escalation().levels.length > 1 ? 's' : ''} configured
|
||
</p>
|
||
</Show>
|
||
<Show
|
||
when={
|
||
!quietHours().enabled &&
|
||
!cooldown().enabled &&
|
||
!grouping().enabled &&
|
||
!escalation().enabled
|
||
}
|
||
>
|
||
<p>• All notification controls are disabled - alerts will be sent immediately</p>
|
||
</Show>
|
||
</SettingsPanel>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
// History Tab - Comprehensive alert table
|
||
function HistoryTab() {
|
||
const { state, activeAlerts } = useWebSocket();
|
||
|
||
// Filter states with localStorage persistence
|
||
const [timeFilter, setTimeFilter] = createSignal(
|
||
localStorage.getItem('alertHistoryTimeFilter') || '7d',
|
||
);
|
||
const [severityFilter, setSeverityFilter] = createSignal(
|
||
localStorage.getItem('alertHistorySeverityFilter') || 'all',
|
||
);
|
||
const [searchTerm, setSearchTerm] = createSignal('');
|
||
const [alertHistory, setAlertHistory] = createSignal<Alert[]>([]);
|
||
const [loading, setLoading] = createSignal(true);
|
||
const [selectedBarIndex, setSelectedBarIndex] = createSignal<number | null>(null);
|
||
const MS_PER_HOUR = 60 * 60 * 1000;
|
||
const userLocale =
|
||
Intl.DateTimeFormat().resolvedOptions().locale ||
|
||
(typeof navigator !== 'undefined' ? navigator.language : undefined) ||
|
||
'en-US';
|
||
|
||
const buildHistoryParams = (range: string) => {
|
||
const params: { limit?: number; startTime?: string } = {};
|
||
const now = Date.now();
|
||
|
||
switch (range) {
|
||
case '24h':
|
||
params.limit = 2000;
|
||
params.startTime = new Date(now - 24 * MS_PER_HOUR).toISOString();
|
||
break;
|
||
case '7d':
|
||
params.limit = 10000;
|
||
params.startTime = new Date(now - 7 * 24 * MS_PER_HOUR).toISOString();
|
||
break;
|
||
case '30d':
|
||
params.limit = 10000;
|
||
params.startTime = new Date(now - 30 * 24 * MS_PER_HOUR).toISOString();
|
||
break;
|
||
case 'all':
|
||
params.limit = 0;
|
||
break;
|
||
default:
|
||
params.limit = 1000;
|
||
}
|
||
|
||
return params;
|
||
};
|
||
|
||
let fetchRequestId = 0;
|
||
const fetchAlertHistory = async (range: string) => {
|
||
const requestId = ++fetchRequestId;
|
||
setLoading(true);
|
||
|
||
try {
|
||
const history = await AlertsAPI.getHistory(buildHistoryParams(range));
|
||
if (requestId === fetchRequestId) {
|
||
setAlertHistory(history);
|
||
}
|
||
} catch (err) {
|
||
if (requestId === fetchRequestId) {
|
||
console.error('Failed to load alert history:', err);
|
||
}
|
||
} finally {
|
||
if (requestId === fetchRequestId) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Ref for search input
|
||
let searchInputRef: HTMLInputElement | undefined;
|
||
|
||
// Persist filter changes to localStorage
|
||
createEffect(() => {
|
||
localStorage.setItem('alertHistoryTimeFilter', timeFilter());
|
||
});
|
||
|
||
createEffect(() => {
|
||
localStorage.setItem('alertHistorySeverityFilter', severityFilter());
|
||
});
|
||
|
||
// Clear chart selection when high-level filters change
|
||
let lastTimeFilterValue: string | null = null;
|
||
createEffect(() => {
|
||
const current = timeFilter();
|
||
if (lastTimeFilterValue !== null && current !== lastTimeFilterValue) {
|
||
setSelectedBarIndex(null);
|
||
}
|
||
lastTimeFilterValue = current;
|
||
});
|
||
|
||
let lastSeverityFilterValue: string | null = null;
|
||
createEffect(() => {
|
||
const current = severityFilter();
|
||
if (lastSeverityFilterValue !== null && current !== lastSeverityFilterValue) {
|
||
setSelectedBarIndex(null);
|
||
}
|
||
lastSeverityFilterValue = current;
|
||
});
|
||
|
||
// Load alert history on mount
|
||
onMount(() => {
|
||
fetchAlertHistory(timeFilter());
|
||
|
||
// Add keyboard event listeners
|
||
const handleKeydown = (e: KeyboardEvent) => {
|
||
// If already focused on an input, select, or textarea, don't interfere
|
||
const activeElement = document.activeElement;
|
||
if (
|
||
activeElement &&
|
||
(activeElement.tagName === 'INPUT' ||
|
||
activeElement.tagName === 'TEXTAREA' ||
|
||
activeElement.tagName === 'SELECT')
|
||
) {
|
||
// Handle Escape to clear and unfocus
|
||
if (e.key === 'Escape' && activeElement === searchInputRef) {
|
||
setSearchTerm('');
|
||
searchInputRef.blur();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// If typing a letter, number, or space, focus the search input
|
||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||
searchInputRef?.focus();
|
||
}
|
||
};
|
||
|
||
document.addEventListener('keydown', handleKeydown);
|
||
|
||
onCleanup(() => {
|
||
document.removeEventListener('keydown', handleKeydown);
|
||
// Prevent pending requests from updating state after unmount
|
||
fetchRequestId++;
|
||
});
|
||
});
|
||
|
||
let skipInitialFetchEffect = true;
|
||
createEffect(() => {
|
||
const range = timeFilter();
|
||
if (skipInitialFetchEffect) {
|
||
skipInitialFetchEffect = false;
|
||
return;
|
||
}
|
||
fetchAlertHistory(range);
|
||
});
|
||
|
||
// Format duration for display
|
||
const formatDuration = (startTime: string, endTime?: string) => {
|
||
const start = new Date(startTime).getTime();
|
||
const end = endTime ? new Date(endTime).getTime() : Date.now();
|
||
const duration = end - start;
|
||
|
||
// Handle negative durations (clock skew or timezone issues)
|
||
if (duration < 0) {
|
||
return '0m';
|
||
}
|
||
|
||
const minutes = Math.floor(duration / 60000);
|
||
const hours = Math.floor(minutes / 60);
|
||
const days = Math.floor(hours / 24);
|
||
|
||
if (days > 0) return `${days}d ${hours % 24}h`;
|
||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||
return `${minutes}m`;
|
||
};
|
||
|
||
const formatBucketRange = (startMs: number, endMs: number) => {
|
||
const start = new Date(startMs);
|
||
const end = new Date(endMs);
|
||
|
||
const sameDay =
|
||
start.getFullYear() === end.getFullYear() &&
|
||
start.getMonth() === end.getMonth() &&
|
||
start.getDate() === end.getDate();
|
||
|
||
const startDay = start.toLocaleDateString(userLocale, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: start.getFullYear() !== end.getFullYear() ? 'numeric' : undefined,
|
||
});
|
||
const endDay = end.toLocaleDateString(userLocale, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: 'numeric',
|
||
});
|
||
|
||
const timeFormatter: Intl.DateTimeFormatOptions = {
|
||
hour: 'numeric',
|
||
minute: '2-digit',
|
||
};
|
||
|
||
const startTimeStr = start.toLocaleTimeString(userLocale, timeFormatter);
|
||
const endTimeStr = end.toLocaleTimeString(userLocale, timeFormatter);
|
||
|
||
if (sameDay) {
|
||
return `${startDay}, ${startTimeStr} – ${endTimeStr}`;
|
||
}
|
||
|
||
return `${startDay}, ${startTimeStr} → ${endDay}, ${endTimeStr}`;
|
||
};
|
||
|
||
// Get resource type (VM, CT, Node, Storage, Docker, PBS, etc.)
|
||
const getResourceType = (
|
||
resourceName: string,
|
||
metadata?: Record<string, unknown> | undefined,
|
||
) => {
|
||
const metadataType =
|
||
typeof metadata?.resourceType === 'string'
|
||
? (metadata.resourceType as string)
|
||
: undefined;
|
||
if (metadataType && metadataType.trim().length > 0) {
|
||
return metadataType;
|
||
}
|
||
|
||
// Check VMs and containers
|
||
const vm = state.vms?.find((v) => v.name === resourceName);
|
||
if (vm) return 'VM';
|
||
|
||
const container = state.containers?.find((c) => c.name === resourceName);
|
||
if (container) return 'CT';
|
||
|
||
// Check nodes
|
||
const node = state.nodes?.find((n) => n.name === resourceName);
|
||
if (node) return 'Node';
|
||
|
||
// Check storage
|
||
const storage = state.storage?.find((s) => s.name === resourceName || s.id === resourceName);
|
||
if (storage) return 'Storage';
|
||
|
||
// Docker hosts
|
||
const dockerHost = state.dockerHosts?.find(
|
||
(host) =>
|
||
host.displayName === resourceName ||
|
||
host.hostname === resourceName ||
|
||
host.agentId === resourceName ||
|
||
host.id === resourceName,
|
||
);
|
||
if (dockerHost) return 'Docker Host';
|
||
|
||
// Docker containers (via known hosts)
|
||
const dockerContainer = state.dockerHosts
|
||
?.flatMap((host) => host.containers || [])
|
||
.find((c) => c.name === resourceName || c.id === resourceName);
|
||
if (dockerContainer) return 'Docker Container';
|
||
|
||
// PBS instances
|
||
const pbsInstance = state.pbs?.find(
|
||
(pbs) => pbs.name === resourceName || pbs.host === resourceName || pbs.id === resourceName,
|
||
);
|
||
if (pbsInstance) return 'PBS';
|
||
|
||
// Ceph clusters
|
||
const cephCluster = state.cephClusters?.find(
|
||
(cluster) => cluster.name === resourceName || cluster.id === resourceName,
|
||
);
|
||
if (cephCluster) return 'Ceph';
|
||
|
||
return 'Unknown';
|
||
};
|
||
|
||
// Extended alert type for display
|
||
interface ExtendedAlert extends Alert {
|
||
status?: string;
|
||
duration?: string;
|
||
resourceType?: string;
|
||
}
|
||
|
||
// Prepare all alerts without filtering
|
||
const allAlertsData = createMemo(() => {
|
||
// Combine active and historical alerts
|
||
const allAlerts: ExtendedAlert[] = [];
|
||
|
||
// Add active alerts
|
||
Object.values(activeAlerts || {}).forEach((alert) => {
|
||
allAlerts.push({
|
||
...alert,
|
||
status: 'active',
|
||
duration: formatDuration(alert.startTime),
|
||
resourceType: getResourceType(alert.resourceName, alert.metadata),
|
||
});
|
||
});
|
||
|
||
// Create a set of active alert IDs for quick lookup
|
||
const activeAlertIds = new Set(Object.keys(activeAlerts || {}));
|
||
|
||
// Add historical alerts
|
||
alertHistory().forEach((alert) => {
|
||
// Skip if this alert is already in active alerts (avoid duplicates)
|
||
if (activeAlertIds.has(alert.id)) {
|
||
return;
|
||
}
|
||
|
||
allAlerts.push({
|
||
...alert,
|
||
status: alert.acknowledged ? 'acknowledged' : 'resolved',
|
||
duration: formatDuration(alert.startTime, alert.lastSeen),
|
||
resourceType: getResourceType(alert.resourceName, alert.metadata),
|
||
});
|
||
});
|
||
|
||
return allAlerts;
|
||
});
|
||
|
||
// Apply severity & search filters (time filtering is layered separately)
|
||
const severityAndSearchFilteredAlerts = createMemo(() => {
|
||
let filtered = allAlertsData();
|
||
|
||
if (severityFilter() !== 'all') {
|
||
filtered = filtered.filter((a) => a.level === severityFilter());
|
||
}
|
||
|
||
if (searchTerm()) {
|
||
const term = searchTerm().toLowerCase();
|
||
filtered = filtered.filter((alert) => {
|
||
const name = alert.resourceName?.toLowerCase() ?? '';
|
||
const message = alert.message?.toLowerCase() ?? '';
|
||
const type = alert.type?.toLowerCase() ?? '';
|
||
const nodeName = alert.node?.toLowerCase() ?? '';
|
||
return (
|
||
name.includes(term) || message.includes(term) || type.includes(term) || nodeName.includes(term)
|
||
);
|
||
});
|
||
}
|
||
|
||
return filtered;
|
||
});
|
||
|
||
// Apply filters to get the final alert data
|
||
const alertData = createMemo(() => {
|
||
let filtered = severityAndSearchFilteredAlerts();
|
||
|
||
// Selected bar filter (takes precedence over time filter)
|
||
if (selectedBarIndex() !== null) {
|
||
const trends = alertTrends();
|
||
const index = selectedBarIndex()!;
|
||
const bucketStart = trends.bucketTimes[index];
|
||
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
|
||
|
||
filtered = filtered.filter((alert) => {
|
||
const alertTime = new Date(alert.startTime).getTime();
|
||
return alertTime >= bucketStart && alertTime < bucketEnd;
|
||
});
|
||
} else if (timeFilter() !== 'all') {
|
||
const now = Date.now();
|
||
const cutoff = {
|
||
'24h': now - 24 * 60 * 60 * 1000,
|
||
'7d': now - 7 * 24 * 60 * 60 * 1000,
|
||
'30d': now - 30 * 24 * 60 * 60 * 1000,
|
||
}[timeFilter()];
|
||
|
||
if (cutoff) {
|
||
filtered = filtered.filter((a) => new Date(a.startTime).getTime() > cutoff);
|
||
}
|
||
}
|
||
|
||
// Sort by start time (newest first)
|
||
return [...filtered].sort(
|
||
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime(),
|
||
);
|
||
});
|
||
|
||
const monthNames = [
|
||
'January',
|
||
'February',
|
||
'March',
|
||
'April',
|
||
'May',
|
||
'June',
|
||
'July',
|
||
'August',
|
||
'September',
|
||
'October',
|
||
'November',
|
||
'December',
|
||
];
|
||
|
||
const getDaySuffix = (day: number) => {
|
||
if (day >= 11 && day <= 13) return 'th';
|
||
switch (day % 10) {
|
||
case 1:
|
||
return 'st';
|
||
case 2:
|
||
return 'nd';
|
||
case 3:
|
||
return 'rd';
|
||
default:
|
||
return 'th';
|
||
}
|
||
};
|
||
|
||
const formatAlertGroupLabel = (date: Date, todayStart: number, yesterdayStart: number) => {
|
||
const month = monthNames[date.getMonth()];
|
||
const day = date.getDate();
|
||
const suffix = getDaySuffix(day);
|
||
const absoluteDate = `${month} ${day}${suffix}`;
|
||
|
||
if (date.getTime() === todayStart) {
|
||
return `Today (${absoluteDate})`;
|
||
}
|
||
|
||
if (date.getTime() === yesterdayStart) {
|
||
return `Yesterday (${absoluteDate})`;
|
||
}
|
||
|
||
return absoluteDate;
|
||
};
|
||
|
||
type AlertHistoryRow = ReturnType<typeof alertData>[number];
|
||
|
||
// Group alerts by day for display
|
||
const groupedAlerts = createMemo(() => {
|
||
const now = new Date();
|
||
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
const todayStart = todayDate.getTime();
|
||
const yesterdayDate = new Date(todayDate);
|
||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||
const yesterdayStart = yesterdayDate.getTime();
|
||
|
||
const groups = new Map<
|
||
number,
|
||
{
|
||
date: Date;
|
||
label: string;
|
||
fullLabel: string;
|
||
alerts: AlertHistoryRow[];
|
||
}
|
||
>();
|
||
|
||
alertData().forEach((alert) => {
|
||
const alertDate = new Date(alert.startTime);
|
||
const dateOnly = new Date(
|
||
alertDate.getFullYear(),
|
||
alertDate.getMonth(),
|
||
alertDate.getDate(),
|
||
);
|
||
const dateKey = dateOnly.getTime();
|
||
|
||
if (!groups.has(dateKey)) {
|
||
groups.set(dateKey, {
|
||
date: dateOnly,
|
||
label: formatAlertGroupLabel(dateOnly, todayStart, yesterdayStart),
|
||
fullLabel: dateOnly.toLocaleDateString('en-US', {
|
||
weekday: 'long',
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
}),
|
||
alerts: [],
|
||
});
|
||
}
|
||
|
||
groups.get(dateKey)!.alerts.push(alert);
|
||
});
|
||
|
||
return Array.from(groups.values()).sort((a, b) => b.date.getTime() - a.date.getTime());
|
||
});
|
||
|
||
// Calculate alert trends for mini-chart
|
||
const alertTrends = createMemo(() => {
|
||
const now = Date.now();
|
||
const msPerHour = MS_PER_HOUR;
|
||
const filteredAlerts = severityAndSearchFilteredAlerts();
|
||
const niceBucketSizes = [1, 2, 3, 6, 12, 24, 48, 72, 168, 336, 720, 1440]; // hours
|
||
const maxBuckets = 30;
|
||
|
||
let bucketSizeHours: number;
|
||
let computedRangeHours: number;
|
||
let startTime: number;
|
||
|
||
const filter = timeFilter();
|
||
if (filter === '24h') {
|
||
bucketSizeHours = 1;
|
||
computedRangeHours = 24;
|
||
startTime = now - computedRangeHours * msPerHour;
|
||
} else if (filter === '7d') {
|
||
bucketSizeHours = 6;
|
||
computedRangeHours = 7 * 24;
|
||
startTime = now - computedRangeHours * msPerHour;
|
||
} else if (filter === '30d') {
|
||
bucketSizeHours = 24;
|
||
computedRangeHours = 30 * 24;
|
||
startTime = now - computedRangeHours * msPerHour;
|
||
} else {
|
||
if (!filteredAlerts.length) {
|
||
bucketSizeHours = 24;
|
||
computedRangeHours = 24;
|
||
startTime = now - computedRangeHours * msPerHour;
|
||
} else {
|
||
const earliest = filteredAlerts.reduce((min, alert) => {
|
||
const alertTime = new Date(alert.startTime).getTime();
|
||
return Math.min(min, alertTime);
|
||
}, now);
|
||
const rawRangeHours = Math.max(1, Math.ceil((now - earliest) / msPerHour));
|
||
const rawBucketSize = Math.max(1, Math.ceil(rawRangeHours / maxBuckets));
|
||
bucketSizeHours =
|
||
niceBucketSizes.find((size) => size >= rawBucketSize) ?? rawBucketSize;
|
||
computedRangeHours = Math.max(rawRangeHours, bucketSizeHours);
|
||
const bucketsNeeded = Math.min(
|
||
Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)),
|
||
maxBuckets,
|
||
);
|
||
startTime = now - bucketsNeeded * bucketSizeHours * msPerHour;
|
||
}
|
||
}
|
||
|
||
const bucketCount = Math.min(
|
||
Math.max(1, Math.ceil(computedRangeHours / bucketSizeHours)),
|
||
maxBuckets,
|
||
);
|
||
startTime = Math.min(startTime, now - bucketCount * bucketSizeHours * msPerHour);
|
||
|
||
const buckets = new Array(bucketCount).fill(0);
|
||
const bucketTimes = new Array(bucketCount)
|
||
.fill(0)
|
||
.map((_, i) => startTime + i * bucketSizeHours * msPerHour);
|
||
|
||
const windowStart = startTime;
|
||
const windowEnd = now;
|
||
|
||
filteredAlerts.forEach((alert) => {
|
||
const alertTime = new Date(alert.startTime).getTime();
|
||
if (alertTime < windowStart || alertTime > windowEnd) {
|
||
return;
|
||
}
|
||
const rawIndex = Math.floor((alertTime - windowStart) / (bucketSizeHours * msPerHour));
|
||
const bucketIndex = Math.min(bucketCount - 1, Math.max(0, rawIndex));
|
||
if (bucketIndex >= 0 && bucketIndex < bucketCount) {
|
||
buckets[bucketIndex]++;
|
||
}
|
||
});
|
||
|
||
const max = Math.max(...buckets, 1);
|
||
|
||
return {
|
||
buckets,
|
||
max,
|
||
bucketSize: bucketSizeHours,
|
||
bucketTimes,
|
||
rangeStart: windowStart,
|
||
rangeHours: bucketCount * bucketSizeHours,
|
||
};
|
||
});
|
||
|
||
const bucketDurationLabel = createMemo(() => {
|
||
const bucketHours = alertTrends().bucketSize;
|
||
if (!Number.isFinite(bucketHours) || bucketHours <= 0) {
|
||
return '—';
|
||
}
|
||
if (bucketHours % 24 === 0) {
|
||
const days = bucketHours / 24;
|
||
return `${days} day${days === 1 ? '' : 's'}`;
|
||
}
|
||
return `${bucketHours} hour${bucketHours === 1 ? '' : 's'}`;
|
||
});
|
||
|
||
const formatAxisTickLabel = (
|
||
timestamp: number,
|
||
bucketHours: number,
|
||
totalHours: number,
|
||
isEnd = false,
|
||
) => {
|
||
if (!Number.isFinite(timestamp)) return '—';
|
||
|
||
if (isEnd && Math.abs(Date.now() - timestamp) < bucketHours * MS_PER_HOUR * 0.75) {
|
||
return 'Now';
|
||
}
|
||
|
||
const date = new Date(timestamp);
|
||
const options: Intl.DateTimeFormatOptions = {};
|
||
|
||
if (totalHours <= 48) {
|
||
options.month = 'short';
|
||
options.day = 'numeric';
|
||
options.hour = '2-digit';
|
||
options.minute = '2-digit';
|
||
} else if (totalHours <= 24 * 90) {
|
||
options.month = 'short';
|
||
options.day = 'numeric';
|
||
if (bucketHours <= 12 || totalHours <= 24 * 14) {
|
||
options.hour = '2-digit';
|
||
}
|
||
} else {
|
||
options.year = 'numeric';
|
||
options.month = 'short';
|
||
options.day = 'numeric';
|
||
}
|
||
|
||
return date.toLocaleString(userLocale, options);
|
||
};
|
||
|
||
const rangeSummary = createMemo(() => {
|
||
const trends = alertTrends();
|
||
if (!trends.bucketTimes.length || trends.bucketSize <= 0) {
|
||
return null;
|
||
}
|
||
|
||
const bucketHours = trends.bucketSize;
|
||
const totalHours = Math.max(trends.rangeHours ?? bucketHours, bucketHours);
|
||
const start = trends.bucketTimes[0];
|
||
const end = start + trends.buckets.length * bucketHours * MS_PER_HOUR;
|
||
|
||
return {
|
||
startLabel: formatAxisTickLabel(start, bucketHours, totalHours),
|
||
endLabel: formatAxisTickLabel(end, bucketHours, totalHours, true),
|
||
};
|
||
});
|
||
|
||
const axisTicks = createMemo(() => {
|
||
const trends = alertTrends();
|
||
if (!trends.bucketTimes.length || trends.bucketSize <= 0) {
|
||
return [] as Array<{ position: number; label: string; align: 'start' | 'center' | 'end' }>;
|
||
}
|
||
|
||
const bucketHours = trends.bucketSize;
|
||
const totalHours = Math.max(trends.rangeHours ?? bucketHours, bucketHours);
|
||
const start = trends.bucketTimes[0];
|
||
const totalDurationMs = Math.max(
|
||
trends.buckets.length * bucketHours * MS_PER_HOUR,
|
||
bucketHours * MS_PER_HOUR,
|
||
);
|
||
const end = start + totalDurationMs;
|
||
|
||
const desiredTicks = Math.min(5, trends.bucketTimes.length + 1);
|
||
const step = Math.max(
|
||
1,
|
||
Math.round(trends.bucketTimes.length / Math.max(1, desiredTicks - 1)),
|
||
);
|
||
const ticks: Array<{ position: number; label: string }> = [];
|
||
|
||
for (let index = 0; index < trends.bucketTimes.length; index += step) {
|
||
const ts = trends.bucketTimes[index];
|
||
const position = Math.min(
|
||
1,
|
||
Math.max(0, (ts - start) / (totalDurationMs || 1)),
|
||
);
|
||
ticks.push({
|
||
position,
|
||
label: formatAxisTickLabel(ts, bucketHours, totalHours),
|
||
});
|
||
}
|
||
|
||
if (!ticks.length || ticks[0].position > 0.01) {
|
||
ticks.unshift({
|
||
position: 0,
|
||
label: formatAxisTickLabel(start, bucketHours, totalHours),
|
||
});
|
||
} else {
|
||
ticks[0] = {
|
||
position: 0,
|
||
label: formatAxisTickLabel(start, bucketHours, totalHours),
|
||
};
|
||
}
|
||
|
||
const lastTick = ticks[ticks.length - 1];
|
||
if (!lastTick || Math.abs(lastTick.position - 1) > 0.01) {
|
||
ticks.push({
|
||
position: 1,
|
||
label: formatAxisTickLabel(end, bucketHours, totalHours, true),
|
||
});
|
||
} else {
|
||
ticks[ticks.length - 1] = {
|
||
position: 1,
|
||
label: formatAxisTickLabel(end, bucketHours, totalHours, true),
|
||
};
|
||
}
|
||
|
||
return ticks.map((tick, index, arr) => ({
|
||
position: tick.position,
|
||
label: tick.label,
|
||
align: index === 0 ? 'start' : index === arr.length - 1 ? 'end' : 'center',
|
||
}));
|
||
});
|
||
|
||
const selectedBucketDetails = createMemo(() => {
|
||
const index = selectedBarIndex();
|
||
if (index === null) return null;
|
||
const trends = alertTrends();
|
||
const bucketStart = trends.bucketTimes[index];
|
||
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
|
||
return {
|
||
rangeLabel: formatBucketRange(bucketStart, bucketEnd),
|
||
start: bucketStart,
|
||
end: bucketEnd,
|
||
};
|
||
});
|
||
|
||
return (
|
||
<div class="space-y-4">
|
||
{/* Alert Trends Mini-Chart */}
|
||
<Card padding="md">
|
||
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||
<SectionHeader
|
||
title="Alert frequency"
|
||
description={
|
||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||
{alertData().length} alerts
|
||
</span>
|
||
}
|
||
size="sm"
|
||
class="flex-1"
|
||
/>
|
||
<div class="flex flex-col items-start gap-2 sm:items-end">
|
||
<Show when={selectedBucketDetails()}>
|
||
{(selection) => (
|
||
<div class="inline-flex items-center gap-2 rounded-full border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/30 px-3 py-1 text-xs text-blue-700 dark:text-blue-200">
|
||
<span class="font-medium uppercase tracking-wide text-[10px] text-blue-600 dark:text-blue-300">
|
||
Filtered Range
|
||
</span>
|
||
<span class="font-mono text-[11px]">{selection().rangeLabel}</span>
|
||
</div>
|
||
)}
|
||
</Show>
|
||
<div class="flex flex-col items-start gap-1 text-xs text-gray-500 dark:text-gray-400 sm:items-end">
|
||
<div>
|
||
<span class="font-medium text-gray-600 dark:text-gray-300">Bar size:</span>{' '}
|
||
{bucketDurationLabel()}
|
||
</div>
|
||
<Show when={rangeSummary()}>
|
||
{(summary) => (
|
||
<div class="flex items-center gap-1 whitespace-nowrap">
|
||
<span class="font-medium text-gray-600 dark:text-gray-300">Range:</span>
|
||
<span>{summary().startLabel}</span>
|
||
<span class="text-gray-400 dark:text-gray-500">→</span>
|
||
<span>{summary().endLabel}</span>
|
||
</div>
|
||
)}
|
||
</Show>
|
||
</div>
|
||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||
<Show when={selectedBarIndex() !== null}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedBarIndex(null)}
|
||
class="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800/50 transition-colors"
|
||
>
|
||
Clear filter
|
||
</button>
|
||
</Show>
|
||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||
<span class="flex items-center gap-1">
|
||
<div class="h-2 w-2 rounded-full bg-yellow-500"></div>
|
||
{alertData().filter((a) => a.level === 'warning').length} warnings
|
||
</span>
|
||
<span class="flex items-center gap-1">
|
||
<div class="h-2 w-2 rounded-full bg-red-500"></div>
|
||
{alertData().filter((a) => a.level === 'critical').length} critical
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mini sparkline chart */}
|
||
<div class="mb-1 text-[10px] text-gray-400 dark:text-gray-500">
|
||
Showing {alertTrends().buckets.length} time periods ({bucketDurationLabel()} each) ·
|
||
Total: {alertData().length} alerts
|
||
</div>
|
||
|
||
{/* Alert frequency chart */}
|
||
{(() => {
|
||
const trends = alertTrends();
|
||
return (
|
||
<div class="rounded bg-gray-100 p-1 dark:bg-gray-800">
|
||
<div class="flex h-12 items-end gap-1">
|
||
{trends.buckets.map((val, i) => {
|
||
const scaledHeight =
|
||
val > 0 ? Math.min(100, Math.max(20, Math.log(val + 1) * 20)) : 0;
|
||
const pixelHeight =
|
||
val > 0 ? Math.max(8, (scaledHeight / 100) * 40) : 0; // 40px is roughly the inner height
|
||
const isSelected = selectedBarIndex() === i;
|
||
const bucketStart = trends.bucketTimes[i];
|
||
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
|
||
const bucketRangeLabel = formatBucketRange(bucketStart, bucketEnd);
|
||
const bucketDurationText =
|
||
trends.bucketSize % 24 === 0
|
||
? `${trends.bucketSize / 24} day${trends.bucketSize / 24 === 1 ? '' : 's'}`
|
||
: `${trends.bucketSize} hour${trends.bucketSize === 1 ? '' : 's'}`;
|
||
const countLabel =
|
||
val === 0 ? 'No alerts' : `${val} alert${val === 1 ? '' : 's'}`;
|
||
const tooltipContent = [countLabel, `${bucketDurationText} period`, bucketRangeLabel].join('\n');
|
||
return (
|
||
<div
|
||
class="flex-1 relative flex items-end cursor-pointer"
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-pressed={isSelected}
|
||
aria-label={`${countLabel} between ${bucketRangeLabel}`}
|
||
onClick={() => setSelectedBarIndex(i === selectedBarIndex() ? null : i)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
setSelectedBarIndex(i === selectedBarIndex() ? null : i);
|
||
}
|
||
}}
|
||
>
|
||
{/* Background track for all slots */}
|
||
<div class="absolute bottom-0 h-1 w-full rounded-full bg-gray-300 opacity-30 dark:bg-gray-600"></div>
|
||
{/* Actual bar */}
|
||
<div
|
||
class="relative w-full rounded-sm transition-all"
|
||
style={{
|
||
height: `${pixelHeight}px`,
|
||
'background-color':
|
||
val > 0 ? (isSelected ? '#2563eb' : '#3b82f6') : 'transparent',
|
||
opacity: isSelected ? '1' : '0.8',
|
||
'box-shadow': isSelected ? '0 0 0 2px rgba(37, 99, 235, 0.4)' : 'none',
|
||
}}
|
||
title={bucketRangeLabel}
|
||
onMouseEnter={(e) => {
|
||
if (val <= 0) {
|
||
hideTooltip();
|
||
return;
|
||
}
|
||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||
showTooltip(tooltipContent, rect.left + rect.width / 2, rect.top, {
|
||
align: 'center',
|
||
direction: 'up',
|
||
});
|
||
}}
|
||
onMouseLeave={() => hideTooltip()}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
<Show when={axisTicks().length > 0}>
|
||
<div class="relative mt-3 h-10">
|
||
<div class="absolute inset-x-0 top-0 h-px bg-gray-200 dark:bg-gray-700"></div>
|
||
<For each={axisTicks()}>
|
||
{(tick) => (
|
||
<div
|
||
class="pointer-events-none absolute top-0 flex h-full flex-col items-center"
|
||
style={{ left: `${tick.position * 100}%` }}
|
||
>
|
||
<div class="h-3 w-px bg-gray-300 dark:bg-gray-600"></div>
|
||
<div
|
||
class="mt-1 whitespace-nowrap text-[10px] text-gray-500 dark:text-gray-400 transform"
|
||
classList={{
|
||
'-translate-x-1/2': tick.align === 'center',
|
||
'-translate-x-full': tick.align === 'end',
|
||
}}
|
||
>
|
||
{tick.label}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
</Card>
|
||
|
||
{/* Filters */}
|
||
<div class="flex flex-wrap gap-2 mb-4">
|
||
<select
|
||
value={timeFilter()}
|
||
onChange={(e) => setTimeFilter(e.currentTarget.value)}
|
||
class="px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||
>
|
||
<option value="24h">Last 24h</option>
|
||
<option value="7d">Last 7d</option>
|
||
<option value="30d">Last 30d</option>
|
||
<option value="all">All Time</option>
|
||
</select>
|
||
|
||
<select
|
||
value={severityFilter()}
|
||
onChange={(e) => setSeverityFilter(e.currentTarget.value)}
|
||
class="px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||
>
|
||
<option value="all">All Levels</option>
|
||
<option value="critical">Critical Only</option>
|
||
<option value="warning">Warning Only</option>
|
||
</select>
|
||
|
||
<div class="flex-1 max-w-xs">
|
||
<input
|
||
ref={searchInputRef}
|
||
type="text"
|
||
placeholder="Search alerts..."
|
||
value={searchTerm()}
|
||
onInput={(e) => setSearchTerm(e.currentTarget.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') {
|
||
setSearchTerm('');
|
||
e.currentTarget.blur();
|
||
}
|
||
}}
|
||
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600
|
||
dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Alert History Table */}
|
||
<Show
|
||
when={loading()}
|
||
fallback={
|
||
<Show
|
||
when={alertData().length > 0}
|
||
fallback={
|
||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||
<p class="text-sm">No alerts found</p>
|
||
<p class="text-xs mt-1">Try adjusting your filters or check back later</p>
|
||
</div>
|
||
}
|
||
>
|
||
{/* Table */}
|
||
<div class="mb-2 border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full min-w-[900px] text-xs sm:text-sm">
|
||
<thead>
|
||
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
|
||
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||
Timestamp
|
||
</th>
|
||
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||
Resource
|
||
</th>
|
||
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||
Type
|
||
</th>
|
||
<th class="p-1 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||
Severity
|
||
</th>
|
||
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||
Message
|
||
</th>
|
||
<th class="p-1 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||
Duration
|
||
</th>
|
||
<th class="p-1 px-2 text-center text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||
Status
|
||
</th>
|
||
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">
|
||
Node
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<For each={groupedAlerts()}>
|
||
{(group) => (
|
||
<>
|
||
{/* Date divider */}
|
||
<tr class="bg-gray-50 dark:bg-gray-900/40">
|
||
<td
|
||
colspan="8"
|
||
class="py-1.5 pr-3 pl-4 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100"
|
||
>
|
||
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||
<span class="truncate" title={group.fullLabel}>
|
||
{group.label}
|
||
</span>
|
||
<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">
|
||
{group.alerts.length}{' '}
|
||
{group.alerts.length === 1 ? 'alert' : 'alerts'}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
|
||
{/* Alerts for this day */}
|
||
<For each={group.alerts}>
|
||
{(alert) => (
|
||
<tr
|
||
class={`border-b border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 ${
|
||
alert.status === 'active' ? 'bg-red-50 dark:bg-red-900/10' : ''
|
||
}`}
|
||
>
|
||
{/* Timestamp */}
|
||
<td class="p-1 px-2 text-gray-600 dark:text-gray-400 font-mono">
|
||
{new Date(alert.startTime).toLocaleTimeString('en-US', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})}
|
||
</td>
|
||
|
||
{/* Resource */}
|
||
<td class="p-1 px-2 font-medium text-gray-900 dark:text-gray-100 truncate max-w-[150px]">
|
||
{alert.resourceName}
|
||
</td>
|
||
|
||
{/* Type */}
|
||
<td class="p-1 px-2">
|
||
<span
|
||
class={`text-xs px-1 py-0.5 rounded ${
|
||
alert.resourceType === 'VM'
|
||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'
|
||
: alert.resourceType === 'CT'
|
||
? 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300'
|
||
: alert.resourceType === 'Node'
|
||
? 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300'
|
||
: alert.resourceType === 'Storage'
|
||
? 'bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300'
|
||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||
}`}
|
||
>
|
||
{alert.type}
|
||
</span>
|
||
</td>
|
||
|
||
{/* Severity */}
|
||
<td class="p-1 px-2 text-center">
|
||
<span
|
||
class={`text-xs px-2 py-0.5 rounded font-medium ${
|
||
alert.level === 'critical'
|
||
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300'
|
||
: 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300'
|
||
}`}
|
||
>
|
||
{alert.level}
|
||
</span>
|
||
</td>
|
||
|
||
{/* Message */}
|
||
<td
|
||
class="p-1 px-2 text-gray-700 dark:text-gray-300 truncate max-w-[300px]"
|
||
title={alert.message}
|
||
>
|
||
{alert.message}
|
||
</td>
|
||
|
||
{/* Duration */}
|
||
<td class="p-1 px-2 text-center text-gray-600 dark:text-gray-400">
|
||
{alert.duration}
|
||
</td>
|
||
|
||
{/* Status */}
|
||
<td class="p-1 px-2 text-center">
|
||
<span
|
||
class={`text-xs px-2 py-0.5 rounded ${
|
||
alert.status === 'active'
|
||
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 font-medium'
|
||
: alert.status === 'acknowledged'
|
||
? 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300'
|
||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||
}`}
|
||
>
|
||
{alert.status}
|
||
</span>
|
||
</td>
|
||
|
||
{/* Node */}
|
||
<td class="p-1 px-2 text-gray-600 dark:text-gray-400 truncate">
|
||
{alert.node || '—'}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</For>
|
||
</>
|
||
)}
|
||
</For>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
}
|
||
>
|
||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||
<p class="text-sm">Loading alert history...</p>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Administrative Actions - Only show if there's history to clear */}
|
||
<Show when={alertHistory().length > 0}>
|
||
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4">
|
||
<div class="flex items-start justify-between">
|
||
<div>
|
||
<h4 class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-1">
|
||
Administrative Actions
|
||
</h4>
|
||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||
Permanently clear all alert history. Use with caution - this action cannot be
|
||
undone.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
if (
|
||
confirm(
|
||
'Are you sure you want to clear all alert history?\n\nThis will permanently delete all historical alert data and cannot be undone.\n\nThis is typically only used for system maintenance or when starting fresh with a new monitoring setup.',
|
||
)
|
||
) {
|
||
try {
|
||
await AlertsAPI.clearHistory();
|
||
setAlertHistory([]);
|
||
// Alert history cleared successfully
|
||
} catch (err) {
|
||
console.error('Error clearing alert history:', err);
|
||
showError(
|
||
'Error clearing alert history',
|
||
'Please check your connection and try again.',
|
||
);
|
||
}
|
||
}
|
||
}}
|
||
class="px-3 py-2 text-xs border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400
|
||
rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors flex-shrink-0"
|
||
>
|
||
Clear All History
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
);
|
||
}
|