The Quick tips banner on the Alerts page said "Set any threshold to 0" but the actual code uses -1 to disable alerts. The tooltips on individual fields were correct; only the top-of-page banner was wrong. Related to #843
2997 lines
110 KiB
TypeScript
2997 lines
110 KiB
TypeScript
import { createSignal, createMemo, Show, For, onMount, onCleanup, createEffect } from 'solid-js';
|
|
import { useNavigate, useLocation } from '@solidjs/router';
|
|
import Toggle from '@/components/shared/Toggle';
|
|
import { Card } from '@/components/shared/Card';
|
|
|
|
// Workaround for eslint false-positive when `For` is used only in JSX
|
|
const __ensureForUsage = For;
|
|
void __ensureForUsage;
|
|
import type {
|
|
VM,
|
|
Container,
|
|
Node,
|
|
Host,
|
|
Alert,
|
|
Storage,
|
|
PBSInstance,
|
|
PMGInstance,
|
|
DockerHost,
|
|
DockerContainer,
|
|
PVEBackups,
|
|
PBSBackup,
|
|
PMGBackup,
|
|
Backups,
|
|
} from '@/types/api';
|
|
import type {
|
|
RawOverrideConfig,
|
|
PMGThresholdDefaults,
|
|
SnapshotAlertConfig,
|
|
BackupAlertConfig,
|
|
} from '@/types/alerts';
|
|
import { ResourceTable, Resource, GroupHeaderMeta } from './ResourceTable';
|
|
import { useAlertsActivation } from '@/stores/alertsActivation';
|
|
import { logger } from '@/utils/logger';
|
|
type OverrideType =
|
|
| 'guest'
|
|
| 'node'
|
|
| 'hostAgent'
|
|
| 'storage'
|
|
| 'pbs'
|
|
| 'pmg'
|
|
| 'dockerHost'
|
|
| 'dockerContainer';
|
|
|
|
type OfflineState = 'off' | 'warning' | 'critical';
|
|
|
|
interface Override {
|
|
id: string;
|
|
name: string;
|
|
type: OverrideType;
|
|
resourceType?: string;
|
|
vmid?: number;
|
|
node?: string;
|
|
instance?: string;
|
|
disabled?: boolean;
|
|
disableConnectivity?: boolean; // For nodes only - disable offline alerts
|
|
poweredOffSeverity?: 'warning' | 'critical';
|
|
note?: string;
|
|
thresholds: {
|
|
cpu?: number;
|
|
memory?: number;
|
|
disk?: number;
|
|
diskRead?: number;
|
|
diskWrite?: number;
|
|
networkIn?: number;
|
|
networkOut?: number;
|
|
usage?: number; // For storage devices
|
|
temperature?: number; // For nodes only - CPU temperature in °C
|
|
};
|
|
}
|
|
|
|
const normalizeThresholdLabel = (label: string): string =>
|
|
label
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(' %', '')
|
|
.replace(' °c', '')
|
|
.replace(' mb/s', '')
|
|
.replace('disk r', 'diskRead')
|
|
.replace('disk w', 'diskWrite')
|
|
.replace('net in', 'networkIn')
|
|
.replace('net out', 'networkOut');
|
|
|
|
const pmgColumn = (key: keyof PMGThresholdDefaults, label: string) => ({
|
|
key,
|
|
label,
|
|
normalized: normalizeThresholdLabel(label),
|
|
});
|
|
|
|
const PMG_THRESHOLD_COLUMNS = [
|
|
pmgColumn('queueTotalWarning', 'Queue Warn'),
|
|
pmgColumn('queueTotalCritical', 'Queue Crit'),
|
|
pmgColumn('deferredQueueWarn', 'Deferred Warn'),
|
|
pmgColumn('deferredQueueCritical', 'Deferred Crit'),
|
|
pmgColumn('holdQueueWarn', 'Hold Warn'),
|
|
pmgColumn('holdQueueCritical', 'Hold Crit'),
|
|
pmgColumn('oldestMessageWarnMins', 'Oldest Warn (min)'),
|
|
pmgColumn('oldestMessageCritMins', 'Oldest Crit (min)'),
|
|
pmgColumn('quarantineSpamWarn', 'Spam Warn'),
|
|
pmgColumn('quarantineSpamCritical', 'Spam Crit'),
|
|
pmgColumn('quarantineVirusWarn', 'Virus Warn'),
|
|
pmgColumn('quarantineVirusCritical', 'Virus Crit'),
|
|
pmgColumn('quarantineGrowthWarnPct', 'Growth Warn %'),
|
|
pmgColumn('quarantineGrowthWarnMin', 'Growth Warn Min'),
|
|
pmgColumn('quarantineGrowthCritPct', 'Growth Crit %'),
|
|
pmgColumn('quarantineGrowthCritMin', 'Growth Crit Min'),
|
|
] as const;
|
|
|
|
const PMG_NORMALIZED_TO_KEY = new Map(
|
|
PMG_THRESHOLD_COLUMNS.map((column) => [column.normalized, column.key]),
|
|
);
|
|
|
|
const PMG_KEY_TO_NORMALIZED = new Map(
|
|
PMG_THRESHOLD_COLUMNS.map((column) => [column.key, column.normalized]),
|
|
);
|
|
|
|
export const normalizeDockerIgnoredInput = (value: string): string[] =>
|
|
value
|
|
.split('\n')
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
|
|
const DEFAULT_SNAPSHOT_WARNING = 30;
|
|
const DEFAULT_SNAPSHOT_CRITICAL = 45;
|
|
const DEFAULT_SNAPSHOT_WARNING_SIZE = 0;
|
|
const DEFAULT_SNAPSHOT_CRITICAL_SIZE = 0;
|
|
const DEFAULT_BACKUP_WARNING = 7;
|
|
const DEFAULT_BACKUP_CRITICAL = 14;
|
|
|
|
// Simple threshold object for the UI
|
|
interface SimpleThresholds {
|
|
cpu?: number;
|
|
memory?: number;
|
|
disk?: number;
|
|
diskRead?: number;
|
|
diskWrite?: number;
|
|
networkIn?: number;
|
|
networkOut?: number;
|
|
temperature?: number; // For nodes only
|
|
[key: string]: number | undefined; // Add index signature for compatibility
|
|
}
|
|
|
|
interface ThresholdsTableProps {
|
|
overrides: () => Override[];
|
|
setOverrides: (overrides: Override[]) => void;
|
|
rawOverridesConfig: () => Record<string, RawOverrideConfig>;
|
|
setRawOverridesConfig: (config: Record<string, RawOverrideConfig>) => void;
|
|
allGuests: () => (VM | Container)[];
|
|
nodes: Node[];
|
|
hosts: Host[];
|
|
storage: Storage[];
|
|
dockerHosts: DockerHost[];
|
|
pbsInstances?: PBSInstance[]; // PBS instances from state
|
|
pmgInstances?: PMGInstance[]; // PMG instances from state
|
|
backups?: Backups;
|
|
pveBackups?: PVEBackups;
|
|
pbsBackups?: PBSBackup[];
|
|
pmgBackups?: PMGBackup[];
|
|
pmgThresholds: () => PMGThresholdDefaults;
|
|
setPMGThresholds: (
|
|
value: PMGThresholdDefaults | ((prev: PMGThresholdDefaults) => PMGThresholdDefaults),
|
|
) => void;
|
|
guestDefaults: SimpleThresholds;
|
|
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;
|
|
nodeDefaults: SimpleThresholds;
|
|
hostDefaults: SimpleThresholds;
|
|
setNodeDefaults: (
|
|
value:
|
|
| Record<string, number | undefined>
|
|
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
|
) => void;
|
|
setHostDefaults: (
|
|
value:
|
|
| Record<string, number | undefined>
|
|
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
|
) => void;
|
|
dockerDefaults: {
|
|
cpu: number;
|
|
memory: number;
|
|
disk: number;
|
|
restartCount: number;
|
|
restartWindow: number;
|
|
memoryWarnPct: number;
|
|
memoryCriticalPct: number;
|
|
serviceWarnGapPercent: number;
|
|
serviceCriticalGapPercent: number;
|
|
};
|
|
dockerDisableConnectivity: () => boolean;
|
|
setDockerDisableConnectivity: (value: boolean) => void;
|
|
dockerPoweredOffSeverity: () => 'warning' | 'critical';
|
|
setDockerPoweredOffSeverity: (value: 'warning' | 'critical') => void;
|
|
setDockerDefaults: (
|
|
value:
|
|
| {
|
|
cpu: number;
|
|
memory: number;
|
|
disk: number;
|
|
restartCount: number;
|
|
restartWindow: number;
|
|
memoryWarnPct: number;
|
|
memoryCriticalPct: number;
|
|
serviceWarnGapPercent: number;
|
|
serviceCriticalGapPercent: number;
|
|
}
|
|
| ((prev: {
|
|
cpu: number;
|
|
memory: number;
|
|
disk: number;
|
|
restartCount: number;
|
|
restartWindow: number;
|
|
memoryWarnPct: number;
|
|
memoryCriticalPct: number;
|
|
serviceWarnGapPercent: number;
|
|
serviceCriticalGapPercent: number;
|
|
}) => {
|
|
cpu: number;
|
|
memory: number;
|
|
disk: number;
|
|
restartCount: number;
|
|
restartWindow: number;
|
|
memoryWarnPct: number;
|
|
memoryCriticalPct: number;
|
|
serviceWarnGapPercent: number;
|
|
serviceCriticalGapPercent: number;
|
|
}),
|
|
) => void;
|
|
dockerIgnoredPrefixes: () => string[];
|
|
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
|
|
storageDefault: () => number;
|
|
setStorageDefault: (value: number) => void;
|
|
resetGuestDefaults?: () => void;
|
|
resetNodeDefaults?: () => void;
|
|
resetHostDefaults?: () => void;
|
|
resetDockerDefaults?: () => void;
|
|
resetDockerIgnoredPrefixes?: () => void;
|
|
resetStorageDefault?: () => void;
|
|
factoryGuestDefaults?: Record<string, number | undefined>;
|
|
factoryNodeDefaults?: Record<string, number | undefined>;
|
|
factoryHostDefaults?: Record<string, number | undefined>;
|
|
factoryDockerDefaults?: Record<string, number | undefined>;
|
|
factoryStorageDefault?: number;
|
|
timeThresholds: () => { guest: number; node: number; storage: number; pbs: number };
|
|
metricTimeThresholds: () => Record<string, Record<string, number>>;
|
|
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;
|
|
setHasUnsavedChanges: (value: boolean) => void;
|
|
activeAlerts?: Record<string, Alert>;
|
|
removeAlerts?: (predicate: (alert: Alert) => boolean) => void;
|
|
// Global disable flags
|
|
disableAllNodes: () => boolean;
|
|
setDisableAllNodes: (value: boolean) => void;
|
|
disableAllGuests: () => boolean;
|
|
setDisableAllGuests: (value: boolean) => void;
|
|
disableAllHosts: () => boolean;
|
|
setDisableAllHosts: (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;
|
|
disableAllDockerServices: () => boolean;
|
|
setDisableAllDockerServices: (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;
|
|
disableAllHostsOffline: () => boolean;
|
|
setDisableAllHostsOffline: (value: boolean) => void;
|
|
disableAllPBSOffline: () => boolean;
|
|
setDisableAllPBSOffline: (value: boolean) => void;
|
|
disableAllPMGOffline: () => boolean;
|
|
setDisableAllPMGOffline: (value: boolean) => void;
|
|
disableAllDockerHostsOffline: () => boolean;
|
|
setDisableAllDockerHostsOffline: (value: boolean) => void;
|
|
}
|
|
|
|
export function ThresholdsTable(props: ThresholdsTableProps) {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const alertsActivation = useAlertsActivation();
|
|
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
|
|
|
const [searchTerm, setSearchTerm] = createSignal('');
|
|
const [editingId, setEditingId] = createSignal<string | null>(null);
|
|
const [editingThresholds, setEditingThresholds] = createSignal<
|
|
Record<string, number | undefined>
|
|
>({});
|
|
const [editingNote, setEditingNote] = createSignal('');
|
|
const [activeTab, setActiveTab] = createSignal<'proxmox' | 'pmg' | 'hosts' | 'docker'>('proxmox');
|
|
let searchInputRef: HTMLInputElement | undefined;
|
|
const [dockerIgnoredInput, setDockerIgnoredInput] = createSignal(
|
|
props.dockerIgnoredPrefixes().join('\n'),
|
|
);
|
|
const serviceWarnInputId = 'docker-service-warn-gap';
|
|
const serviceCriticalInputId = 'docker-service-critical-gap';
|
|
|
|
createEffect(() => {
|
|
setDockerIgnoredInput(props.dockerIgnoredPrefixes().join('\n'));
|
|
});
|
|
|
|
const serviceGapValidationMessage = createMemo(() => {
|
|
const warn = Number(props.dockerDefaults.serviceWarnGapPercent ?? 0);
|
|
const crit = Number(props.dockerDefaults.serviceCriticalGapPercent ?? 0);
|
|
if (crit > 0 && warn > crit) {
|
|
return 'Critical gap must be greater than or equal to the warning gap when enabled.';
|
|
}
|
|
return '';
|
|
});
|
|
|
|
// Determine active tab from URL
|
|
const getActiveTabFromRoute = (): 'proxmox' | 'pmg' | 'hosts' | 'docker' => {
|
|
const path = location.pathname;
|
|
if (path.includes('/thresholds/containers')) return 'docker';
|
|
if (path.includes('/thresholds/docker')) return 'docker'; // Legacy support
|
|
if (path.includes('/thresholds/hosts')) return 'hosts';
|
|
if (path.includes('/thresholds/mail-gateway')) return 'pmg';
|
|
return 'proxmox'; // default
|
|
};
|
|
|
|
// Sync active tab with route on mount and route changes
|
|
createEffect(() => {
|
|
const tabFromRoute = getActiveTabFromRoute();
|
|
if (activeTab() !== tabFromRoute) {
|
|
setActiveTab(tabFromRoute);
|
|
}
|
|
});
|
|
|
|
// Handle default redirect - if at /alerts/thresholds exactly, redirect to /alerts/thresholds/proxmox
|
|
createEffect(() => {
|
|
if (location.pathname === '/alerts/thresholds') {
|
|
navigate('/alerts/thresholds/proxmox', { replace: true });
|
|
}
|
|
});
|
|
|
|
createEffect(() => {
|
|
if (location.pathname.startsWith('/alerts/thresholds/docker')) {
|
|
navigate(
|
|
location.pathname.replace('/alerts/thresholds/docker', '/alerts/thresholds/containers'),
|
|
{ replace: true, scroll: false },
|
|
);
|
|
}
|
|
});
|
|
|
|
const handleTabClick = (tab: 'proxmox' | 'pmg' | 'hosts' | 'docker') => {
|
|
const tabRoutes = {
|
|
proxmox: '/alerts/thresholds/proxmox',
|
|
pmg: '/alerts/thresholds/mail-gateway',
|
|
hosts: '/alerts/thresholds/hosts',
|
|
docker: '/alerts/thresholds/containers',
|
|
};
|
|
navigate(tabRoutes[tab]);
|
|
};
|
|
|
|
const handleDockerIgnoredChange = (value: string) => {
|
|
setDockerIgnoredInput(value);
|
|
const normalized = normalizeDockerIgnoredInput(value);
|
|
props.setDockerIgnoredPrefixes(normalized);
|
|
props.setHasUnsavedChanges(true);
|
|
};
|
|
|
|
const handleResetDockerIgnored = () => {
|
|
if (props.resetDockerIgnoredPrefixes) {
|
|
props.resetDockerIgnoredPrefixes();
|
|
} else {
|
|
props.setDockerIgnoredPrefixes([]);
|
|
}
|
|
setDockerIgnoredInput('');
|
|
props.setHasUnsavedChanges(true);
|
|
};
|
|
|
|
// Set up keyboard shortcuts
|
|
onMount(() => {
|
|
const isEditableElement = (el: HTMLElement | null | undefined): boolean => {
|
|
if (!el) return false;
|
|
const tag = el.tagName;
|
|
return (
|
|
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.contentEditable === 'true'
|
|
);
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement | null;
|
|
const activeElement = (document.activeElement as HTMLElement) ?? null;
|
|
const inEditable = isEditableElement(target);
|
|
|
|
if (e.key === 'Escape') {
|
|
if (searchTerm()) {
|
|
e.preventDefault();
|
|
setSearchTerm('');
|
|
}
|
|
if (searchInputRef && document.activeElement === searchInputRef) {
|
|
searchInputRef.blur();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.defaultPrevented || inEditable || isEditableElement(activeElement) || editingId()) {
|
|
return;
|
|
}
|
|
|
|
if (e.key.length === 1 && e.key.match(/[a-z0-9]/i)) {
|
|
e.preventDefault();
|
|
if (searchInputRef) {
|
|
searchInputRef.focus();
|
|
setSearchTerm(e.key);
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
|
|
onCleanup(() => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
});
|
|
});
|
|
|
|
// Helper function to format values with units
|
|
const formatMetricValue = (metric: string, value: number | undefined): string => {
|
|
if (value === undefined || value === null) return '0';
|
|
|
|
// Show "Off" for disabled thresholds (0 or negative values)
|
|
if (value <= 0) return 'Off';
|
|
|
|
// Percentage-based metrics
|
|
if (
|
|
metric === 'cpu' ||
|
|
metric === 'memory' ||
|
|
metric === 'disk' ||
|
|
metric === 'usage' ||
|
|
metric === 'memoryWarnPct' ||
|
|
metric === 'memoryCriticalPct'
|
|
) {
|
|
return `${value}%`;
|
|
}
|
|
|
|
// Temperature in Celsius
|
|
if (metric === 'temperature') {
|
|
return `${value}°C`;
|
|
}
|
|
|
|
if (metric === 'restartWindow') {
|
|
return `${value}s`;
|
|
}
|
|
|
|
if (metric === 'restartCount') {
|
|
return String(value);
|
|
}
|
|
|
|
if (metric === 'warningSizeGiB' || metric === 'criticalSizeGiB') {
|
|
const rounded = Math.round(value * 10) / 10;
|
|
return `${rounded} GiB`;
|
|
}
|
|
|
|
// MB/s metrics
|
|
if (
|
|
metric === 'diskRead' ||
|
|
metric === 'diskWrite' ||
|
|
metric === 'networkIn' ||
|
|
metric === 'networkOut'
|
|
) {
|
|
return `${value} MB/s`;
|
|
}
|
|
|
|
return String(value);
|
|
};
|
|
|
|
// Check if there's an active alert for a resource/metric
|
|
const hasActiveAlert = (resourceId: string, metric: string): boolean => {
|
|
if (!alertsEnabled()) return false;
|
|
if (!props.activeAlerts) return false;
|
|
const alertKey = `${resourceId}-${metric}`;
|
|
return alertKey in props.activeAlerts;
|
|
};
|
|
|
|
// Process nodes with their overrides
|
|
const getFriendlyNodeName = (value: string, clusterName?: string): string => {
|
|
if (!value) return value;
|
|
|
|
const clusterLower = clusterName?.toLowerCase().trim();
|
|
|
|
const normalizeToken = (token?: string | null): string => {
|
|
if (!token) return '';
|
|
let result = token
|
|
.replace(/\(.*?\)/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
if (clusterLower) {
|
|
result = result
|
|
.split(' ')
|
|
.filter((part) => part.toLowerCase() !== clusterLower)
|
|
.join(' ')
|
|
.trim();
|
|
}
|
|
if (!result) return '';
|
|
const firstWord = result.split(/\s+/)[0] || result;
|
|
const withoutDomain = firstWord.includes('.')
|
|
? (firstWord.split('.')[0] ?? firstWord)
|
|
: firstWord;
|
|
return withoutDomain.trim();
|
|
};
|
|
|
|
const parentheticalMatch = value.match(/\(([^)]+)\)/);
|
|
const parentheticalRaw = parentheticalMatch?.[1]?.trim();
|
|
|
|
let base = normalizeToken(value);
|
|
if (!base) {
|
|
base = value.trim();
|
|
}
|
|
|
|
const parenthetical = normalizeToken(parentheticalRaw);
|
|
if (parenthetical && parenthetical.toLowerCase() !== base.toLowerCase()) {
|
|
return parenthetical;
|
|
}
|
|
|
|
return base;
|
|
};
|
|
|
|
const buildNodeHeaderMeta = (node: Node) => {
|
|
const originalDisplayName = node.displayName?.trim() || node.name;
|
|
const friendlyName = getFriendlyNodeName(originalDisplayName, node.clusterName);
|
|
const hostValue = node.host?.trim();
|
|
let host: string | undefined;
|
|
if (hostValue && hostValue !== '') {
|
|
host = hostValue.startsWith('http')
|
|
? hostValue
|
|
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
|
|
} else if (node.name) {
|
|
host = `https://${node.name.includes(':') ? node.name : `${node.name}:8006`}`;
|
|
}
|
|
|
|
const headerMeta: GroupHeaderMeta = {
|
|
type: 'node',
|
|
displayName: friendlyName,
|
|
rawName: originalDisplayName,
|
|
host,
|
|
status: node.status,
|
|
clusterName: node.isClusterMember ? node.clusterName?.trim() || 'Cluster' : undefined,
|
|
isClusterMember: node.isClusterMember ?? false,
|
|
};
|
|
|
|
const keys = new Set<string>();
|
|
[node.name, originalDisplayName, friendlyName].forEach((value) => {
|
|
if (value && value.trim()) {
|
|
keys.add(value.trim());
|
|
}
|
|
});
|
|
|
|
return { headerMeta, keys };
|
|
};
|
|
|
|
const nodesWithOverrides = createMemo<Resource[]>((prev = []) => {
|
|
// If we're currently editing, return the previous value to avoid re-renders
|
|
if (editingId()) {
|
|
return prev;
|
|
}
|
|
|
|
const search = searchTerm().toLowerCase();
|
|
const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o]));
|
|
|
|
const nodes = (props.nodes ?? []).map((node) => {
|
|
const override = overridesMap.get(node.id);
|
|
|
|
// Check if any threshold values actually differ from defaults
|
|
const hasCustomThresholds =
|
|
override?.thresholds &&
|
|
Object.keys(override.thresholds).some((key) => {
|
|
const k = key as keyof typeof override.thresholds;
|
|
return (
|
|
override.thresholds[k] !== undefined &&
|
|
override.thresholds[k] !== (props.nodeDefaults as any)[k]
|
|
);
|
|
});
|
|
|
|
const note = typeof override?.note === 'string' ? override.note : undefined;
|
|
const hasNote = Boolean(note && note.trim().length > 0);
|
|
|
|
const originalDisplayName = node.displayName?.trim() || node.name;
|
|
const friendlyName = getFriendlyNodeName(originalDisplayName, node.clusterName);
|
|
const rawName = node.name;
|
|
const sanitizedName = friendlyName || originalDisplayName || rawName.split('.')[0] || rawName;
|
|
// Build a best-effort management URL for the node
|
|
const hostValue = node.host?.trim() || rawName;
|
|
const normalizedHost =
|
|
hostValue.startsWith('http://') || hostValue.startsWith('https://')
|
|
? hostValue
|
|
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
|
|
|
|
return {
|
|
id: node.id,
|
|
name: sanitizedName,
|
|
displayName: sanitizedName,
|
|
rawName: originalDisplayName,
|
|
host: normalizedHost,
|
|
type: 'node' as const,
|
|
resourceType: 'Node',
|
|
status: node.status,
|
|
uptime: node.uptime,
|
|
cpu: node.cpu,
|
|
memory: node.memory?.usage,
|
|
hasOverride:
|
|
hasCustomThresholds || hasNote || Boolean(override?.disableConnectivity) || false,
|
|
disabled: false,
|
|
disableConnectivity: override?.disableConnectivity || false,
|
|
thresholds: override?.thresholds || {},
|
|
defaults: props.nodeDefaults,
|
|
clusterName: node.isClusterMember ? node.clusterName?.trim() : undefined,
|
|
isClusterMember: node.isClusterMember ?? false,
|
|
instance: node.instance,
|
|
note,
|
|
} satisfies Resource;
|
|
});
|
|
|
|
if (search) {
|
|
return nodes.filter((n) => n.name.toLowerCase().includes(search));
|
|
}
|
|
return nodes;
|
|
}, []);
|
|
|
|
const hostAgentsWithOverrides = createMemo<Resource[]>((prev = []) => {
|
|
if (editingId()) {
|
|
return prev;
|
|
}
|
|
|
|
const search = searchTerm().toLowerCase();
|
|
const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o]));
|
|
const seen = new Set<string>();
|
|
|
|
const hosts = (props.hosts ?? []).map((host) => {
|
|
const override = overridesMap.get(host.id);
|
|
const hasCustomThresholds =
|
|
override?.thresholds &&
|
|
Object.keys(override.thresholds).some((key) => {
|
|
const k = key as keyof typeof override.thresholds;
|
|
return (
|
|
override.thresholds[k] !== undefined &&
|
|
override.thresholds[k] !== (props.hostDefaults as any)[k]
|
|
);
|
|
});
|
|
|
|
const displayName = host.displayName?.trim() || host.hostname || host.id;
|
|
const status = host.status || (host.lastSeen ? 'online' : 'offline');
|
|
|
|
seen.add(host.id);
|
|
|
|
return {
|
|
id: host.id,
|
|
name: displayName,
|
|
displayName,
|
|
rawName: host.hostname || host.id,
|
|
type: 'hostAgent' as const,
|
|
resourceType: 'Host Agent',
|
|
node: host.hostname,
|
|
instance: host.platform || host.osName || '',
|
|
status,
|
|
hasOverride:
|
|
hasCustomThresholds ||
|
|
Boolean(override?.disabled) ||
|
|
Boolean(override?.disableConnectivity),
|
|
disableConnectivity: override?.disableConnectivity || false,
|
|
thresholds: override?.thresholds || {},
|
|
defaults: props.hostDefaults,
|
|
} satisfies Resource;
|
|
});
|
|
|
|
(props.overrides() ?? [])
|
|
.filter((override) => override.type === 'hostAgent' && !seen.has(override.id))
|
|
.forEach((override) => {
|
|
const name = override.name?.trim() || override.id;
|
|
hosts.push({
|
|
id: override.id,
|
|
name,
|
|
displayName: name,
|
|
rawName: name,
|
|
type: 'hostAgent' as const,
|
|
resourceType: 'Host Agent',
|
|
node: '',
|
|
instance: '',
|
|
status: 'unknown',
|
|
hasOverride: true,
|
|
disableConnectivity: override.disableConnectivity || false,
|
|
thresholds: override.thresholds || {},
|
|
defaults: props.hostDefaults,
|
|
} satisfies Resource);
|
|
});
|
|
|
|
if (search) {
|
|
return hosts.filter((host) => host.name.toLowerCase().includes(search));
|
|
}
|
|
|
|
return hosts;
|
|
}, []);
|
|
|
|
// Process Docker hosts with their overrides (primarily for connectivity toggles)
|
|
const dockerHostsWithOverrides = createMemo<Resource[]>((prev = []) => {
|
|
if (editingId()) {
|
|
return prev;
|
|
}
|
|
|
|
const search = searchTerm().toLowerCase();
|
|
const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o]));
|
|
const seen = new Set<string>();
|
|
|
|
const hosts = (props.dockerHosts ?? []).map((host) => {
|
|
const originalName = host.displayName?.trim() || host.hostname || host.id;
|
|
const friendlyName = getFriendlyNodeName(originalName);
|
|
const override = overridesMap.get(host.id);
|
|
const disableConnectivity = override?.disableConnectivity || false;
|
|
const status = host.status || (host.lastSeen ? 'online' : 'offline');
|
|
|
|
seen.add(host.id);
|
|
|
|
return {
|
|
id: host.id,
|
|
name: friendlyName,
|
|
displayName: friendlyName,
|
|
rawName: originalName,
|
|
type: 'dockerHost' as const,
|
|
resourceType: 'Container Host',
|
|
node: host.hostname,
|
|
instance: host.displayName,
|
|
status,
|
|
hasOverride: disableConnectivity,
|
|
disableConnectivity,
|
|
thresholds: override?.thresholds || {},
|
|
defaults: {},
|
|
} satisfies Resource;
|
|
});
|
|
|
|
// Include any overrides referencing Docker hosts that are no longer reporting
|
|
(props.overrides() ?? [])
|
|
.filter((override) => override.type === 'dockerHost' && !seen.has(override.id))
|
|
.forEach((override) => {
|
|
const originalName = override.name || override.id;
|
|
const friendlyName = getFriendlyNodeName(originalName);
|
|
hosts.push({
|
|
id: override.id,
|
|
name: friendlyName,
|
|
displayName: friendlyName,
|
|
rawName: originalName,
|
|
type: 'dockerHost',
|
|
resourceType: 'Container Host',
|
|
node: override.node || '',
|
|
instance: override.instance || '',
|
|
status: 'unknown',
|
|
hasOverride: true,
|
|
disableConnectivity: override.disableConnectivity || false,
|
|
thresholds: override.thresholds || {},
|
|
defaults: {},
|
|
});
|
|
});
|
|
|
|
if (search) {
|
|
return hosts.filter((host) => host.name.toLowerCase().includes(search));
|
|
}
|
|
return hosts;
|
|
}, []);
|
|
|
|
// Process Docker containers grouped by host
|
|
const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
|
if (editingId()) {
|
|
return prev;
|
|
}
|
|
|
|
const search = searchTerm().toLowerCase();
|
|
const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o]));
|
|
const groups: Record<string, Resource[]> = {};
|
|
const seen = new Set<string>();
|
|
|
|
const normalizeContainerName = (container: DockerContainer): string => {
|
|
const name = container.name?.trim() || '';
|
|
if (name.startsWith('/')) {
|
|
return name.replace(/^\/+/, '') || (container.id?.slice(0, 12) ?? 'container');
|
|
}
|
|
if (!name) {
|
|
return container.id?.slice(0, 12) ?? 'container';
|
|
}
|
|
return name;
|
|
};
|
|
|
|
(props.dockerHosts ?? []).forEach((host) => {
|
|
const hostLabel = host.displayName?.trim() || host.hostname || host.id;
|
|
const friendlyHostName = getFriendlyNodeName(hostLabel);
|
|
const hostLabelLower = hostLabel.toLowerCase();
|
|
const friendlyHostNameLower = friendlyHostName.toLowerCase();
|
|
|
|
(host.containers || []).forEach((container) => {
|
|
const containerId = container.id || normalizeContainerName(container);
|
|
const resourceId = `docker:${host.id}/${containerId}`;
|
|
const override = overridesMap.get(resourceId);
|
|
const overrideSeverity = override?.poweredOffSeverity;
|
|
|
|
const defaults = props.dockerDefaults as Record<string, number | undefined>;
|
|
const hasCustomThresholds =
|
|
override?.thresholds &&
|
|
Object.keys(override.thresholds).some((key) => {
|
|
const k = key as keyof typeof override.thresholds;
|
|
return (
|
|
override.thresholds[k] !== undefined &&
|
|
override.thresholds[k] !== defaults?.[k as keyof typeof defaults]
|
|
);
|
|
});
|
|
|
|
const hasOverride =
|
|
hasCustomThresholds ||
|
|
override?.disabled ||
|
|
override?.disableConnectivity ||
|
|
overrideSeverity !== undefined ||
|
|
false;
|
|
|
|
const containerName = normalizeContainerName(container);
|
|
const containerNameLower = containerName.toLowerCase();
|
|
const imageLower = container.image?.toLowerCase() || '';
|
|
|
|
const matchesSearch =
|
|
!search ||
|
|
containerNameLower.includes(search) ||
|
|
hostLabelLower.includes(search) ||
|
|
friendlyHostNameLower.includes(search) ||
|
|
imageLower.includes(search);
|
|
if (!matchesSearch) {
|
|
return;
|
|
}
|
|
|
|
const status = container.state || container.status || 'unknown';
|
|
const groupKey = friendlyHostName || hostLabel;
|
|
|
|
const resource: Resource = {
|
|
id: resourceId,
|
|
name: containerName,
|
|
type: 'dockerContainer',
|
|
resourceType: 'Container',
|
|
node: groupKey,
|
|
instance: host.hostname,
|
|
status,
|
|
hasOverride,
|
|
disabled: override?.disabled || false,
|
|
disableConnectivity: override?.disableConnectivity || false,
|
|
thresholds: override?.thresholds || {},
|
|
defaults: props.dockerDefaults,
|
|
hostId: host.id,
|
|
image: container.image,
|
|
poweredOffSeverity: overrideSeverity,
|
|
};
|
|
|
|
if (!groups[groupKey]) {
|
|
groups[groupKey] = [];
|
|
}
|
|
groups[groupKey].push(resource);
|
|
seen.add(resourceId);
|
|
});
|
|
});
|
|
|
|
// Include overrides for Docker containers that aren't currently reporting
|
|
(props.overrides() ?? [])
|
|
.filter((override) => override.type === 'dockerContainer' && !seen.has(override.id))
|
|
.forEach((override) => {
|
|
const fallbackName = override.name || override.id.split('/').pop() || override.id;
|
|
const group = 'Unassigned Containers';
|
|
if (!groups[group]) {
|
|
groups[group] = [];
|
|
}
|
|
groups[group].push({
|
|
id: override.id,
|
|
name: fallbackName,
|
|
type: 'dockerContainer',
|
|
resourceType: 'Container',
|
|
status: 'unknown',
|
|
hasOverride: true,
|
|
disabled: override.disabled || false,
|
|
disableConnectivity: override.disableConnectivity || false,
|
|
thresholds: override.thresholds || {},
|
|
defaults: props.dockerDefaults,
|
|
poweredOffSeverity: override.poweredOffSeverity,
|
|
});
|
|
});
|
|
|
|
Object.keys(groups).forEach((group) => {
|
|
groups[group].sort((a, b) => a.name.localeCompare(b.name));
|
|
});
|
|
|
|
if (!search) {
|
|
return groups;
|
|
}
|
|
|
|
// With search applied, remove empty groups (should already be filtered)
|
|
const filteredGroups: Record<string, Resource[]> = {};
|
|
Object.entries(groups).forEach(([group, resources]) => {
|
|
if (resources.length > 0) {
|
|
filteredGroups[group] = resources;
|
|
}
|
|
});
|
|
return filteredGroups;
|
|
}, {});
|
|
|
|
const dockerContainersFlat = createMemo<Resource[]>(() =>
|
|
Object.values(dockerContainersGroupedByHost() ?? {}).flat(),
|
|
);
|
|
|
|
const totalDockerContainers = createMemo(() =>
|
|
(props.dockerHosts ?? []).reduce((sum, host) => sum + (host.containers?.length ?? 0), 0),
|
|
);
|
|
|
|
const dockerHostGroupMeta = createMemo<Record<string, GroupHeaderMeta>>(() => {
|
|
const meta: Record<string, GroupHeaderMeta> = {};
|
|
(props.dockerHosts ?? []).forEach((host) => {
|
|
const originalName = host.displayName?.trim() || host.hostname || host.id;
|
|
const friendlyName = getFriendlyNodeName(originalName);
|
|
const headerMeta: GroupHeaderMeta = {
|
|
displayName: friendlyName,
|
|
rawName: originalName,
|
|
status: host.status || (host.lastSeen ? 'online' : 'offline'),
|
|
};
|
|
|
|
[friendlyName, originalName, host.hostname, host.id]
|
|
.filter((key): key is string => Boolean(key && key.trim()))
|
|
.forEach((key) => {
|
|
meta[key.trim()] = headerMeta;
|
|
});
|
|
});
|
|
|
|
meta['Unassigned Containers'] = {
|
|
displayName: 'Unassigned Containers',
|
|
status: 'unknown',
|
|
};
|
|
|
|
return meta;
|
|
});
|
|
|
|
const countOverrides = (resources: Resource[] | undefined) =>
|
|
resources?.filter(
|
|
(resource) => resource.hasOverride || resource.disabled || resource.disableConnectivity,
|
|
).length ?? 0;
|
|
|
|
const registerSection = (_key: string) => (_el: HTMLDivElement | null) => {
|
|
/* no-op placeholder for future scroll restoration */
|
|
};
|
|
|
|
const snapshotFactoryConfig = () =>
|
|
props.snapshotFactoryDefaults ?? {
|
|
enabled: false,
|
|
warningDays: DEFAULT_SNAPSHOT_WARNING,
|
|
criticalDays: DEFAULT_SNAPSHOT_CRITICAL,
|
|
warningSizeGiB: DEFAULT_SNAPSHOT_WARNING_SIZE,
|
|
criticalSizeGiB: DEFAULT_SNAPSHOT_CRITICAL_SIZE,
|
|
};
|
|
|
|
const sanitizeSnapshotConfig = (config: SnapshotAlertConfig): SnapshotAlertConfig => {
|
|
let warning = Math.max(0, Math.round(config.warningDays ?? 0));
|
|
let critical = Math.max(0, Math.round(config.criticalDays ?? 0));
|
|
|
|
if (critical > 0 && warning > critical) {
|
|
warning = critical;
|
|
}
|
|
if (critical === 0 && warning > 0) {
|
|
critical = warning;
|
|
}
|
|
|
|
const rawWarningSize = Number.isFinite(config.warningSizeGiB)
|
|
? Number(config.warningSizeGiB)
|
|
: DEFAULT_SNAPSHOT_WARNING_SIZE;
|
|
const rawCriticalSize = Number.isFinite(config.criticalSizeGiB)
|
|
? Number(config.criticalSizeGiB)
|
|
: DEFAULT_SNAPSHOT_CRITICAL_SIZE;
|
|
|
|
const roundSize = (value: number) => Math.round(Math.max(0, value) * 10) / 10;
|
|
|
|
let warningSize = roundSize(rawWarningSize);
|
|
let criticalSize = roundSize(rawCriticalSize);
|
|
|
|
if (criticalSize > 0 && warningSize > criticalSize) {
|
|
warningSize = criticalSize;
|
|
}
|
|
if (criticalSize === 0 && warningSize > 0) {
|
|
criticalSize = warningSize;
|
|
}
|
|
|
|
return {
|
|
enabled: !!config.enabled,
|
|
warningDays: warning,
|
|
criticalDays: critical,
|
|
warningSizeGiB: warningSize,
|
|
criticalSizeGiB: criticalSize,
|
|
};
|
|
};
|
|
|
|
const updateSnapshotDefaults = (
|
|
updater: SnapshotAlertConfig | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
|
) => {
|
|
props.setSnapshotDefaults((prev) => {
|
|
const next =
|
|
typeof updater === 'function'
|
|
? (updater as (prev: SnapshotAlertConfig) => SnapshotAlertConfig)(prev)
|
|
: { ...prev, ...updater };
|
|
return sanitizeSnapshotConfig(next);
|
|
});
|
|
props.setHasUnsavedChanges(true);
|
|
};
|
|
|
|
const snapshotDefaultsRecord = createMemo(() => {
|
|
const current = props.snapshotDefaults();
|
|
return {
|
|
'warning days': current.warningDays ?? 0,
|
|
'critical days': current.criticalDays ?? 0,
|
|
'warning size (gib)': current.warningSizeGiB ?? 0,
|
|
'critical size (gib)': current.criticalSizeGiB ?? 0,
|
|
};
|
|
});
|
|
|
|
const snapshotFactoryDefaultsRecord = createMemo(() => {
|
|
const factory = snapshotFactoryConfig();
|
|
return {
|
|
'warning days': factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING,
|
|
'critical days': factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL,
|
|
'warning size (gib)': factory.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE,
|
|
'critical size (gib)': factory.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE,
|
|
};
|
|
});
|
|
|
|
const backupFactoryConfig = () =>
|
|
props.backupFactoryDefaults ?? {
|
|
enabled: false,
|
|
warningDays: DEFAULT_BACKUP_WARNING,
|
|
criticalDays: DEFAULT_BACKUP_CRITICAL,
|
|
};
|
|
|
|
const sanitizeBackupConfig = (config: BackupAlertConfig): BackupAlertConfig => {
|
|
let warning = Math.max(0, Math.round(config.warningDays ?? 0));
|
|
let critical = Math.max(0, Math.round(config.criticalDays ?? 0));
|
|
|
|
if (critical > 0 && warning > critical) {
|
|
warning = critical;
|
|
}
|
|
if (critical === 0 && warning > 0) {
|
|
critical = warning;
|
|
}
|
|
|
|
return {
|
|
enabled: !!config.enabled,
|
|
warningDays: warning,
|
|
criticalDays: critical,
|
|
};
|
|
};
|
|
|
|
const updateBackupDefaults = (
|
|
updater: BackupAlertConfig | ((prev: BackupAlertConfig) => BackupAlertConfig),
|
|
) => {
|
|
props.setBackupDefaults((prev) => {
|
|
const next =
|
|
typeof updater === 'function'
|
|
? (updater as (prev: BackupAlertConfig) => BackupAlertConfig)(prev)
|
|
: { ...prev, ...updater };
|
|
return sanitizeBackupConfig(next);
|
|
});
|
|
props.setHasUnsavedChanges(true);
|
|
};
|
|
|
|
const backupDefaultsRecord = createMemo(() => {
|
|
const current = props.backupDefaults();
|
|
return {
|
|
'warning days': current.warningDays ?? 0,
|
|
'critical days': current.criticalDays ?? 0,
|
|
};
|
|
});
|
|
|
|
const backupFactoryDefaultsRecord = createMemo(() => {
|
|
const factory = backupFactoryConfig();
|
|
return {
|
|
'warning days': factory.warningDays ?? DEFAULT_BACKUP_WARNING,
|
|
'critical days': factory.criticalDays ?? DEFAULT_BACKUP_CRITICAL,
|
|
};
|
|
});
|
|
|
|
const snapshotOverridesCount = createMemo(() => {
|
|
const current = props.snapshotDefaults();
|
|
const factory = snapshotFactoryConfig();
|
|
const differs =
|
|
current.enabled !== factory.enabled ||
|
|
(current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !==
|
|
(factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) ||
|
|
(current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !==
|
|
(factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) ||
|
|
(current.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) !==
|
|
(factory.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) ||
|
|
(current.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE) !==
|
|
(factory.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE);
|
|
return differs ? 1 : 0;
|
|
});
|
|
|
|
const backupOverridesCount = createMemo(() => {
|
|
const backupCurrent = props.backupDefaults();
|
|
const backupFactory = backupFactoryConfig();
|
|
return backupCurrent.enabled !== backupFactory.enabled ||
|
|
(backupCurrent.warningDays ?? DEFAULT_BACKUP_WARNING) !==
|
|
(backupFactory.warningDays ?? DEFAULT_BACKUP_WARNING) ||
|
|
(backupCurrent.criticalDays ?? DEFAULT_BACKUP_CRITICAL) !==
|
|
(backupFactory.criticalDays ?? DEFAULT_BACKUP_CRITICAL)
|
|
? 1
|
|
: 0;
|
|
});
|
|
|
|
// Process guests with their overrides and group by node
|
|
const guestsGroupedByNode = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
|
// If we're currently editing, return the previous value to avoid re-renders
|
|
if (editingId()) {
|
|
return prev;
|
|
}
|
|
|
|
const search = searchTerm().toLowerCase();
|
|
const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o]));
|
|
|
|
const guests = (props.allGuests() ?? []).map((guest) => {
|
|
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
|
|
const override = overridesMap.get(guestId);
|
|
const overrideSeverity = override?.poweredOffSeverity;
|
|
|
|
// Check if any threshold values actually differ from defaults
|
|
const hasCustomThresholds =
|
|
override?.thresholds &&
|
|
Object.keys(override.thresholds).some((key) => {
|
|
const k = key as keyof typeof override.thresholds;
|
|
return (
|
|
override.thresholds[k] !== undefined &&
|
|
override.thresholds[k] !== (props.guestDefaults as any)[k]
|
|
);
|
|
});
|
|
|
|
// A guest has an override if it has custom thresholds OR is disabled OR has connectivity disabled
|
|
const hasOverride =
|
|
hasCustomThresholds ||
|
|
override?.disabled ||
|
|
override?.disableConnectivity ||
|
|
overrideSeverity !== undefined ||
|
|
false;
|
|
|
|
return {
|
|
id: guestId,
|
|
name: guest.name,
|
|
type: 'guest' as const,
|
|
resourceType: guest.type === 'qemu' ? 'VM' : 'CT',
|
|
vmid: guest.vmid,
|
|
node: guest.node,
|
|
instance: guest.instance,
|
|
status: guest.status,
|
|
hasOverride: hasOverride,
|
|
disabled: override?.disabled || false,
|
|
disableConnectivity: override?.disableConnectivity || false,
|
|
thresholds: override?.thresholds || {},
|
|
defaults: props.guestDefaults,
|
|
poweredOffSeverity: overrideSeverity,
|
|
};
|
|
});
|
|
|
|
const filteredGuests = search
|
|
? guests.filter(
|
|
(g) =>
|
|
g.name.toLowerCase().includes(search) ||
|
|
g.vmid?.toString().includes(search) ||
|
|
g.node?.toLowerCase().includes(search),
|
|
)
|
|
: guests;
|
|
|
|
// Group by node
|
|
const grouped: Record<string, Resource[]> = {};
|
|
filteredGuests.forEach((guest) => {
|
|
const node = guest.node || 'Unknown';
|
|
if (!grouped[node]) {
|
|
grouped[node] = [];
|
|
}
|
|
grouped[node].push(guest);
|
|
});
|
|
|
|
// Sort guests within each group by vmid
|
|
Object.keys(grouped).forEach((node) => {
|
|
grouped[node].sort((a, b) => {
|
|
if (a.vmid && b.vmid) return a.vmid - b.vmid;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
});
|
|
|
|
return grouped;
|
|
}, {});
|
|
|
|
const guestsFlat = createMemo<Resource[]>(() =>
|
|
Object.values(guestsGroupedByNode() ?? {}).flat(),
|
|
);
|
|
|
|
const guestGroupHeaderMeta = createMemo<Record<string, GroupHeaderMeta>>(() => {
|
|
const meta: Record<string, GroupHeaderMeta> = {};
|
|
(props.nodes ?? []).forEach((node) => {
|
|
const { headerMeta, keys } = buildNodeHeaderMeta(node);
|
|
keys.forEach((key) => {
|
|
meta[key] = headerMeta;
|
|
});
|
|
});
|
|
return meta;
|
|
});
|
|
|
|
// Process PBS servers with their overrides
|
|
const pbsServersWithOverrides = createMemo<Resource[]>((prev = []) => {
|
|
// If we're currently editing, return the previous value to avoid re-renders
|
|
if (editingId()) {
|
|
return prev;
|
|
}
|
|
|
|
const search = searchTerm().toLowerCase();
|
|
const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o]));
|
|
|
|
// Get PBS instances from props
|
|
const pbsInstances = props.pbsInstances || [];
|
|
|
|
const pbsServers = pbsInstances.map((pbs) => {
|
|
// Offline PBS instances report zero metrics; keep them visible so connectivity toggles stay usable
|
|
// PBS IDs already have "pbs-" prefix from backend, don't double it
|
|
const pbsId = pbs.id;
|
|
const override = overridesMap.get(pbsId);
|
|
|
|
// Check if any threshold values actually differ from defaults
|
|
const hasCustomThresholds =
|
|
override?.thresholds &&
|
|
Object.keys(override.thresholds).some((key) => {
|
|
const k = key as keyof typeof override.thresholds;
|
|
// PBS uses node defaults for CPU/Memory
|
|
return (
|
|
override.thresholds[k] !== undefined &&
|
|
override.thresholds[k] !== props.nodeDefaults[k as keyof typeof props.nodeDefaults]
|
|
);
|
|
});
|
|
|
|
const disableConnectivity = override?.disableConnectivity || false;
|
|
const hasOverride = hasCustomThresholds || disableConnectivity;
|
|
|
|
return {
|
|
id: pbsId,
|
|
name: pbs.name,
|
|
type: 'pbs' as const,
|
|
resourceType: 'PBS',
|
|
host: pbs.host,
|
|
status: pbs.status,
|
|
cpu: pbs.cpu,
|
|
memory: pbs.memory,
|
|
memoryUsed: pbs.memoryUsed,
|
|
memoryTotal: pbs.memoryTotal,
|
|
uptime: pbs.uptime,
|
|
hasOverride,
|
|
disabled: false,
|
|
disableConnectivity,
|
|
thresholds: override?.thresholds || {},
|
|
defaults: {
|
|
cpu: props.nodeDefaults.cpu,
|
|
memory: props.nodeDefaults.memory,
|
|
},
|
|
};
|
|
});
|
|
|
|
if (search) {
|
|
return pbsServers.filter(
|
|
(p) => p.name.toLowerCase().includes(search) || p.host?.toLowerCase().includes(search),
|
|
);
|
|
}
|
|
return pbsServers;
|
|
}, []);
|
|
|
|
const pmgGlobalDefaults = createMemo<Record<string, number>>(() => {
|
|
const defaults = props.pmgThresholds();
|
|
const record: Record<string, number> = {};
|
|
PMG_THRESHOLD_COLUMNS.forEach(({ key, normalized }) => {
|
|
const value = defaults[key];
|
|
record[normalized] = typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
});
|
|
return record;
|
|
});
|
|
|
|
const setPMGGlobalDefaults = (
|
|
value:
|
|
| Record<string, number | undefined>
|
|
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
|
) => {
|
|
const current = pmgGlobalDefaults();
|
|
const nextRecord =
|
|
typeof value === 'function' ? value({ ...current }) : { ...current, ...value };
|
|
|
|
let changed = false;
|
|
props.setPMGThresholds((prev: PMGThresholdDefaults) => {
|
|
const updated: PMGThresholdDefaults = { ...prev };
|
|
PMG_THRESHOLD_COLUMNS.forEach(({ key, normalized }) => {
|
|
const raw = nextRecord[normalized];
|
|
if (typeof raw === 'number' && !Number.isNaN(raw)) {
|
|
const sanitized = Math.max(0, Math.round(raw));
|
|
if (updated[key] !== sanitized) {
|
|
updated[key] = sanitized;
|
|
changed = true;
|
|
}
|
|
}
|
|
});
|
|
return updated;
|
|
});
|
|
|
|
if (changed) {
|
|
props.setHasUnsavedChanges(true);
|
|
}
|
|
};
|
|
|
|
// Process PMG servers with their overrides
|
|
const pmgServersWithOverrides = createMemo<Resource[]>((prev = []) => {
|
|
// If we're currently editing, return the previous value to avoid re-renders
|
|
if (editingId()) {
|
|
return prev;
|
|
}
|
|
|
|
const search = searchTerm().toLowerCase();
|
|
const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o]));
|
|
|
|
// Get PMG instances from props
|
|
const pmgInstances = props.pmgInstances || [];
|
|
const defaultThresholds = pmgGlobalDefaults();
|
|
|
|
const pmgServers = pmgInstances.map((pmg) => {
|
|
// PMG IDs should already have appropriate prefix from backend
|
|
const pmgId = pmg.id;
|
|
const override = overridesMap.get(pmgId);
|
|
|
|
const thresholdOverrides: Record<string, number> = {};
|
|
const overrideThresholds = (override?.thresholds ?? {}) as Record<string, unknown>;
|
|
Object.entries(overrideThresholds).forEach(([rawKey, rawValue]) => {
|
|
if (typeof rawValue !== 'number' || Number.isNaN(rawValue)) return;
|
|
const normalizedKey =
|
|
PMG_KEY_TO_NORMALIZED.get(rawKey as keyof PMGThresholdDefaults) ||
|
|
(PMG_NORMALIZED_TO_KEY.has(rawKey) ? rawKey : undefined);
|
|
if (!normalizedKey) return;
|
|
thresholdOverrides[normalizedKey] = rawValue;
|
|
});
|
|
|
|
const hasOverride =
|
|
override?.disableConnectivity ||
|
|
override?.disabled ||
|
|
Object.keys(thresholdOverrides).length > 0 ||
|
|
false;
|
|
|
|
return {
|
|
id: pmgId,
|
|
name: pmg.name,
|
|
type: 'pmg' as const,
|
|
resourceType: 'PMG',
|
|
host: pmg.host,
|
|
status: pmg.status,
|
|
hasOverride,
|
|
disabled: override?.disabled || false,
|
|
disableConnectivity: override?.disableConnectivity || false,
|
|
thresholds: thresholdOverrides,
|
|
defaults: { ...defaultThresholds },
|
|
};
|
|
});
|
|
|
|
if (search) {
|
|
return pmgServers.filter(
|
|
(p) => p.name.toLowerCase().includes(search) || p.host?.toLowerCase().includes(search),
|
|
);
|
|
}
|
|
return pmgServers;
|
|
}, []);
|
|
|
|
// Process storage with their overrides
|
|
const storageWithOverrides = createMemo<Resource[]>((prev = []) => {
|
|
// If we're currently editing, return the previous value to avoid re-renders
|
|
if (editingId()) {
|
|
return prev;
|
|
}
|
|
|
|
const search = searchTerm().toLowerCase();
|
|
const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o]));
|
|
|
|
const storageDevices = (props.storage ?? []).map((storage) => {
|
|
const override = overridesMap.get(storage.id);
|
|
|
|
// Storage only has usage threshold
|
|
const hasCustomThresholds =
|
|
override?.thresholds?.usage !== undefined &&
|
|
override.thresholds.usage !== props.storageDefault();
|
|
|
|
// A storage device has an override if it has custom thresholds OR is disabled
|
|
const hasOverride = hasCustomThresholds || override?.disabled || false;
|
|
|
|
return {
|
|
id: storage.id,
|
|
name: storage.name,
|
|
type: 'storage' as const,
|
|
resourceType: 'Storage',
|
|
node: storage.node,
|
|
instance: storage.instance,
|
|
status: storage.status,
|
|
hasOverride: hasOverride,
|
|
disabled: override?.disabled || false,
|
|
thresholds: override?.thresholds || {},
|
|
defaults: { usage: props.storageDefault() },
|
|
};
|
|
});
|
|
|
|
if (search) {
|
|
return storageDevices.filter(
|
|
(s) => s.name.toLowerCase().includes(search) || s.node?.toLowerCase().includes(search),
|
|
);
|
|
}
|
|
return storageDevices;
|
|
}, []);
|
|
|
|
const storageGroupedByNode = createMemo<Record<string, Resource[]>>(() => {
|
|
const grouped: Record<string, Resource[]> = {};
|
|
storageWithOverrides().forEach((storage) => {
|
|
const key = storage.node?.trim() || 'Unassigned';
|
|
if (!grouped[key]) {
|
|
grouped[key] = [];
|
|
}
|
|
grouped[key].push(storage);
|
|
});
|
|
|
|
Object.values(grouped).forEach((resources) => {
|
|
resources.sort((a, b) => a.name.localeCompare(b.name));
|
|
});
|
|
|
|
return grouped;
|
|
});
|
|
|
|
const summaryItems = createMemo(() => {
|
|
try {
|
|
const items = [
|
|
{
|
|
key: 'nodes' as const,
|
|
label: 'Nodes',
|
|
total: props.nodes?.length ?? 0,
|
|
overrides: countOverrides(nodesWithOverrides()),
|
|
tab: 'proxmox' as const,
|
|
},
|
|
{
|
|
key: 'dockerHosts' as const,
|
|
label: 'Container Hosts',
|
|
total: props.dockerHosts?.length ?? 0,
|
|
overrides: countOverrides(dockerHostsWithOverrides()),
|
|
tab: 'docker' as const,
|
|
},
|
|
{
|
|
key: 'hostAgents' as const,
|
|
label: 'Host Agents',
|
|
total: props.hosts?.length ?? 0,
|
|
overrides: countOverrides(hostAgentsWithOverrides()),
|
|
tab: 'hosts' as const,
|
|
},
|
|
{
|
|
key: 'storage' as const,
|
|
label: 'Storage',
|
|
total: props.storage?.length ?? 0,
|
|
overrides: countOverrides(storageWithOverrides()),
|
|
tab: 'proxmox' as const,
|
|
},
|
|
{
|
|
key: 'backups' as const,
|
|
label: 'Backups',
|
|
total: 1,
|
|
overrides: backupOverridesCount(),
|
|
tab: 'proxmox' as const,
|
|
},
|
|
{
|
|
key: 'snapshots' as const,
|
|
label: 'Snapshot Age',
|
|
total: 1,
|
|
overrides: snapshotOverridesCount(),
|
|
tab: 'proxmox' as const,
|
|
},
|
|
{
|
|
key: 'pbs' as const,
|
|
label: 'PBS Servers',
|
|
total: props.pbsInstances?.length ?? 0,
|
|
overrides: countOverrides(pbsServersWithOverrides()),
|
|
tab: 'proxmox' as const,
|
|
},
|
|
{
|
|
key: 'pmg' as const,
|
|
label: 'Mail Gateways',
|
|
total: props.pmgInstances?.length ?? 0,
|
|
overrides: countOverrides(pmgServersWithOverrides()),
|
|
tab: 'pmg' as const,
|
|
},
|
|
{
|
|
key: 'dockerContainers' as const,
|
|
label: 'Containers',
|
|
total: totalDockerContainers() ?? 0,
|
|
overrides: countOverrides(dockerContainersFlat()),
|
|
tab: 'docker' as const,
|
|
},
|
|
{
|
|
key: 'guests' as const,
|
|
label: 'VMs & Containers',
|
|
total: props.allGuests?.()?.length ?? 0,
|
|
overrides: countOverrides(guestsFlat()),
|
|
tab: 'proxmox' as const,
|
|
},
|
|
];
|
|
|
|
const filtered = items.filter((item) => item.total > 0 || item.overrides > 0);
|
|
return filtered.filter((item) => item.tab === activeTab());
|
|
} catch (err) {
|
|
logger.error('Error in summaryItems memo:', err);
|
|
return [];
|
|
}
|
|
});
|
|
|
|
const hasSection = (key: string) => summaryItems()?.some((item) => item.key === key) ?? false;
|
|
|
|
const startEditing = (
|
|
resourceId: string,
|
|
currentThresholds: Record<string, number | undefined>,
|
|
defaults: Record<string, number | undefined>,
|
|
note?: string,
|
|
) => {
|
|
setEditingId(resourceId);
|
|
// Merge defaults with overrides for editing
|
|
const mergedThresholds = { ...defaults, ...currentThresholds };
|
|
setEditingThresholds(mergedThresholds);
|
|
setEditingNote(note ?? '');
|
|
};
|
|
|
|
const saveEdit = (resourceId: string) => {
|
|
// Flatten grouped guests to find the resource
|
|
const allGuests = guestsFlat();
|
|
const allDockerContainers = dockerContainersFlat();
|
|
const allResources = [
|
|
...nodesWithOverrides(),
|
|
...hostAgentsWithOverrides(),
|
|
...dockerHostsWithOverrides(),
|
|
...allGuests,
|
|
...allDockerContainers,
|
|
...storageWithOverrides(),
|
|
...pbsServersWithOverrides(),
|
|
];
|
|
const resource = allResources.find((r) => r.id === resourceId);
|
|
if (!resource) return;
|
|
|
|
const editedThresholds = editingThresholds();
|
|
const trimmedNote = editingNote().trim();
|
|
const noteForOverride = trimmedNote.length > 0 ? trimmedNote : undefined;
|
|
|
|
if (resource.editScope === 'backup') {
|
|
const currentBackupDefaults = props.backupDefaults();
|
|
const nextWarning =
|
|
editedThresholds['warning days'] ??
|
|
currentBackupDefaults.warningDays ??
|
|
DEFAULT_BACKUP_WARNING;
|
|
const nextCritical =
|
|
editedThresholds['critical days'] ??
|
|
currentBackupDefaults.criticalDays ??
|
|
DEFAULT_BACKUP_CRITICAL;
|
|
|
|
updateBackupDefaults({
|
|
enabled: currentBackupDefaults.enabled,
|
|
warningDays: nextWarning,
|
|
criticalDays: nextCritical,
|
|
});
|
|
|
|
cancelEdit();
|
|
return;
|
|
}
|
|
|
|
if (resource.editScope === 'snapshot') {
|
|
const currentSnapshotDefaults = props.snapshotDefaults();
|
|
const nextWarning =
|
|
editedThresholds['warning days'] ??
|
|
currentSnapshotDefaults.warningDays ??
|
|
DEFAULT_SNAPSHOT_WARNING;
|
|
const nextCritical =
|
|
editedThresholds['critical days'] ??
|
|
currentSnapshotDefaults.criticalDays ??
|
|
DEFAULT_SNAPSHOT_CRITICAL;
|
|
const nextWarningSize =
|
|
editedThresholds['warning size (gib)'] ??
|
|
currentSnapshotDefaults.warningSizeGiB ??
|
|
DEFAULT_SNAPSHOT_WARNING_SIZE;
|
|
const nextCriticalSize =
|
|
editedThresholds['critical size (gib)'] ??
|
|
currentSnapshotDefaults.criticalSizeGiB ??
|
|
DEFAULT_SNAPSHOT_CRITICAL_SIZE;
|
|
|
|
updateSnapshotDefaults({
|
|
enabled: currentSnapshotDefaults.enabled,
|
|
warningDays: nextWarning,
|
|
criticalDays: nextCritical,
|
|
warningSizeGiB: nextWarningSize,
|
|
criticalSizeGiB: nextCriticalSize,
|
|
});
|
|
|
|
cancelEdit();
|
|
return;
|
|
}
|
|
|
|
const defaultThresholds = (resource.defaults ?? {}) as Record<string, number | undefined>;
|
|
|
|
// Only include values that differ from defaults
|
|
const overrideThresholds: Record<string, number> = {};
|
|
Object.keys(editedThresholds).forEach((key) => {
|
|
const editedValue = editedThresholds[key];
|
|
const defaultValue = defaultThresholds[key as keyof typeof defaultThresholds];
|
|
if (editedValue !== undefined && editedValue !== defaultValue) {
|
|
overrideThresholds[key] = editedValue;
|
|
}
|
|
});
|
|
|
|
const hasStateOnlyOverride = Boolean(
|
|
resource.disabled ||
|
|
resource.disableConnectivity ||
|
|
resource.poweredOffSeverity !== undefined ||
|
|
noteForOverride !== undefined,
|
|
);
|
|
|
|
// If no threshold overrides or state flags remain, remove the override entirely
|
|
if (Object.keys(overrideThresholds).length === 0 && !hasStateOnlyOverride) {
|
|
// If there was an existing override, remove it
|
|
if (resource.hasOverride) {
|
|
const newOverrides = props.overrides().filter((o) => o.id !== resourceId);
|
|
props.setOverrides(newOverrides);
|
|
|
|
// Also remove from raw config
|
|
const newRawConfig = { ...props.rawOverridesConfig() };
|
|
delete newRawConfig[resourceId];
|
|
props.setRawOverridesConfig(newRawConfig);
|
|
props.setHasUnsavedChanges(true);
|
|
}
|
|
cancelEdit();
|
|
return;
|
|
}
|
|
|
|
// Create or update override
|
|
const override: Override = {
|
|
id: resourceId,
|
|
name: resource.name,
|
|
type: resource.type as OverrideType,
|
|
resourceType: resource.resourceType,
|
|
vmid: 'vmid' in resource ? resource.vmid : undefined,
|
|
node: 'node' in resource ? resource.node : undefined,
|
|
instance: 'instance' in resource ? resource.instance : undefined,
|
|
disabled: resource.disabled,
|
|
disableConnectivity: resource.disableConnectivity,
|
|
poweredOffSeverity: resource.poweredOffSeverity,
|
|
note: noteForOverride,
|
|
thresholds: overrideThresholds,
|
|
};
|
|
|
|
// Update overrides list
|
|
const existingIndex = props.overrides().findIndex((o) => o.id === resourceId);
|
|
if (existingIndex >= 0) {
|
|
const newOverrides = [...props.overrides()];
|
|
newOverrides[existingIndex] = override;
|
|
props.setOverrides(newOverrides);
|
|
} else {
|
|
props.setOverrides([...props.overrides(), override]);
|
|
}
|
|
|
|
// Update raw config
|
|
const newRawConfig: Record<string, RawOverrideConfig> = { ...props.rawOverridesConfig() };
|
|
const previousRaw = props.rawOverridesConfig()[resourceId];
|
|
const hysteresisThresholds: RawOverrideConfig = {};
|
|
if (previousRaw) {
|
|
if (previousRaw.disabled !== undefined) {
|
|
hysteresisThresholds.disabled = previousRaw.disabled;
|
|
}
|
|
if (previousRaw.disableConnectivity !== undefined) {
|
|
hysteresisThresholds.disableConnectivity = previousRaw.disableConnectivity;
|
|
}
|
|
if (previousRaw.poweredOffSeverity) {
|
|
hysteresisThresholds.poweredOffSeverity = previousRaw.poweredOffSeverity;
|
|
}
|
|
}
|
|
Object.entries(overrideThresholds).forEach(([metric, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
hysteresisThresholds[metric] = {
|
|
trigger: value,
|
|
clear: Math.max(0, value - 5),
|
|
};
|
|
}
|
|
});
|
|
if (resource.disabled) {
|
|
hysteresisThresholds.disabled = true;
|
|
} else {
|
|
delete hysteresisThresholds.disabled;
|
|
}
|
|
if (resource.disableConnectivity) {
|
|
hysteresisThresholds.disableConnectivity = true;
|
|
delete hysteresisThresholds.poweredOffSeverity;
|
|
} else {
|
|
if (
|
|
(resource.type === 'guest' || resource.type === 'dockerContainer') &&
|
|
props.guestDisableConnectivity()
|
|
) {
|
|
hysteresisThresholds.disableConnectivity = false;
|
|
} else {
|
|
delete hysteresisThresholds.disableConnectivity;
|
|
}
|
|
if (resource.poweredOffSeverity) {
|
|
hysteresisThresholds.poweredOffSeverity = resource.poweredOffSeverity;
|
|
} else {
|
|
delete hysteresisThresholds.poweredOffSeverity;
|
|
}
|
|
}
|
|
if (noteForOverride) {
|
|
hysteresisThresholds.note = noteForOverride;
|
|
} else {
|
|
delete hysteresisThresholds.note;
|
|
}
|
|
newRawConfig[resourceId] = hysteresisThresholds;
|
|
props.setRawOverridesConfig(newRawConfig);
|
|
|
|
props.setHasUnsavedChanges(true);
|
|
setEditingId(null);
|
|
setEditingThresholds({});
|
|
setEditingNote('');
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setEditingId(null);
|
|
setEditingThresholds({});
|
|
setEditingNote('');
|
|
};
|
|
|
|
const updateMetricDelay = (
|
|
typeKey: 'guest' | 'node' | 'storage' | 'pbs',
|
|
metricKey: string,
|
|
value: number | null,
|
|
) => {
|
|
const normalizedMetric = metricKey.trim().toLowerCase();
|
|
if (!normalizedMetric) return;
|
|
|
|
let changed = false;
|
|
props.setMetricTimeThresholds((prev) => {
|
|
const current = prev ? { ...prev } : {};
|
|
const existing = prev?.[typeKey];
|
|
const typeOverrides = existing ? { ...existing } : {};
|
|
|
|
if (value === null) {
|
|
if (typeOverrides[normalizedMetric] === undefined) {
|
|
return prev;
|
|
}
|
|
delete typeOverrides[normalizedMetric];
|
|
changed = true;
|
|
} else {
|
|
const sanitized = Math.max(0, Math.round(value));
|
|
if (typeOverrides[normalizedMetric] === sanitized) {
|
|
return prev;
|
|
}
|
|
typeOverrides[normalizedMetric] = sanitized;
|
|
changed = true;
|
|
}
|
|
|
|
if (!changed) {
|
|
return prev;
|
|
}
|
|
|
|
if (Object.keys(typeOverrides).length === 0) {
|
|
delete current[typeKey];
|
|
} else {
|
|
current[typeKey] = typeOverrides;
|
|
}
|
|
|
|
return current;
|
|
});
|
|
|
|
if (changed) {
|
|
props.setHasUnsavedChanges(true);
|
|
}
|
|
};
|
|
|
|
const removeOverride = (resourceId: string) => {
|
|
props.setOverrides(props.overrides().filter((o) => o.id !== resourceId));
|
|
|
|
const newRawConfig = { ...props.rawOverridesConfig() };
|
|
delete newRawConfig[resourceId];
|
|
props.setRawOverridesConfig(newRawConfig);
|
|
|
|
props.setHasUnsavedChanges(true);
|
|
};
|
|
|
|
const toggleDisabled = (resourceId: string, forceState?: boolean) => {
|
|
// Flatten grouped guests to find the resource
|
|
const allGuests = guestsFlat();
|
|
const allDockerContainers = dockerContainersFlat();
|
|
const allResources = [
|
|
...allGuests,
|
|
...allDockerContainers,
|
|
...storageWithOverrides(),
|
|
...pbsServersWithOverrides(),
|
|
];
|
|
const resource = allResources.find((r) => r.id === resourceId);
|
|
if (
|
|
!resource ||
|
|
(resource.type !== 'guest' &&
|
|
resource.type !== 'storage' &&
|
|
resource.type !== 'pbs' &&
|
|
resource.type !== 'dockerContainer')
|
|
)
|
|
return;
|
|
|
|
// Get existing override if it exists
|
|
const existingOverride = props.overrides().find((o) => o.id === resourceId);
|
|
|
|
// Determine the current disabled state - check the resource's current state, not the override
|
|
const currentDisabledState = resource.disabled;
|
|
const newDisabledState = forceState !== undefined ? forceState : !currentDisabledState;
|
|
|
|
// Clean the thresholds to exclude 'disabled' if it got in there
|
|
const cleanThresholds: Record<string, number> = { ...(existingOverride?.thresholds || {}) };
|
|
delete (cleanThresholds as Record<string, unknown>).disabled;
|
|
|
|
// If enabling (disabled = false) and no custom thresholds exist, remove the override entirely
|
|
if (!newDisabledState && (!existingOverride || Object.keys(cleanThresholds).length === 0)) {
|
|
// Remove the override completely
|
|
props.setOverrides(props.overrides().filter((o) => o.id !== resourceId));
|
|
|
|
// Remove from raw config
|
|
const newRawConfig = { ...props.rawOverridesConfig() };
|
|
delete newRawConfig[resourceId];
|
|
props.setRawOverridesConfig(newRawConfig);
|
|
} else {
|
|
const override: Override = {
|
|
id: resourceId,
|
|
name: resource.name,
|
|
type: resource.type,
|
|
resourceType: resource.resourceType,
|
|
vmid: 'vmid' in resource ? resource.vmid : undefined,
|
|
node: 'node' in resource ? resource.node : undefined,
|
|
instance: 'instance' in resource ? resource.instance : undefined,
|
|
disabled: newDisabledState,
|
|
thresholds: cleanThresholds, // Only keep actual threshold overrides
|
|
};
|
|
|
|
const existingIndex = props.overrides().findIndex((o) => o.id === resourceId);
|
|
if (existingIndex >= 0) {
|
|
const newOverrides = [...props.overrides()];
|
|
newOverrides[existingIndex] = override;
|
|
props.setOverrides(newOverrides);
|
|
} else {
|
|
props.setOverrides([...props.overrides(), override]);
|
|
}
|
|
|
|
// Update raw config
|
|
const newRawConfig: Record<string, RawOverrideConfig> = { ...props.rawOverridesConfig() };
|
|
const hysteresisThresholds: RawOverrideConfig = {};
|
|
|
|
// Only add threshold overrides that differ from defaults
|
|
Object.entries(override.thresholds).forEach(([metric, value]) => {
|
|
if (typeof value === 'number') {
|
|
hysteresisThresholds[metric] = {
|
|
trigger: value,
|
|
clear: Math.max(0, value - 5),
|
|
};
|
|
}
|
|
});
|
|
|
|
if (newDisabledState) {
|
|
hysteresisThresholds.disabled = true;
|
|
} else {
|
|
delete hysteresisThresholds.disabled;
|
|
}
|
|
|
|
if (Object.keys(hysteresisThresholds).length === 0) {
|
|
delete newRawConfig[resourceId];
|
|
} else {
|
|
newRawConfig[resourceId] = hysteresisThresholds;
|
|
}
|
|
props.setRawOverridesConfig(newRawConfig);
|
|
}
|
|
|
|
if (newDisabledState && props.removeAlerts) {
|
|
if (resource.type === 'guest') {
|
|
props.removeAlerts(
|
|
(alert) => alert.resourceId === resourceId && alert.type === 'powered-off',
|
|
);
|
|
} else if (resource.type === 'pbs') {
|
|
const offlineId = `pbs-offline-${resourceId}`;
|
|
props.removeAlerts(
|
|
(alert) =>
|
|
alert.resourceId === resourceId && (alert.id === offlineId || alert.type === 'offline'),
|
|
);
|
|
} else if (resource.type === 'dockerContainer') {
|
|
props.removeAlerts(
|
|
(alert) =>
|
|
alert.resourceId === resourceId &&
|
|
(alert.type === 'docker-container-state' || alert.type === 'docker-container-health'),
|
|
);
|
|
}
|
|
}
|
|
|
|
props.setHasUnsavedChanges(true);
|
|
};
|
|
|
|
const toggleNodeConnectivity = (resourceId: string, forceState?: boolean) => {
|
|
// Find the resource - could be a node, PBS server, or guest
|
|
const nodes = nodesWithOverrides();
|
|
const pbsServers = pbsServersWithOverrides();
|
|
const guests = guestsFlat();
|
|
const hostAgents = hostAgentsWithOverrides();
|
|
const dockerHosts = dockerHostsWithOverrides();
|
|
const resource = [...nodes, ...pbsServers, ...guests, ...hostAgents, ...dockerHosts].find(
|
|
(r) => r.id === resourceId,
|
|
);
|
|
if (
|
|
!resource ||
|
|
(resource.type !== 'node' &&
|
|
resource.type !== 'pbs' &&
|
|
resource.type !== 'guest' &&
|
|
resource.type !== 'hostAgent' &&
|
|
resource.type !== 'dockerHost')
|
|
)
|
|
return;
|
|
|
|
// Get existing override if it exists
|
|
const existingOverride = props.overrides().find((o) => o.id === resourceId);
|
|
|
|
// Determine the current state - use the resource's computed state, not just the override
|
|
const currentDisableConnectivity = resource.disableConnectivity;
|
|
const newDisableConnectivity =
|
|
forceState !== undefined ? forceState : !currentDisableConnectivity;
|
|
|
|
// Clean the thresholds to exclude any unwanted fields
|
|
const cleanThresholds: Record<string, number> = { ...(existingOverride?.thresholds || {}) };
|
|
delete (cleanThresholds as Record<string, unknown>).disabled;
|
|
delete (cleanThresholds as Record<string, unknown>).disableConnectivity;
|
|
|
|
// If enabling connectivity alerts (disableConnectivity = false) and no custom thresholds exist, remove the override entirely
|
|
if (!newDisableConnectivity && Object.keys(cleanThresholds).length === 0) {
|
|
// Remove the override completely
|
|
props.setOverrides(props.overrides().filter((o) => o.id !== resourceId));
|
|
|
|
// Remove from raw config
|
|
const newRawConfig = { ...props.rawOverridesConfig() };
|
|
delete newRawConfig[resourceId];
|
|
props.setRawOverridesConfig(newRawConfig);
|
|
} else {
|
|
// Update or create the override
|
|
const override: Override = {
|
|
id: resourceId,
|
|
name: resource.name,
|
|
type: resource.type as OverrideType,
|
|
resourceType: resource.resourceType,
|
|
disableConnectivity: newDisableConnectivity,
|
|
thresholds: cleanThresholds,
|
|
};
|
|
|
|
// Update overrides list
|
|
const existingIndex = props.overrides().findIndex((o) => o.id === resourceId);
|
|
if (existingIndex >= 0) {
|
|
const newOverrides = [...props.overrides()];
|
|
newOverrides[existingIndex] = override;
|
|
props.setOverrides(newOverrides);
|
|
} else {
|
|
props.setOverrides([...props.overrides(), override]);
|
|
}
|
|
|
|
// Update raw config
|
|
const newRawConfig = { ...props.rawOverridesConfig() };
|
|
const hysteresisThresholds: Record<string, any> = {};
|
|
|
|
// Add threshold configs
|
|
Object.entries(cleanThresholds).forEach(([metric, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
hysteresisThresholds[metric] = {
|
|
trigger: value,
|
|
clear: Math.max(0, (value as number) - 5),
|
|
};
|
|
}
|
|
});
|
|
|
|
if (newDisableConnectivity) {
|
|
hysteresisThresholds.disableConnectivity = true;
|
|
} else {
|
|
delete hysteresisThresholds.disableConnectivity;
|
|
}
|
|
|
|
if (Object.keys(hysteresisThresholds).length === 0) {
|
|
delete newRawConfig[resourceId];
|
|
} else {
|
|
newRawConfig[resourceId] = hysteresisThresholds;
|
|
}
|
|
props.setRawOverridesConfig(newRawConfig);
|
|
}
|
|
|
|
props.setHasUnsavedChanges(true);
|
|
|
|
if (props.removeAlerts && resource.type === 'dockerHost') {
|
|
const offlineId = `docker-host-offline-${resourceId}`;
|
|
const resourceKey = `docker:${resourceId}`;
|
|
props.removeAlerts((alert) => alert.id === offlineId || alert.resourceId === resourceKey);
|
|
}
|
|
};
|
|
|
|
const setOfflineState = (resourceId: string, state: OfflineState) => {
|
|
const guests = guestsFlat();
|
|
const dockerContainers = dockerContainersFlat();
|
|
const resource = [...guests, ...dockerContainers].find((r) => r.id === resourceId);
|
|
if (!resource) return;
|
|
|
|
const isDockerContainer = resource.type === 'dockerContainer';
|
|
const defaultDisabled = isDockerContainer
|
|
? props.dockerDisableConnectivity()
|
|
: props.guestDisableConnectivity();
|
|
const defaultSeverity = isDockerContainer
|
|
? props.dockerPoweredOffSeverity()
|
|
: props.guestPoweredOffSeverity();
|
|
|
|
const existingOverride = props.overrides().find((o) => o.id === resourceId);
|
|
const cleanThresholds: Record<string, number> = { ...(existingOverride?.thresholds || {}) };
|
|
delete (cleanThresholds as Record<string, unknown>).disabled;
|
|
delete (cleanThresholds as Record<string, unknown>).disableConnectivity;
|
|
delete (cleanThresholds as Record<string, unknown>).poweredOffSeverity;
|
|
|
|
const newDisableConnectivity = state === 'off';
|
|
const newSeverity: 'warning' | 'critical' | undefined =
|
|
state === 'off' ? undefined : state === 'critical' ? 'critical' : 'warning';
|
|
|
|
const overrideDisabled = existingOverride?.disabled || false;
|
|
const hasThresholds = Object.keys(cleanThresholds).length > 0;
|
|
|
|
const differsFromDefaults =
|
|
newDisableConnectivity !== defaultDisabled ||
|
|
(!newDisableConnectivity && newSeverity !== defaultSeverity);
|
|
|
|
if (
|
|
!differsFromDefaults &&
|
|
!hasThresholds &&
|
|
!overrideDisabled &&
|
|
!existingOverride?.disableConnectivity
|
|
) {
|
|
// Remove override entirely
|
|
if (existingOverride) {
|
|
props.setOverrides(props.overrides().filter((o) => o.id !== resourceId));
|
|
const newRawConfig = { ...props.rawOverridesConfig() };
|
|
delete newRawConfig[resourceId];
|
|
props.setRawOverridesConfig(newRawConfig);
|
|
props.setHasUnsavedChanges(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const override: Override = {
|
|
id: resourceId,
|
|
name: resource.name,
|
|
type: resource.type as OverrideType,
|
|
resourceType: resource.resourceType,
|
|
vmid: 'vmid' in resource ? resource.vmid : undefined,
|
|
node: 'node' in resource ? resource.node : undefined,
|
|
instance: 'instance' in resource ? resource.instance : undefined,
|
|
disabled: overrideDisabled,
|
|
disableConnectivity: newDisableConnectivity,
|
|
poweredOffSeverity: newDisableConnectivity ? undefined : newSeverity,
|
|
thresholds: cleanThresholds,
|
|
};
|
|
|
|
const existingIndex = props.overrides().findIndex((o) => o.id === resourceId);
|
|
if (existingIndex >= 0) {
|
|
const newOverrides = [...props.overrides()];
|
|
newOverrides[existingIndex] = override;
|
|
props.setOverrides(newOverrides);
|
|
} else {
|
|
props.setOverrides([...props.overrides(), override]);
|
|
}
|
|
|
|
const newRawConfig: Record<string, RawOverrideConfig> = { ...props.rawOverridesConfig() };
|
|
const hysteresisThresholds: RawOverrideConfig = {};
|
|
|
|
Object.entries(cleanThresholds).forEach(([metric, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
hysteresisThresholds[metric] = {
|
|
trigger: value,
|
|
clear: Math.max(0, value - 5),
|
|
};
|
|
}
|
|
});
|
|
|
|
if (overrideDisabled) {
|
|
hysteresisThresholds.disabled = true;
|
|
}
|
|
|
|
if (newDisableConnectivity) {
|
|
hysteresisThresholds.disableConnectivity = true;
|
|
} else {
|
|
if (defaultDisabled) {
|
|
hysteresisThresholds.disableConnectivity = false;
|
|
}
|
|
if (newSeverity) {
|
|
hysteresisThresholds.poweredOffSeverity = newSeverity;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(hysteresisThresholds).length > 0) {
|
|
newRawConfig[resourceId] = hysteresisThresholds;
|
|
} else {
|
|
delete newRawConfig[resourceId];
|
|
}
|
|
|
|
props.setRawOverridesConfig(newRawConfig);
|
|
props.setHasUnsavedChanges(true);
|
|
|
|
if (props.removeAlerts && newDisableConnectivity) {
|
|
if (resource.type === 'guest') {
|
|
props.removeAlerts(
|
|
(alert) => alert.resourceId === resourceId && alert.type === 'powered-off',
|
|
);
|
|
} else if (resource.type === 'dockerContainer') {
|
|
props.removeAlerts(
|
|
(alert) =>
|
|
alert.resourceId === resourceId &&
|
|
(alert.type === 'docker-container-state' || alert.type === 'docker-container-health'),
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div class="space-y-6">
|
|
{/* Search Bar */}
|
|
<div class="relative">
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
placeholder="Search resources..."
|
|
value={searchTerm()}
|
|
onInput={(e) => setSearchTerm(e.currentTarget.value)}
|
|
class="w-full pl-10 pr-10 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
<svg
|
|
class="absolute left-3 top-2.5 w-5 h-5 text-gray-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
<Show when={searchTerm()}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearchTerm('')}
|
|
class="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Help Banner */}
|
|
<div class="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3">
|
|
<div class="flex items-start gap-2">
|
|
<svg
|
|
class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<div class="text-sm text-blue-900 dark:text-blue-100">
|
|
<span class="font-medium">Quick tips:</span> Set any threshold to{' '}
|
|
<code class="px-1 py-0.5 bg-blue-100 dark:bg-blue-900/50 rounded text-xs font-mono">
|
|
-1
|
|
</code>{' '}
|
|
to disable alerts for that metric. Click on disabled thresholds showing{' '}
|
|
<span class="italic">Off</span> to re-enable them. Resources with custom settings show a{' '}
|
|
<span class="inline-flex items-center px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded text-xs">
|
|
Custom
|
|
</span>{' '}
|
|
badge.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Navigation */}
|
|
<div class="border-b border-gray-200 dark:border-gray-700">
|
|
<nav class="-mb-px flex gap-6" aria-label="Tabs">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleTabClick('proxmox')}
|
|
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab() === 'proxmox'
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
Proxmox / PBS
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleTabClick('pmg')}
|
|
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab() === 'pmg'
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
Mail Gateway
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleTabClick('hosts')}
|
|
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab() === 'hosts'
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
Host Agents
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleTabClick('docker')}
|
|
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab() === 'docker'
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
Containers
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="space-y-6">
|
|
<Show when={activeTab() === 'proxmox'}>
|
|
<Show when={hasSection('nodes')}>
|
|
<div ref={registerSection('nodes')} class="scroll-mt-24">
|
|
<ResourceTable
|
|
title="Proxmox Nodes"
|
|
resources={nodesWithOverrides()}
|
|
columns={['CPU %', 'Memory %', 'Disk %', 'Temp °C']}
|
|
activeAlerts={props.activeAlerts}
|
|
emptyMessage="No nodes match the current filters."
|
|
onEdit={startEditing}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onRemoveOverride={removeOverride}
|
|
onToggleDisabled={toggleDisabled}
|
|
onToggleNodeConnectivity={toggleNodeConnectivity}
|
|
showOfflineAlertsColumn={true}
|
|
editingId={editingId}
|
|
editingThresholds={editingThresholds}
|
|
setEditingThresholds={setEditingThresholds}
|
|
editingNote={editingNote}
|
|
setEditingNote={setEditingNote}
|
|
formatMetricValue={formatMetricValue}
|
|
hasActiveAlert={hasActiveAlert}
|
|
globalDefaults={props.nodeDefaults}
|
|
setGlobalDefaults={props.setNodeDefaults}
|
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
|
globalDisableFlag={props.disableAllNodes}
|
|
onToggleGlobalDisable={() => props.setDisableAllNodes(!props.disableAllNodes())}
|
|
globalDisableOfflineFlag={props.disableAllNodesOffline}
|
|
onToggleGlobalDisableOffline={() =>
|
|
props.setDisableAllNodesOffline(!props.disableAllNodesOffline())
|
|
}
|
|
showDelayColumn={true}
|
|
globalDelaySeconds={props.timeThresholds().node}
|
|
metricDelaySeconds={props.metricTimeThresholds().node ?? {}}
|
|
onMetricDelayChange={(metric, value) => updateMetricDelay('node', metric, value)}
|
|
factoryDefaults={props.factoryNodeDefaults}
|
|
onResetDefaults={props.resetNodeDefaults}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={hasSection('pbs')}>
|
|
<div ref={registerSection('pbs')} class="scroll-mt-24">
|
|
<ResourceTable
|
|
title="PBS Servers"
|
|
resources={pbsServersWithOverrides()}
|
|
columns={['CPU %', 'Memory %']}
|
|
activeAlerts={props.activeAlerts}
|
|
emptyMessage="No PBS servers match the current filters."
|
|
onEdit={startEditing}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onRemoveOverride={removeOverride}
|
|
onToggleDisabled={toggleDisabled}
|
|
onToggleNodeConnectivity={toggleNodeConnectivity}
|
|
showOfflineAlertsColumn={true}
|
|
editingId={editingId}
|
|
editingThresholds={editingThresholds}
|
|
setEditingThresholds={setEditingThresholds}
|
|
editingNote={editingNote}
|
|
setEditingNote={setEditingNote}
|
|
formatMetricValue={formatMetricValue}
|
|
hasActiveAlert={hasActiveAlert}
|
|
globalDefaults={{ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory }}
|
|
setGlobalDefaults={(value) => {
|
|
if (typeof value === 'function') {
|
|
const newValue = value({
|
|
cpu: props.nodeDefaults.cpu,
|
|
memory: props.nodeDefaults.memory,
|
|
});
|
|
props.setNodeDefaults((prev) => ({
|
|
...prev,
|
|
cpu: newValue.cpu ?? prev.cpu,
|
|
memory: newValue.memory ?? prev.memory,
|
|
}));
|
|
} else {
|
|
props.setNodeDefaults((prev) => ({
|
|
...prev,
|
|
cpu: value.cpu ?? prev.cpu,
|
|
memory: value.memory ?? prev.memory,
|
|
}));
|
|
}
|
|
}}
|
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
|
globalDisableFlag={props.disableAllPBS}
|
|
onToggleGlobalDisable={() => props.setDisableAllPBS(!props.disableAllPBS())}
|
|
globalDisableOfflineFlag={props.disableAllPBSOffline}
|
|
onToggleGlobalDisableOffline={() =>
|
|
props.setDisableAllPBSOffline(!props.disableAllPBSOffline())
|
|
}
|
|
showDelayColumn={true}
|
|
globalDelaySeconds={props.timeThresholds().pbs}
|
|
metricDelaySeconds={props.metricTimeThresholds().pbs ?? {}}
|
|
onMetricDelayChange={(metric, value) => updateMetricDelay('pbs', metric, value)}
|
|
factoryDefaults={
|
|
props.factoryNodeDefaults
|
|
? {
|
|
cpu: props.factoryNodeDefaults.cpu,
|
|
memory: props.factoryNodeDefaults.memory,
|
|
}
|
|
: undefined
|
|
}
|
|
onResetDefaults={props.resetNodeDefaults}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={hasSection('guests')}>
|
|
<div ref={registerSection('guests')} class="scroll-mt-24">
|
|
<ResourceTable
|
|
title="VMs & Containers"
|
|
groupedResources={guestsGroupedByNode()}
|
|
groupHeaderMeta={guestGroupHeaderMeta()}
|
|
columns={[
|
|
'CPU %',
|
|
'Memory %',
|
|
'Disk %',
|
|
'Disk R MB/s',
|
|
'Disk W MB/s',
|
|
'Net In MB/s',
|
|
'Net Out MB/s',
|
|
]}
|
|
activeAlerts={props.activeAlerts}
|
|
emptyMessage="No VMs or containers match the current filters."
|
|
onEdit={startEditing}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onRemoveOverride={removeOverride}
|
|
onToggleDisabled={toggleDisabled}
|
|
onToggleNodeConnectivity={toggleNodeConnectivity}
|
|
showOfflineAlertsColumn={true}
|
|
editingId={editingId}
|
|
editingThresholds={editingThresholds}
|
|
setEditingThresholds={setEditingThresholds}
|
|
editingNote={editingNote}
|
|
setEditingNote={setEditingNote}
|
|
formatMetricValue={formatMetricValue}
|
|
hasActiveAlert={hasActiveAlert}
|
|
globalDefaults={props.guestDefaults}
|
|
setGlobalDefaults={props.setGuestDefaults}
|
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
|
globalDisableFlag={props.disableAllGuests}
|
|
onToggleGlobalDisable={() => props.setDisableAllGuests(!props.disableAllGuests())}
|
|
globalDisableOfflineFlag={() => props.guestDisableConnectivity()}
|
|
onToggleGlobalDisableOffline={() =>
|
|
props.setGuestDisableConnectivity(!props.guestDisableConnectivity())
|
|
}
|
|
globalOfflineSeverity={props.guestPoweredOffSeverity()}
|
|
onSetGlobalOfflineState={(state) => {
|
|
if (state === 'off') {
|
|
props.setGuestDisableConnectivity(true);
|
|
} else {
|
|
props.setGuestDisableConnectivity(false);
|
|
props.setGuestPoweredOffSeverity(state === 'critical' ? 'critical' : 'warning');
|
|
}
|
|
props.setHasUnsavedChanges(true);
|
|
}}
|
|
onSetOfflineState={setOfflineState}
|
|
showDelayColumn={true}
|
|
globalDelaySeconds={props.timeThresholds().guest}
|
|
metricDelaySeconds={props.metricTimeThresholds().guest ?? {}}
|
|
onMetricDelayChange={(metric, value) => updateMetricDelay('guest', metric, value)}
|
|
factoryDefaults={props.factoryGuestDefaults}
|
|
onResetDefaults={props.resetGuestDefaults}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={hasSection('backups')}>
|
|
<div ref={registerSection('backups')} class="scroll-mt-24">
|
|
<ResourceTable
|
|
title="Backups"
|
|
resources={[
|
|
{
|
|
id: 'backups-defaults',
|
|
name: 'Global Defaults',
|
|
thresholds: backupDefaultsRecord(),
|
|
defaults: backupDefaultsRecord(),
|
|
editable: true,
|
|
editScope: 'backup',
|
|
},
|
|
]}
|
|
columns={[
|
|
'Warning Days',
|
|
'Critical Days',
|
|
'Warning Size (GiB)',
|
|
'Critical Size (GiB)',
|
|
]}
|
|
activeAlerts={props.activeAlerts}
|
|
emptyMessage=""
|
|
onEdit={startEditing}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onRemoveOverride={removeOverride}
|
|
showOfflineAlertsColumn={true}
|
|
editingId={editingId}
|
|
editingThresholds={editingThresholds}
|
|
setEditingThresholds={setEditingThresholds}
|
|
editingNote={editingNote}
|
|
setEditingNote={setEditingNote}
|
|
formatMetricValue={formatMetricValue}
|
|
hasActiveAlert={hasActiveAlert}
|
|
globalDefaults={backupDefaultsRecord()}
|
|
setGlobalDefaults={(value) => {
|
|
updateBackupDefaults((prev) => {
|
|
const currentRecord = {
|
|
'warning days': prev.warningDays ?? 0,
|
|
'critical days': prev.criticalDays ?? 0,
|
|
};
|
|
const nextRecord =
|
|
typeof value === 'function'
|
|
? value(currentRecord)
|
|
: { ...currentRecord, ...value };
|
|
return {
|
|
...prev,
|
|
warningDays:
|
|
typeof nextRecord['warning days'] === 'number'
|
|
? nextRecord['warning days']
|
|
: prev.warningDays,
|
|
criticalDays:
|
|
typeof nextRecord['critical days'] === 'number'
|
|
? nextRecord['critical days']
|
|
: prev.criticalDays,
|
|
};
|
|
});
|
|
}}
|
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
|
globalDisableFlag={() => !props.backupDefaults().enabled}
|
|
onToggleGlobalDisable={() =>
|
|
updateBackupDefaults((prev) => ({
|
|
...prev,
|
|
enabled: !prev.enabled,
|
|
}))
|
|
}
|
|
factoryDefaults={backupFactoryDefaultsRecord()}
|
|
onResetDefaults={() => {
|
|
if (props.resetBackupDefaults) {
|
|
props.resetBackupDefaults();
|
|
props.setHasUnsavedChanges(true);
|
|
} else {
|
|
updateBackupDefaults(backupFactoryConfig());
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={hasSection('snapshots')}>
|
|
<div ref={registerSection('snapshots')} class="scroll-mt-24">
|
|
<ResourceTable
|
|
title="Snapshot Age"
|
|
resources={[
|
|
{
|
|
id: 'snapshots-defaults',
|
|
name: 'Global Defaults',
|
|
thresholds: snapshotDefaultsRecord(),
|
|
defaults: snapshotDefaultsRecord(),
|
|
editable: true,
|
|
editScope: 'snapshot',
|
|
},
|
|
]}
|
|
columns={['Warning Days', 'Critical Days']}
|
|
activeAlerts={props.activeAlerts}
|
|
emptyMessage=""
|
|
onEdit={startEditing}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onRemoveOverride={removeOverride}
|
|
showOfflineAlertsColumn={true}
|
|
editingId={editingId}
|
|
editingThresholds={editingThresholds}
|
|
setEditingThresholds={setEditingThresholds}
|
|
editingNote={editingNote}
|
|
setEditingNote={setEditingNote}
|
|
formatMetricValue={formatMetricValue}
|
|
hasActiveAlert={hasActiveAlert}
|
|
globalDefaults={snapshotDefaultsRecord()}
|
|
setGlobalDefaults={(value) => {
|
|
updateSnapshotDefaults((prev) => {
|
|
const currentRecord = {
|
|
'warning days': prev.warningDays ?? 0,
|
|
'critical days': prev.criticalDays ?? 0,
|
|
'warning size (gib)': prev.warningSizeGiB ?? 0,
|
|
'critical size (gib)': prev.criticalSizeGiB ?? 0,
|
|
};
|
|
const nextRecord =
|
|
typeof value === 'function'
|
|
? value(currentRecord)
|
|
: { ...currentRecord, ...value };
|
|
return {
|
|
...prev,
|
|
warningDays:
|
|
typeof nextRecord['warning days'] === 'number'
|
|
? nextRecord['warning days']
|
|
: prev.warningDays,
|
|
criticalDays:
|
|
typeof nextRecord['critical days'] === 'number'
|
|
? nextRecord['critical days']
|
|
: prev.criticalDays,
|
|
warningSizeGiB:
|
|
typeof nextRecord['warning size (gib)'] === 'number'
|
|
? nextRecord['warning size (gib)']
|
|
: prev.warningSizeGiB,
|
|
criticalSizeGiB:
|
|
typeof nextRecord['critical size (gib)'] === 'number'
|
|
? nextRecord['critical size (gib)']
|
|
: prev.criticalSizeGiB,
|
|
};
|
|
});
|
|
}}
|
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
|
globalDisableFlag={() => !props.snapshotDefaults().enabled}
|
|
onToggleGlobalDisable={() =>
|
|
updateSnapshotDefaults((prev) => ({
|
|
...prev,
|
|
enabled: !prev.enabled,
|
|
}))
|
|
}
|
|
factoryDefaults={snapshotFactoryDefaultsRecord()}
|
|
onResetDefaults={() => {
|
|
if (props.resetSnapshotDefaults) {
|
|
props.resetSnapshotDefaults();
|
|
props.setHasUnsavedChanges(true);
|
|
} else {
|
|
updateSnapshotDefaults(snapshotFactoryConfig());
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={hasSection('storage')}>
|
|
<div ref={registerSection('storage')} class="scroll-mt-24">
|
|
<ResourceTable
|
|
title="Storage Devices"
|
|
groupedResources={storageGroupedByNode()}
|
|
groupHeaderMeta={guestGroupHeaderMeta()}
|
|
columns={['Usage %']}
|
|
activeAlerts={props.activeAlerts}
|
|
emptyMessage="No storage devices match the current filters."
|
|
onEdit={startEditing}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onRemoveOverride={removeOverride}
|
|
onToggleDisabled={toggleDisabled}
|
|
showOfflineAlertsColumn={false}
|
|
editingId={editingId}
|
|
editingThresholds={editingThresholds}
|
|
setEditingThresholds={setEditingThresholds}
|
|
editingNote={editingNote}
|
|
setEditingNote={setEditingNote}
|
|
formatMetricValue={formatMetricValue}
|
|
hasActiveAlert={hasActiveAlert}
|
|
globalDefaults={{ usage: props.storageDefault() }}
|
|
setGlobalDefaults={(value) => {
|
|
if (typeof value === 'function') {
|
|
const newValue = value({ usage: props.storageDefault() });
|
|
props.setStorageDefault(newValue.usage ?? 85);
|
|
} else {
|
|
props.setStorageDefault(value.usage ?? 85);
|
|
}
|
|
}}
|
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
|
globalDisableFlag={props.disableAllStorage}
|
|
onToggleGlobalDisable={() => props.setDisableAllStorage(!props.disableAllStorage())}
|
|
showDelayColumn={true}
|
|
globalDelaySeconds={props.timeThresholds().storage}
|
|
metricDelaySeconds={props.metricTimeThresholds().storage ?? {}}
|
|
onMetricDelayChange={(metric, value) => updateMetricDelay('storage', metric, value)}
|
|
factoryDefaults={
|
|
props.factoryStorageDefault !== undefined
|
|
? { usage: props.factoryStorageDefault }
|
|
: undefined
|
|
}
|
|
onResetDefaults={props.resetStorageDefault}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
|
|
<Show when={activeTab() === 'pmg'}>
|
|
<Show
|
|
when={pmgServersWithOverrides().length > 0}
|
|
fallback={
|
|
<div class="rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
|
No mail gateways configured yet. Add a PMG instance in Settings to manage
|
|
thresholds.
|
|
</div>
|
|
}
|
|
>
|
|
<div ref={registerSection('pmg')} class="scroll-mt-24">
|
|
<ResourceTable
|
|
title="Mail Gateway Thresholds"
|
|
resources={pmgServersWithOverrides()}
|
|
columns={[
|
|
'Queue Warn',
|
|
'Queue Crit',
|
|
'Deferred Warn',
|
|
'Deferred Crit',
|
|
'Hold Warn',
|
|
'Hold Crit',
|
|
'Oldest Warn (min)',
|
|
'Oldest Crit (min)',
|
|
'Spam Warn',
|
|
'Spam Crit',
|
|
'Virus Warn',
|
|
'Virus Crit',
|
|
'Growth Warn %',
|
|
'Growth Warn Min',
|
|
'Growth Crit %',
|
|
'Growth Crit Min',
|
|
]}
|
|
activeAlerts={props.activeAlerts}
|
|
emptyMessage="No mail gateways match the current filters."
|
|
onEdit={startEditing}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onRemoveOverride={removeOverride}
|
|
onToggleDisabled={toggleDisabled}
|
|
onToggleNodeConnectivity={toggleNodeConnectivity}
|
|
showOfflineAlertsColumn={true}
|
|
editingId={editingId}
|
|
editingThresholds={editingThresholds}
|
|
setEditingThresholds={setEditingThresholds}
|
|
editingNote={editingNote}
|
|
setEditingNote={setEditingNote}
|
|
formatMetricValue={formatMetricValue}
|
|
hasActiveAlert={hasActiveAlert}
|
|
globalDefaults={pmgGlobalDefaults()}
|
|
setGlobalDefaults={setPMGGlobalDefaults}
|
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
|
globalDisableFlag={props.disableAllPMG}
|
|
onToggleGlobalDisable={() => props.setDisableAllPMG(!props.disableAllPMG())}
|
|
globalDisableOfflineFlag={props.disableAllPMGOffline}
|
|
onToggleGlobalDisableOffline={() =>
|
|
props.setDisableAllPMGOffline(!props.disableAllPMGOffline())
|
|
}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
|
|
<Show when={activeTab() === 'hosts'}>
|
|
<Show when={hasSection('hostAgents')}>
|
|
<div ref={registerSection('hostAgents')} class="scroll-mt-24">
|
|
<ResourceTable
|
|
title="Host Agents"
|
|
resources={hostAgentsWithOverrides()}
|
|
columns={['CPU %', 'Memory %', 'Disk %']}
|
|
activeAlerts={props.activeAlerts}
|
|
emptyMessage="No host agents match the current filters."
|
|
onEdit={startEditing}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onRemoveOverride={removeOverride}
|
|
onToggleDisabled={toggleDisabled}
|
|
onToggleNodeConnectivity={toggleNodeConnectivity}
|
|
showOfflineAlertsColumn={true}
|
|
editingId={editingId}
|
|
editingThresholds={editingThresholds}
|
|
setEditingThresholds={setEditingThresholds}
|
|
editingNote={editingNote}
|
|
setEditingNote={setEditingNote}
|
|
formatMetricValue={formatMetricValue}
|
|
hasActiveAlert={hasActiveAlert}
|
|
globalDefaults={props.hostDefaults}
|
|
setGlobalDefaults={props.setHostDefaults}
|
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
|
globalDisableFlag={props.disableAllHosts}
|
|
onToggleGlobalDisable={() => props.setDisableAllHosts(!props.disableAllHosts())}
|
|
globalDisableOfflineFlag={props.disableAllHostsOffline}
|
|
onToggleGlobalDisableOffline={() =>
|
|
props.setDisableAllHostsOffline(!props.disableAllHostsOffline())
|
|
}
|
|
factoryDefaults={props.factoryHostDefaults}
|
|
onResetDefaults={props.resetHostDefaults}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
|
|
<Show when={activeTab() === 'docker'}>
|
|
<Card padding="md" tone="glass" class="mb-6">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
Ignored container prefixes
|
|
</h3>
|
|
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
|
Containers whose name or ID starts with any prefix below are skipped for container
|
|
alerts. Enter one prefix per line; matching is case-insensitive.
|
|
</p>
|
|
</div>
|
|
<Show when={(props.dockerIgnoredPrefixes().length ?? 0) > 0}>
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center justify-center rounded-md border border-transparent bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
onClick={handleResetDockerIgnored}
|
|
>
|
|
Reset
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
<textarea
|
|
value={dockerIgnoredInput()}
|
|
onInput={(event) => handleDockerIgnoredChange(event.currentTarget.value)}
|
|
onKeyDown={(event) => {
|
|
// Ensure Enter key works in textarea for creating new lines
|
|
if (event.key === 'Enter') {
|
|
// Don't prevent default - allow the newline to be inserted
|
|
event.stopPropagation();
|
|
}
|
|
}}
|
|
placeholder="runner-"
|
|
rows={4}
|
|
class="mt-4 w-full rounded-md border border-gray-300 bg-white p-3 text-sm text-gray-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-sky-400 dark:focus:ring-sky-600/40"
|
|
/>
|
|
</Card>
|
|
|
|
<Card padding="md" tone="glass" class="mb-6">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Swarm service alerts</h3>
|
|
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
|
Pulse raises alerts when running replicas fall behind the desired count or a rollout gets stuck. Adjust the gap thresholds below or disable service alerts entirely.
|
|
</p>
|
|
</div>
|
|
<Toggle
|
|
checked={!props.disableAllDockerServices()}
|
|
onToggle={() => {
|
|
props.setDisableAllDockerServices(!props.disableAllDockerServices());
|
|
props.setHasUnsavedChanges(true);
|
|
}}
|
|
label={<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Alerts</span>}
|
|
description={<span class="text-xs text-gray-500 dark:text-gray-400">Toggle Swarm service replica monitoring</span>}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
|
|
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label
|
|
for={serviceWarnInputId}
|
|
class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-400"
|
|
>
|
|
Warning gap %
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
id={serviceWarnInputId}
|
|
value={props.dockerDefaults.serviceWarnGapPercent}
|
|
onInput={(event) => {
|
|
const value = Number(event.currentTarget.value);
|
|
const normalized = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
|
|
props.setDockerDefaults((prev) => ({
|
|
...prev,
|
|
serviceWarnGapPercent: normalized,
|
|
}));
|
|
props.setHasUnsavedChanges(true);
|
|
}}
|
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white p-2 text-sm text-gray-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-sky-400 dark:focus:ring-sky-600/40"
|
|
/>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
Convert to warning when at least this percentage of replicas are missing.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label
|
|
for={serviceCriticalInputId}
|
|
class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-400"
|
|
>
|
|
Critical gap %
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
id={serviceCriticalInputId}
|
|
value={props.dockerDefaults.serviceCriticalGapPercent}
|
|
onInput={(event) => {
|
|
const value = Number(event.currentTarget.value);
|
|
const normalized = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
|
|
props.setDockerDefaults((prev) => ({
|
|
...prev,
|
|
serviceCriticalGapPercent: normalized,
|
|
}));
|
|
props.setHasUnsavedChanges(true);
|
|
}}
|
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white p-2 text-sm text-gray-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-sky-400 dark:focus:ring-sky-600/40"
|
|
/>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
Raise a critical alert when the missing replica gap meets or exceeds this value.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{serviceGapValidationMessage() && (
|
|
<p class="mt-1.5 text-xs font-medium text-red-600 dark:text-red-400">
|
|
{serviceGapValidationMessage()}
|
|
</p>
|
|
)}
|
|
</Card>
|
|
|
|
<Show when={hasSection('dockerHosts')}>
|
|
<div ref={registerSection('dockerHosts')} class="scroll-mt-24">
|
|
<ResourceTable
|
|
title="Container Hosts"
|
|
resources={dockerHostsWithOverrides()}
|
|
columns={[]}
|
|
activeAlerts={props.activeAlerts}
|
|
emptyMessage="No container hosts match the current filters."
|
|
onEdit={startEditing}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onRemoveOverride={removeOverride}
|
|
onToggleDisabled={toggleDisabled}
|
|
onToggleNodeConnectivity={toggleNodeConnectivity}
|
|
showOfflineAlertsColumn={true}
|
|
editingId={editingId}
|
|
editingThresholds={editingThresholds}
|
|
setEditingThresholds={setEditingThresholds}
|
|
editingNote={editingNote}
|
|
setEditingNote={setEditingNote}
|
|
formatMetricValue={formatMetricValue}
|
|
hasActiveAlert={hasActiveAlert}
|
|
globalDisableFlag={props.disableAllDockerHosts}
|
|
onToggleGlobalDisable={() =>
|
|
props.setDisableAllDockerHosts(!props.disableAllDockerHosts())
|
|
}
|
|
globalDisableOfflineFlag={props.disableAllDockerHostsOffline}
|
|
onToggleGlobalDisableOffline={() =>
|
|
props.setDisableAllDockerHostsOffline(!props.disableAllDockerHostsOffline())
|
|
}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={hasSection('dockerContainers')}>
|
|
<div ref={registerSection('dockerContainers')} class="scroll-mt-24">
|
|
<ResourceTable
|
|
title="Containers"
|
|
groupedResources={dockerContainersGroupedByHost()}
|
|
groupHeaderMeta={dockerHostGroupMeta()}
|
|
columns={[
|
|
'CPU %',
|
|
'Memory %',
|
|
'Disk %',
|
|
'Restart Count',
|
|
'Restart Window (s)',
|
|
'Memory Warn %',
|
|
'Memory Critical %',
|
|
]}
|
|
activeAlerts={props.activeAlerts}
|
|
emptyMessage="No containers match the current filters."
|
|
onEdit={startEditing}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onRemoveOverride={removeOverride}
|
|
onToggleDisabled={toggleDisabled}
|
|
showOfflineAlertsColumn={false}
|
|
editingId={editingId}
|
|
editingThresholds={editingThresholds}
|
|
setEditingThresholds={setEditingThresholds}
|
|
editingNote={editingNote}
|
|
setEditingNote={setEditingNote}
|
|
formatMetricValue={formatMetricValue}
|
|
hasActiveAlert={hasActiveAlert}
|
|
globalDefaults={{
|
|
cpu: props.dockerDefaults.cpu,
|
|
memory: props.dockerDefaults.memory,
|
|
disk: props.dockerDefaults.disk,
|
|
restartCount: props.dockerDefaults.restartCount,
|
|
restartWindow: props.dockerDefaults.restartWindow,
|
|
memoryWarnPct: props.dockerDefaults.memoryWarnPct,
|
|
memoryCriticalPct: props.dockerDefaults.memoryCriticalPct,
|
|
}}
|
|
setGlobalDefaults={(value) => {
|
|
const current = {
|
|
cpu: props.dockerDefaults.cpu,
|
|
memory: props.dockerDefaults.memory,
|
|
disk: props.dockerDefaults.disk,
|
|
restartCount: props.dockerDefaults.restartCount,
|
|
restartWindow: props.dockerDefaults.restartWindow,
|
|
memoryWarnPct: props.dockerDefaults.memoryWarnPct,
|
|
memoryCriticalPct: props.dockerDefaults.memoryCriticalPct,
|
|
};
|
|
const next =
|
|
typeof value === 'function' ? value(current) : { ...current, ...value };
|
|
|
|
props.setDockerDefaults((prev) => ({
|
|
...prev,
|
|
cpu: next.cpu ?? prev.cpu,
|
|
memory: next.memory ?? prev.memory,
|
|
disk: next.disk ?? prev.disk,
|
|
restartCount: next.restartCount ?? prev.restartCount,
|
|
restartWindow: next.restartWindow ?? prev.restartWindow,
|
|
memoryWarnPct: next.memoryWarnPct ?? prev.memoryWarnPct,
|
|
memoryCriticalPct: next.memoryCriticalPct ?? prev.memoryCriticalPct,
|
|
}));
|
|
}}
|
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
|
globalDisableFlag={props.disableAllDockerContainers}
|
|
onToggleGlobalDisable={() =>
|
|
props.setDisableAllDockerContainers(!props.disableAllDockerContainers())
|
|
}
|
|
globalDisableOfflineFlag={() => props.dockerDisableConnectivity()}
|
|
onToggleGlobalDisableOffline={() =>
|
|
props.setDockerDisableConnectivity(!props.dockerDisableConnectivity())
|
|
}
|
|
showDelayColumn={true}
|
|
globalDelaySeconds={props.timeThresholds().guest}
|
|
metricDelaySeconds={props.metricTimeThresholds().guest ?? {}}
|
|
onMetricDelayChange={(metric, value) => updateMetricDelay('guest', metric, value)}
|
|
globalOfflineSeverity={props.dockerPoweredOffSeverity()}
|
|
onSetGlobalOfflineState={(state) => {
|
|
if (state === 'off') {
|
|
props.setDockerDisableConnectivity(true);
|
|
} else {
|
|
props.setDockerDisableConnectivity(false);
|
|
props.setDockerPoweredOffSeverity(state === 'critical' ? 'critical' : 'warning');
|
|
}
|
|
props.setHasUnsavedChanges(true);
|
|
}}
|
|
onSetOfflineState={setOfflineState}
|
|
factoryDefaults={props.factoryDockerDefaults}
|
|
onResetDefaults={props.resetDockerDefaults}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|