improve: redesign thresholds tab with table layout for better usability

- replaced slider-based UI with clean table layout similar to Guest URLs tab
- shows all resources in a searchable table with inline threshold editing
- added collapsible global defaults section at the top
- enable/disable alerts directly in the table for each guest
- visual indicators for custom overrides
- preserves all existing threshold configurations and overrides
This commit is contained in:
Pulse Monitor
2025-08-20 20:30:33 +00:00
parent 220594b6d3
commit 4bc8cfb1d2
2 changed files with 747 additions and 725 deletions
@@ -0,0 +1,721 @@
import { createSignal, createMemo, For, Show } from 'solid-js';
import type { VM, Container, Node } from '@/types/api';
interface Override {
id: string;
name: string;
type: 'guest' | 'node';
resourceType?: string;
vmid?: number;
node?: string;
instance?: string;
disabled?: boolean;
thresholds: {
cpu?: number;
memory?: number;
disk?: number;
diskRead?: number;
diskWrite?: number;
networkIn?: number;
networkOut?: number;
};
}
// Simple threshold object for the UI
interface SimpleThresholds {
cpu?: number;
memory?: number;
disk?: number;
diskRead?: number;
diskWrite?: number;
networkIn?: number;
networkOut?: number;
[key: string]: number | undefined; // Add index signature for compatibility
}
interface ThresholdsTableProps {
overrides: () => Override[];
setOverrides: (overrides: Override[]) => void;
rawOverridesConfig: () => Record<string, any>;
setRawOverridesConfig: (config: Record<string, any>) => void;
allGuests: () => (VM | Container)[];
nodes: Node[];
guestDefaults: SimpleThresholds;
setGuestDefaults: (value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>)) => void;
nodeDefaults: SimpleThresholds;
setNodeDefaults: (value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>)) => void;
storageDefault: () => number;
setStorageDefault: (value: number) => void;
timeThreshold: () => number;
setTimeThreshold: (value: number) => void;
setHasUnsavedChanges: (value: boolean) => void;
}
export function ThresholdsTable(props: ThresholdsTableProps) {
const [searchTerm, setSearchTerm] = createSignal('');
const [showGlobalSettings, setShowGlobalSettings] = createSignal(true);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [editingThresholds, setEditingThresholds] = createSignal<Record<string, any>>({});
// Combine all resources (guests and nodes) with their overrides
const resourcesWithOverrides = createMemo(() => {
const search = searchTerm().toLowerCase();
const overridesMap = new Map(props.overrides().map(o => [o.id, o]));
// Process guests
const guests = props.allGuests().map(guest => {
const guestId = guest.id || `${guest.instance}-${guest.name}-${guest.vmid}`;
const override = overridesMap.get(guestId);
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: !!override,
disabled: override?.disabled || false,
thresholds: override?.thresholds || props.guestDefaults
};
});
// Process nodes
const nodes = props.nodes.map(node => {
const override = overridesMap.get(node.id);
return {
id: node.id,
name: node.name,
type: 'node' as const,
resourceType: 'Node',
status: node.status,
hasOverride: !!override,
disabled: false,
thresholds: override?.thresholds || props.nodeDefaults
};
});
// Combine and filter
const allResources = [...guests, ...nodes];
if (search) {
return allResources.filter(r =>
r.name.toLowerCase().includes(search) ||
('vmid' in r && r.vmid && r.vmid.toString().includes(search)) ||
('node' in r && r.node && r.node.toLowerCase().includes(search))
);
}
return allResources;
});
// Group resources by node for display
const groupedResources = createMemo(() => {
const groups: Record<string, any[]> = {};
resourcesWithOverrides().forEach(resource => {
const groupKey = resource.type === 'node' ? 'Nodes' : ('node' in resource ? resource.node : 'Unknown');
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(resource);
});
// Sort resources within each group
Object.keys(groups).forEach(key => {
groups[key] = groups[key].sort((a, b) => {
if (a.type === 'node' && b.type !== 'node') return -1;
if (a.type !== 'node' && b.type === 'node') return 1;
if ('vmid' in a && 'vmid' in b && a.vmid && b.vmid) return a.vmid - b.vmid;
return a.name.localeCompare(b.name);
});
});
return groups;
});
const startEditing = (resourceId: string, currentThresholds: any) => {
setEditingId(resourceId);
setEditingThresholds(currentThresholds);
};
const saveEdit = (resourceId: string) => {
const resource = resourcesWithOverrides().find(r => r.id === resourceId);
if (!resource) return;
const thresholds = editingThresholds();
// Create or update override
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: resource.disabled,
thresholds
};
// 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> = {};
Object.entries(thresholds).forEach(([metric, value]) => {
if (value !== undefined && value !== null) {
hysteresisThresholds[metric] = {
trigger: value,
clear: Math.max(0, (value as number) - 5)
};
}
});
if (resource.disabled) {
hysteresisThresholds.disabled = true;
}
newRawConfig[resourceId] = hysteresisThresholds;
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
setEditingId(null);
setEditingThresholds({});
};
const cancelEdit = () => {
setEditingId(null);
setEditingThresholds({});
};
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) => {
const resource = resourcesWithOverrides().find(r => r.id === resourceId);
if (!resource || resource.type !== 'guest') return;
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: !resource.disabled,
thresholds: resource.thresholds
};
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> = {};
Object.entries(resource.thresholds).forEach(([metric, value]) => {
if (value !== undefined && value !== null) {
hysteresisThresholds[metric] = {
trigger: value,
clear: Math.max(0, (value as number) - 5)
};
}
});
hysteresisThresholds.disabled = !resource.disabled;
newRawConfig[resourceId] = hysteresisThresholds;
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
};
return (
<div class="space-y-6">
{/* Global Settings Section */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
<div
class="p-4 flex items-center justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900/50"
onClick={() => setShowGlobalSettings(!showGlobalSettings())}
>
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Global Default Thresholds</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Default thresholds that apply to all resources unless overridden
</p>
</div>
<svg
class={`w-5 h-5 text-gray-500 transition-transform ${showGlobalSettings() ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<Show when={showGlobalSettings()}>
<div class="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
{/* Time Threshold */}
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 w-32">
Time Threshold:
</label>
<input
type="number"
min="0"
max="300"
value={props.timeThreshold()}
onInput={(e) => {
props.setTimeThreshold(parseInt(e.currentTarget.value) || 0);
props.setHasUnsavedChanges(true);
}}
class="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span class="text-sm text-gray-500 dark:text-gray-400">
seconds {props.timeThreshold() === 0 ? '(disabled)' : ''}
</span>
</div>
{/* Default Thresholds Grid */}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Guest Defaults */}
<div class="space-y-3">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">VMs & Containers</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600 dark:text-gray-400 w-16">CPU:</label>
<input
type="number"
min="0"
max="100"
value={props.guestDefaults.cpu || 0}
onInput={(e) => {
props.setGuestDefaults((prev) => ({...prev, cpu: parseInt(e.currentTarget.value) || 0}));
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-0.5 text-sm border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
<span class="text-xs text-gray-500">%</span>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600 dark:text-gray-400 w-16">Memory:</label>
<input
type="number"
min="0"
max="100"
value={props.guestDefaults.memory || 0}
onInput={(e) => {
props.setGuestDefaults((prev) => ({...prev, memory: parseInt(e.currentTarget.value) || 0}));
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-0.5 text-sm border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
<span class="text-xs text-gray-500">%</span>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600 dark:text-gray-400 w-16">Disk:</label>
<input
type="number"
min="0"
max="100"
value={props.guestDefaults.disk || 0}
onInput={(e) => {
props.setGuestDefaults((prev) => ({...prev, disk: parseInt(e.currentTarget.value) || 0}));
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-0.5 text-sm border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
<span class="text-xs text-gray-500">%</span>
</div>
</div>
</div>
{/* Node Defaults */}
<div class="space-y-3">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Proxmox Nodes</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600 dark:text-gray-400 w-16">CPU:</label>
<input
type="number"
min="0"
max="100"
value={props.nodeDefaults.cpu || 0}
onInput={(e) => {
props.setNodeDefaults((prev) => ({...prev, cpu: parseInt(e.currentTarget.value) || 0}));
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-0.5 text-sm border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
<span class="text-xs text-gray-500">%</span>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600 dark:text-gray-400 w-16">Memory:</label>
<input
type="number"
min="0"
max="100"
value={props.nodeDefaults.memory || 0}
onInput={(e) => {
props.setNodeDefaults((prev) => ({...prev, memory: parseInt(e.currentTarget.value) || 0}));
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-0.5 text-sm border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
<span class="text-xs text-gray-500">%</span>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600 dark:text-gray-400 w-16">Disk:</label>
<input
type="number"
min="0"
max="100"
value={props.nodeDefaults.disk || 0}
onInput={(e) => {
props.setNodeDefaults((prev) => ({...prev, disk: parseInt(e.currentTarget.value) || 0}));
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-0.5 text-sm border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
<span class="text-xs text-gray-500">%</span>
</div>
</div>
</div>
{/* Storage Default */}
<div class="space-y-3">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Storage</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600 dark:text-gray-400 w-16">Usage:</label>
<input
type="number"
min="0"
max="100"
value={props.storageDefault()}
onInput={(e) => {
props.setStorageDefault(parseInt(e.currentTarget.value) || 0);
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-0.5 text-sm border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
<span class="text-xs text-gray-500">%</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-2">
<button
onClick={() => {
props.setGuestDefaults({
cpu: 80,
memory: 85,
disk: 90,
diskRead: 150,
diskWrite: 150,
networkIn: 200,
networkOut: 200
});
props.setNodeDefaults({
cpu: 80,
memory: 85,
disk: 90
});
props.setStorageDefault(85);
props.setHasUnsavedChanges(true);
}}
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
>
Reset to Defaults
</button>
</div>
</div>
</Show>
</div>
{/* Search Bar */}
<div class="relative">
<input
type="text"
placeholder="Search resources..."
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
class="w-full px-4 py-2 pl-10 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-transparent"
/>
<svg class="absolute left-3 top-2.5 w-4 h-4 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>
</div>
{/* Resources Table */}
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Resource
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
CPU %
</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Memory %
</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Disk %
</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Alerts
</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<Show when={Object.keys(groupedResources()).length === 0} fallback={
<For each={Object.entries(groupedResources()).sort(([a], [b]) => {
if (a === 'Nodes') return -1;
if (b === 'Nodes') return 1;
return a.localeCompare(b);
})}>
{([groupName, resources]) => (
<>
{/* Group header */}
<tr class="bg-gray-50 dark:bg-gray-700/50">
<td colspan="8" class="px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
{groupName}
</td>
</tr>
{/* Resource rows */}
<For each={resources}>
{(resource) => {
const isEditing = () => editingId() === resource.id;
const thresholds = () => isEditing() ? editingThresholds() : resource.thresholds;
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors">
<td class="px-3 py-1.5">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{resource.name}
</span>
<Show when={'vmid' in resource && resource.vmid}>
<span class="text-xs text-gray-500">({resource.vmid})</span>
</Show>
<Show when={resource.hasOverride}>
<span class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded">
Custom
</span>
</Show>
</div>
</td>
<td class="px-3 py-1.5">
<span class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${
resource.type === 'node' ? 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300' :
resource.resourceType === 'VM' ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' :
'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300'
}`}>
{resource.resourceType}
</span>
</td>
<td class="px-3 py-1.5">
<span class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${
resource.status === 'online' || resource.status === 'running' ?
'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300' :
resource.status === 'stopped' ?
'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300' :
'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300'
}`}>
{resource.status}
</span>
</td>
<td class="px-3 py-1.5 text-center">
<Show when={isEditing()} fallback={
<span class="text-sm text-gray-700 dark:text-gray-300">
{thresholds().cpu || '-'}
</span>
}>
<input
type="number"
min="0"
max="100"
value={thresholds().cpu || ''}
onInput={(e) => setEditingThresholds({
...editingThresholds(),
cpu: parseInt(e.currentTarget.value) || undefined
})}
class="w-14 px-1 py-0.5 text-sm text-center border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</Show>
</td>
<td class="px-3 py-1.5 text-center">
<Show when={isEditing()} fallback={
<span class="text-sm text-gray-700 dark:text-gray-300">
{thresholds().memory || '-'}
</span>
}>
<input
type="number"
min="0"
max="100"
value={thresholds().memory || ''}
onInput={(e) => setEditingThresholds({
...editingThresholds(),
memory: parseInt(e.currentTarget.value) || undefined
})}
class="w-14 px-1 py-0.5 text-sm text-center border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</Show>
</td>
<td class="px-3 py-1.5 text-center">
<Show when={isEditing()} fallback={
<span class="text-sm text-gray-700 dark:text-gray-300">
{thresholds().disk || '-'}
</span>
}>
<input
type="number"
min="0"
max="100"
value={thresholds().disk || ''}
onInput={(e) => setEditingThresholds({
...editingThresholds(),
disk: parseInt(e.currentTarget.value) || undefined
})}
class="w-14 px-1 py-0.5 text-sm text-center border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</Show>
</td>
<td class="px-3 py-1.5 text-center">
<Show when={resource.type === 'guest'}>
<button
onClick={() => toggleDisabled(resource.id)}
class={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${
resource.disabled
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800/50'
: 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800/50'
}`}
>
{resource.disabled ? 'Disabled' : 'Enabled'}
</button>
</Show>
<Show when={resource.type === 'node'}>
<span class="text-xs text-gray-500">-</span>
</Show>
</td>
<td class="px-3 py-1.5">
<div class="flex items-center justify-center gap-1">
<Show when={isEditing()} fallback={
<>
<button
onClick={() => startEditing(resource.id, resource.thresholds)}
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
title="Edit thresholds"
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<Show when={resource.hasOverride}>
<button
onClick={() => removeOverride(resource.id)}
class="p-1 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
title="Remove override"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</Show>
</>
}>
<button
onClick={() => saveEdit(resource.id)}
class="p-1 text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
title="Save"
>
<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="M5 13l4 4L19 7" />
</svg>
</button>
<button
onClick={cancelEdit}
class="p-1 text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
title="Cancel"
>
<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>
</td>
</tr>
);
}}
</For>
</>
)}
</For>
}>
<tr>
<td colspan="8" class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
No resources found
</td>
</tr>
</Show>
</tbody>
</table>
</div>
</div>
</div>
);
}
+26 -725
View File
@@ -1,15 +1,15 @@
import { createSignal, Show, For, createMemo, createEffect, onMount } from 'solid-js';
import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider';
import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
import { CustomRulesTab } from '@/components/Alerts/CustomRulesTab';
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
import { useWebSocket } from '@/App';
import { showSuccess, showError } from '@/utils/toast';
import { AlertsAPI } from '@/api/alerts';
import { NotificationsAPI, Webhook } from '@/api/notifications';
import type { EmailConfig } from '@/api/notifications';
import type { HysteresisThreshold, AlertThresholds } from '@/types/alerts';
import type { Alert, State } from '@/types/api';
import type { Alert, State, VM, Container } from '@/types/api';
type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history' | 'custom-rules';
@@ -313,15 +313,7 @@ export function Alerts() {
const allGuests = createMemo(() => {
const vms = state.vms || [];
const containers = state.containers || [];
return [...vms, ...containers].map(g => ({
id: g.id,
name: g.name,
vmid: g.vmid,
type: g.type, // Keep original type (qemu/lxc)
displayType: g.type === 'qemu' ? 'VM' : 'CT', // Add display type
node: g.node,
instance: g.instance
}));
return [...vms, ...containers];
}, [], { equals: (prev, next) => {
// Only update if the actual guest list changed
if (prev.length !== next.length) return false;
@@ -579,9 +571,9 @@ export function Alerts() {
setRawOverridesConfig={setRawOverridesConfig}
allGuests={allGuests}
state={state}
guestDefaults={guestDefaults()}
guestDefaults={guestDefaults}
setGuestDefaults={setGuestDefaults}
nodeDefaults={nodeDefaults()}
nodeDefaults={nodeDefaults}
setNodeDefaults={setNodeDefaults}
storageDefault={storageDefault}
setStorageDefault={setStorageDefault}
@@ -818,411 +810,13 @@ function OverviewTab(props: {
);
}
// Add Override Form Component
function AddOverrideForm(props: {
guests: Array<{ id: string; name: string; vmid: number; type: string; node: string; instance: string }>;
nodes: Array<{ id: string; name: string }>;
existingOverrides: Override[];
editingOverride?: Override | null;
onAdd: (override: Override) => void;
onUpdate?: (override: Override) => void;
onCancelEdit?: () => void;
}) {
const [resourceType, setResourceType] = createSignal<'guest' | 'node'>('guest');
const [selectedResource, setSelectedResource] = createSignal('');
const [alertsDisabled, setAlertsDisabled] = createSignal(false);
const [thresholds, setThresholds] = createSignal({
cpu: 80,
memory: 85,
disk: 90
});
const [enabledThresholds, setEnabledThresholds] = createSignal({
cpu: true,
memory: true,
disk: true
});
// When editing, populate the form with existing values
let previousEditingId: string | null = null;
createEffect(() => {
const currentEditing = props.editingOverride;
const currentId = currentEditing?.id || null;
// Only update if the editing override changed
if (currentId !== previousEditingId) {
previousEditingId = currentId;
if (currentEditing) {
setResourceType(currentEditing.type);
setSelectedResource(currentEditing.id);
setAlertsDisabled(currentEditing.disabled || false);
const editThresholds = { cpu: 80, memory: 85, disk: 90 };
const editEnabled = { cpu: false, memory: false, disk: false };
Object.entries(currentEditing.thresholds).forEach(([key, value]) => {
if (key in editThresholds) {
editThresholds[key as keyof typeof editThresholds] = value as number;
editEnabled[key as keyof typeof editEnabled] = true;
}
});
setThresholds(editThresholds);
setEnabledThresholds(editEnabled);
} else {
// Reset when not editing
setResourceType('guest');
setSelectedResource('');
setAlertsDisabled(false);
setThresholds({ cpu: 80, memory: 85, disk: 90 });
setEnabledThresholds({ cpu: true, memory: true, disk: true });
}
}
});
const availableResources = createMemo(() => {
const existingIds = new Set(props.existingOverrides.map(o => o.id));
if (resourceType() === 'node') {
return props.nodes.filter(n => !existingIds.has(n.id));
} else {
return props.guests.filter(g => !existingIds.has(g.id));
}
});
const handleSubmit = () => {
const resourceId = selectedResource();
if (!resourceId) return;
const activeThresholds: Record<string, number> = {};
const thresholdValues = thresholds();
Object.entries(enabledThresholds()).forEach(([key, enabled]) => {
if (enabled && key in thresholdValues) {
activeThresholds[key] = thresholdValues[key as keyof typeof thresholdValues];
}
});
// Allow creation if either thresholds are set OR alerts are disabled
if (Object.keys(activeThresholds).length === 0 && !alertsDisabled()) return;
let override: Override;
if (props.editingOverride) {
// Update existing override
override = {
...props.editingOverride,
disabled: alertsDisabled(),
thresholds: activeThresholds
};
props.onUpdate?.(override);
} else {
// Create new override
if (resourceType() === 'node') {
const node = props.nodes.find(n => n.id === resourceId);
if (!node) return;
override = {
id: node.id,
name: node.name,
type: 'node',
resourceType: 'Node',
thresholds: activeThresholds
};
} else {
const guest = props.guests.find(g => g.id === resourceId);
if (!guest) return;
override = {
id: guest.id,
name: guest.name,
type: 'guest',
resourceType: guest.type === 'qemu' ? 'VM' : 'CT',
vmid: guest.vmid,
node: guest.node,
instance: guest.instance,
disabled: alertsDisabled(),
thresholds: activeThresholds
};
}
props.onAdd(override);
}
// Reset form after successful submission
setSelectedResource('');
setAlertsDisabled(false);
setThresholds({ cpu: 80, memory: 85, disk: 90 });
setEnabledThresholds({ cpu: true, memory: true, disk: true });
};
return (
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5z"/>
</svg>
{props.editingOverride ? 'Edit Override' : 'Add New Override'}
</h4>
<Show when={props.editingOverride}>
<button
onClick={props.onCancelEdit}
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
</Show>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Resource Selection */}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{props.editingOverride ? 'Resource' : 'Select Resource'}
</label>
<Show when={props.editingOverride} fallback={
<>
{/* Resource Type Toggle */}
<div class="flex p-1 bg-gray-100 dark:bg-gray-700 rounded-lg mb-4">
<button
onClick={() => {
setResourceType('guest');
setSelectedResource('');
}}
class={`flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors ${
resourceType() === 'guest'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
}`}
>
Guests
</button>
<button
onClick={() => {
setResourceType('node');
setSelectedResource('');
}}
class={`flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors ${
resourceType() === 'node'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
}`}
>
Nodes
</button>
</div>
<select
value={selectedResource()}
onChange={(e) => setSelectedResource(e.target.value)}
class="w-full px-4 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent
transition-colors"
>
<option value="">Choose a {resourceType()}...</option>
<For each={availableResources()}>
{(resource) => (
<option value={resource.id}>
{resource.name}
{resourceType() === 'guest' && 'vmid' in resource && ` (${resource.vmid})`}
</option>
)}
</For>
</select>
</>
}>
{/* When editing, just show the resource name */}
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<span class="text-sm text-gray-700 dark:text-gray-300">
{props.editingOverride?.name}
{props.editingOverride?.vmid && ` (${props.editingOverride.vmid})`}
<span class={`ml-2 text-xs px-2 py-0.5 rounded-full ${
props.editingOverride?.type === 'node' ? 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300' :
props.editingOverride?.resourceType === 'VM' ? 'bg-indigo-100 dark:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300' :
'bg-teal-100 dark:bg-teal-900/50 text-teal-700 dark:text-teal-300'
}`}>
{props.editingOverride?.type === 'node' ? 'Node' : props.editingOverride?.resourceType}
</span>
</span>
</div>
</Show>
</div>
</div>
{/* Threshold Configuration */}
<div class="space-y-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Configure Thresholds
</label>
{/* Alert Mode Selection for Guests */}
<Show when={resourceType() === 'guest'}>
<div class="flex gap-3">
<label class="flex items-center gap-2 flex-1 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<input
type="radio"
name="alert-mode"
checked={!alertsDisabled()}
onChange={() => setAlertsDisabled(false)}
class="text-blue-600"
/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Custom thresholds</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Alert when thresholds exceeded</p>
</div>
</label>
<label class="flex items-center gap-2 flex-1 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<input
type="radio"
name="alert-mode"
checked={alertsDisabled()}
onChange={() => setAlertsDisabled(true)}
class="text-red-600"
/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Disable alerts</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Never alert for this guest</p>
</div>
</label>
</div>
</Show>
<div class={`space-y-4 ${alertsDisabled() ? 'opacity-50 pointer-events-none' : ''}`}>
<For each={['cpu', 'memory', 'disk']}>
{(metric) => (
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">
{metric === 'cpu' ? 'CPU' : metric === 'memory' ? 'Memory' : 'Disk'} Usage
</label>
<span class="text-xs text-gray-500">{thresholds()[metric as keyof typeof thresholds]}%</span>
</div>
<ThresholdSlider
value={thresholds()[metric as keyof typeof thresholds]}
onChange={(value) => {
setThresholds({
...thresholds(),
[metric]: value
});
// Auto-enable this threshold when value changes
setEnabledThresholds({
...enabledThresholds(),
[metric]: true
});
}}
type={metric as 'cpu' | 'memory' | 'disk'}
/>
</div>
)}
</For>
</div>
</div>
</div>
<div class="flex justify-end pt-2">
<button
onClick={handleSubmit}
disabled={!selectedResource()}
class="px-6 py-2.5 text-sm font-medium bg-gradient-to-r from-blue-600 to-blue-700
text-white rounded-lg hover:from-blue-700 hover:to-blue-800
disabled:opacity-50 disabled:cursor-not-allowed transition-all
shadow-sm hover:shadow-md"
>
<span class="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{props.editingOverride ? (
<path d="M20 6L9 17l-5-5" />
) : (
<>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</>
)}
</svg>
{props.editingOverride ? 'Update Override' : 'Add Override'}
</span>
</button>
</div>
</div>
);
}
// Override Item Component
function OverrideItem(props: {
override: Override;
onEdit: () => void;
onRemove: () => void;
}) {
return (
<div class="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-shadow">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-3">
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">
{props.override.name}
<Show when={props.override.type === 'guest'}>
<span class="text-xs text-gray-500 ml-1">({props.override.vmid})</span>
</Show>
</p>
<span class={`text-xs px-2 py-0.5 rounded-full ${
props.override.type === 'node' ? 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300' :
props.override.resourceType === 'VM' ? 'bg-indigo-100 dark:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300' :
'bg-teal-100 dark:bg-teal-900/50 text-teal-700 dark:text-teal-300'
}`}>
{props.override.type === 'node' ? 'Node' : props.override.resourceType}
</span>
<Show when={props.override.disabled}>
<span class="text-xs px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300">
Alerts Disabled
</span>
</Show>
</div>
<Show when={!props.override.disabled} fallback={
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
All alerts disabled for this guest
</div>
}>
<div class="flex gap-4">
<For each={Object.entries(props.override.thresholds).filter(([_, v]) => v)}>
{([key, value]) => (
<div class="flex items-center gap-1 text-sm">
<span class="text-gray-600 dark:text-gray-400">{key.toUpperCase()}:</span>
<span class="font-medium text-gray-800 dark:text-gray-200">{value as number}%</span>
</div>
)}
</For>
</div>
</Show>
</div>
<div class="flex items-center gap-2 ml-4">
<button
onClick={props.onEdit}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
<button
onClick={props.onRemove}
class="text-sm text-red-600 hover:text-red-700 dark:text-red-400"
>
Remove
</button>
</div>
</div>
</div>
);
}
// Thresholds Tab - Improved design
interface ThresholdsTabProps {
allGuests: () => Array<{ id: string; name: string; vmid: number; type: string; node: string; instance: string }>;
allGuests: () => (VM | Container)[];
state: State;
guestDefaults: Record<string, number>;
nodeDefaults: Record<string, number>;
guestDefaults: () => Record<string, number>;
nodeDefaults: () => Record<string, number>;
storageDefault: () => number;
timeThreshold: () => number;
overrides: () => Override[];
@@ -1238,318 +832,25 @@ interface ThresholdsTabProps {
}
function ThresholdsTab(props: ThresholdsTabProps) {
// Track which override is currently being edited
const [editingOverride, setEditingOverride] = createSignal<Override | null>(null);
// Use the new table component for a cleaner, more information-dense layout
return (
<div class="space-y-8">
{/* Time Threshold */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-5 shadow-sm">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">Time Threshold</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Require thresholds to be exceeded for a minimum duration before triggering alerts (helps prevent false positives from brief spikes)
</p>
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Time threshold (seconds):
</label>
<input
type="number"
min="0"
max="300"
value={props.timeThreshold()}
onInput={(e) => {
props.setTimeThreshold(parseInt(e.currentTarget.value) || 0);
props.setHasUnsavedChanges(true);
}}
class="w-24 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400"
/>
<span class="text-sm text-gray-500 dark:text-gray-400">
{props.timeThreshold() === 0 ? '(disabled)' : `Wait ${props.timeThreshold()}s before alerting`}
</span>
</div>
</div>
{/* Step 1: Global Default Thresholds */}
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Default Alert Thresholds</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
These thresholds apply to all resources unless overridden below
</p>
</div>
<button
onClick={() => {
props.setGuestDefaults({
cpu: 80,
memory: 85,
disk: 90,
diskRead: 150,
diskWrite: 150,
networkIn: 200,
networkOut: 200
});
props.setNodeDefaults({
cpu: 80,
memory: 85,
disk: 90
});
props.setStorageDefault(85);
// Changed
}}
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium">
Reset All to Defaults
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* VMs & Containers */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-5 shadow-sm">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-blue-500">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
VMs & Containers
</h4>
<div class="space-y-4">
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">CPU Usage</label>
<span class="text-xs text-gray-500">{props.guestDefaults.cpu}%</span>
</div>
<ThresholdSlider
value={props.guestDefaults.cpu}
onChange={(v) => {
props.setGuestDefaults((prev) => ({...prev, cpu: v}));
props.setHasUnsavedChanges(true);
}}
type="cpu"
/>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">Memory Usage</label>
<span class="text-xs text-gray-500">{props.guestDefaults.memory}%</span>
</div>
<ThresholdSlider
value={props.guestDefaults.memory}
onChange={(v) => {
props.setGuestDefaults((prev) => ({...prev, memory: v}));
props.setHasUnsavedChanges(true);
}}
type="memory"
/>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">Disk Usage</label>
<span class="text-xs text-gray-500">{props.guestDefaults.disk}%</span>
</div>
<ThresholdSlider
value={props.guestDefaults.disk}
onChange={(v) => {
props.setGuestDefaults((prev) => ({...prev, disk: v}));
props.setHasUnsavedChanges(true);
}}
type="disk"
/>
</div>
</div>
</div>
{/* Proxmox Nodes */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-5 shadow-sm">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-green-500">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
Proxmox Nodes
</h4>
<div class="space-y-4">
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">CPU Usage</label>
<span class="text-xs text-gray-500">{props.nodeDefaults.cpu}%</span>
</div>
<ThresholdSlider
value={props.nodeDefaults.cpu}
onChange={(v) => {
props.setNodeDefaults((prev) => ({...prev, cpu: v}));
props.setHasUnsavedChanges(true);
}}
type="cpu"
/>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">Memory Usage</label>
<span class="text-xs text-gray-500">{props.nodeDefaults.memory}%</span>
</div>
<ThresholdSlider
value={props.nodeDefaults.memory}
onChange={(v) => {
props.setNodeDefaults((prev) => ({...prev, memory: v}));
props.setHasUnsavedChanges(true);
}}
type="memory"
/>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">Disk Usage</label>
<span class="text-xs text-gray-500">{props.nodeDefaults.disk}%</span>
</div>
<ThresholdSlider
value={props.nodeDefaults.disk}
onChange={(v) => {
props.setNodeDefaults((prev) => ({...prev, disk: v}));
props.setHasUnsavedChanges(true);
}}
type="disk"
/>
</div>
</div>
</div>
{/* Storage */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-5 shadow-sm">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-amber-500">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
Storage
</h4>
<div class="space-y-4">
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">Usage Threshold</label>
<span class="text-xs text-gray-500">{props.storageDefault()}%</span>
</div>
<ThresholdSlider
value={props.storageDefault()}
onChange={(v) => {
props.setStorageDefault(v);
props.setHasUnsavedChanges(true);
}}
type="disk"
/>
</div>
</div>
</div>
</div>
</div>
{/* Step 2: Resource-Specific Overrides */}
<div>
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Custom Overrides</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Override default thresholds for specific VMs, containers, or nodes
</p>
</div>
{/* Existing Overrides List */}
<Show when={props.overrides().length > 0}>
<div class="space-y-3 mb-6">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Active Overrides ({props.overrides().length})
</h4>
<div class="space-y-2">
<For each={props.overrides()}>
{(override) => (
<OverrideItem
override={override}
onEdit={() => setEditingOverride(override)}
onRemove={() => {
props.setOverrides(props.overrides().filter((o) => o.id !== override.id));
// Update raw config too
const newRawConfig = { ...props.rawOverridesConfig() };
delete newRawConfig[override.id];
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
}}
/>
)}
</For>
</div>
</div>
</Show>
{/* Add New Override Form */}
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 hover:border-blue-400 dark:hover:border-blue-500 transition-colors">
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-blue-600 dark:text-blue-400">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</div>
<div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Add Custom Override</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">Set different thresholds for a specific resource</p>
</div>
</div>
<AddOverrideForm
guests={props.allGuests()}
nodes={props.state.nodes || []}
existingOverrides={props.overrides()}
editingOverride={editingOverride()}
onAdd={(override) => {
props.setOverrides([...props.overrides(), override]);
// Update raw config too
const newRawConfig = { ...props.rawOverridesConfig() };
const hysteresisThresholds: Record<string, any> = {};
Object.entries(override.thresholds).forEach(([metric, value]) => {
hysteresisThresholds[metric] = { trigger: value, clear: Math.max(0, (value as number) - 5) };
});
// Include disabled field if present
if (override.disabled) {
hysteresisThresholds.disabled = true;
}
newRawConfig[override.id] = hysteresisThresholds;
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
}}
onUpdate={(override) => {
props.setOverrides(props.overrides().map((o: Override) =>
o.id === override.id ? override : o
));
// Update raw config too
const newRawConfig = { ...props.rawOverridesConfig() };
const hysteresisThresholds: Record<string, any> = {};
Object.entries(override.thresholds).forEach(([metric, value]) => {
hysteresisThresholds[metric] = { trigger: value, clear: Math.max(0, (value as number) - 5) };
});
// Include disabled field if present
if (override.disabled) {
hysteresisThresholds.disabled = true;
}
newRawConfig[override.id] = hysteresisThresholds;
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
setEditingOverride(null);
}}
onCancelEdit={() => setEditingOverride(null)}
/>
</div>
</div>
</div>
<ThresholdsTable
overrides={props.overrides}
setOverrides={props.setOverrides}
rawOverridesConfig={props.rawOverridesConfig}
setRawOverridesConfig={props.setRawOverridesConfig}
allGuests={props.allGuests}
nodes={props.state.nodes || []}
guestDefaults={props.guestDefaults()}
setGuestDefaults={props.setGuestDefaults}
nodeDefaults={props.nodeDefaults()}
setNodeDefaults={props.setNodeDefaults}
storageDefault={props.storageDefault}
setStorageDefault={props.setStorageDefault}
timeThreshold={props.timeThreshold}
setTimeThreshold={props.setTimeThreshold}
setHasUnsavedChanges={props.setHasUnsavedChanges}
/>
);
}