mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 03:33:53 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user