From 8aebbe3b546012b3fdf4e7396915f2eb27f6dd44 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 7 Oct 2025 14:51:55 +0000 Subject: [PATCH] Revamp alerts and Docker host management --- frontend-modern/package-lock.json | 14 + frontend-modern/package.json | 1 + frontend-modern/src/App.tsx | 344 ++--- frontend-modern/src/api/monitoring.ts | 36 + .../src/components/Alerts/ResourceTable.tsx | 1176 ++++++++++------- .../src/components/Alerts/ThresholdsTable.tsx | 863 ++++-------- .../src/components/Dashboard/GuestRow.tsx | 20 +- .../components/Proxmox/ProxmoxSectionNav.tsx | 8 +- .../src/components/Settings/DockerAgents.tsx | 87 +- .../src/components/Settings/Settings.tsx | 33 +- .../src/components/icons/DockerIcon.tsx | 20 + .../src/components/icons/ProxmoxIcon.tsx | 20 + .../src/components/shared/Toggle.tsx | 125 +- frontend-modern/src/pages/Alerts.tsx | 164 ++- frontend-modern/src/stores/websocket.ts | 35 +- frontend-modern/src/types/alerts.ts | 1 + internal/alerts/alerts.go | 394 +++++- internal/api/alerts.go | 34 +- internal/api/docker_agents.go | 32 + internal/api/router.go | 1 + internal/models/models.go | 24 + internal/monitoring/monitor.go | 25 + 22 files changed, 2105 insertions(+), 1352 deletions(-) create mode 100644 frontend-modern/src/components/icons/DockerIcon.tsx create mode 100644 frontend-modern/src/components/icons/ProxmoxIcon.tsx diff --git a/frontend-modern/package-lock.json b/frontend-modern/package-lock.json index dda7a344b..95d1d2c64 100644 --- a/frontend-modern/package-lock.json +++ b/frontend-modern/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@solidjs/router": "^0.10.10", + "simple-icons": "^13.21.0", "solid-js": "^1.8.0", "ws": "^8.18.3" }, @@ -3770,6 +3771,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-icons": { + "version": "13.21.0", + "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-13.21.0.tgz", + "integrity": "sha512-LI5pVJPBv6oc79OMsffwb6kEqnmB8P1Cjg1crNUlhsxPETQ5UzbCKQdxU+7MW6+DD1qfPkla/vSKlLD4IfyXpQ==", + "license": "CC0-1.0", + "engines": { + "node": ">=0.12.18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/simple-icons" + } + }, "node_modules/solid-js": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.7.tgz", diff --git a/frontend-modern/package.json b/frontend-modern/package.json index 92f0049ca..45bb55b42 100644 --- a/frontend-modern/package.json +++ b/frontend-modern/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@solidjs/router": "^0.10.10", + "simple-icons": "^13.21.0", "solid-js": "^1.8.0", "ws": "^8.18.3" }, diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index f47003dc1..a7d8d1bbb 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -4,8 +4,9 @@ import { createContext, useContext, createEffect, - onMount, + createMemo, onCleanup, + onMount, getOwner, runWithOwner, } from 'solid-js'; @@ -35,6 +36,8 @@ import { UpdateBanner } from './components/UpdateBanner'; import { DemoBanner } from './components/DemoBanner'; import { createTooltipSystem } from './components/shared/Tooltip'; import type { State } from '@/types/api'; +import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; +import { DockerIcon } from '@/components/icons/DockerIcon'; // Enhanced store type with proper typing type EnhancedStore = ReturnType; @@ -537,8 +540,6 @@ function App() { dataUpdated={dataUpdated} lastUpdateText={lastUpdateText} versionInfo={versionInfo} - darkMode={darkMode} - toggleDarkMode={toggleDarkMode} hasAuth={hasAuth} needsAuth={needsAuth} proxyAuthInfo={proxyAuthInfo} @@ -576,7 +577,10 @@ function App() { } /> } /> - + } + /> ); } @@ -588,8 +592,6 @@ function AppLayout(props: { dataUpdated: () => boolean; lastUpdateText: () => string; versionInfo: () => VersionInfo | null; - darkMode: () => boolean; - toggleDarkMode: () => void; hasAuth: () => boolean; needsAuth: () => boolean; proxyAuthInfo: () => { username?: string; logoutURL?: string } | null; @@ -599,39 +601,45 @@ function AppLayout(props: { }) { const navigate = useNavigate(); const location = useLocation(); - const [dockerTabPreference, setDockerTabPreference] = createSignal(true); + const PLATFORM_SEEN_STORAGE_KEY = 'pulse-platforms-seen'; - const DOCKER_VISIBILITY_EVENT = 'pulse:docker-tab-visibility'; - const DOCKER_PREFERENCE_KEY = 'pulse-show-docker-tab'; - - const readDockerPreference = () => { - if (typeof window === 'undefined') return true; - const stored = window.localStorage.getItem(DOCKER_PREFERENCE_KEY); - return stored !== 'false'; + const readSeenPlatforms = (): Record => { + if (typeof window === 'undefined') return {}; + try { + const stored = window.localStorage.getItem(PLATFORM_SEEN_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored) as Record; + if (parsed && typeof parsed === 'object') { + return parsed; + } + } + } catch (error) { + console.warn('Failed to parse stored platform visibility preferences', error); + } + return {}; }; - onMount(() => { - setDockerTabPreference(readDockerPreference()); + const [seenPlatforms, setSeenPlatforms] = createSignal>(readSeenPlatforms()); - const refreshPreference = (value?: boolean) => { - if (typeof value === 'boolean') { - setDockerTabPreference(value); - return; + const persistSeenPlatforms = (map: Record) => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(PLATFORM_SEEN_STORAGE_KEY, JSON.stringify(map)); + } catch (error) { + console.warn('Failed to persist platform visibility preferences', error); + } + }; + + const markPlatformSeen = (platformId: string) => { + setSeenPlatforms((current) => { + if (current[platformId]) { + return current; } - setDockerTabPreference(readDockerPreference()); - }; - - const handler = (event: Event) => { - if (event instanceof CustomEvent && typeof event.detail?.value === 'boolean') { - refreshPreference(event.detail.value); - } else { - refreshPreference(); - } - }; - - window.addEventListener(DOCKER_VISIBILITY_EVENT, handler); - return () => window.removeEventListener(DOCKER_VISIBILITY_EVENT, handler); - }); + const updated = { ...current, [platformId]: true }; + persistSeenPlatforms(updated); + return updated; + }); + }; // Determine active tab from current path const getActiveTab = () => { @@ -642,13 +650,61 @@ function AppLayout(props: { if (path.startsWith('/settings')) return 'settings'; return 'proxmox'; }; + const hasDockerHosts = createMemo(() => (props.state().dockerHosts?.length ?? 0) > 0); + const hasProxmoxHosts = createMemo( + () => + (props.state().nodes?.length ?? 0) > 0 || + (props.state().vms?.length ?? 0) > 0 || + (props.state().containers?.length ?? 0) > 0, + ); - const shouldShowDockerTab = () => { - const hosts = props.state().dockerHosts || []; - if (hosts.length > 0) { - return true; + createEffect(() => { + if (hasDockerHosts()) { + markPlatformSeen('docker'); + } + }); + + createEffect(() => { + if (hasProxmoxHosts()) { + markPlatformSeen('proxmox'); + } + }); + + const platformTabs = createMemo(() => { + return [ + { + id: 'proxmox' as const, + label: 'Proxmox', + route: '/proxmox/overview', + settingsRoute: '/settings', + tooltip: 'Monitor Proxmox clusters and nodes', + enabled: hasProxmoxHosts() || !!seenPlatforms()['proxmox'], + live: hasProxmoxHosts(), + icon: ( + + ), + }, + { + id: 'docker' as const, + label: 'Docker', + route: '/docker', + settingsRoute: '/settings/docker', + tooltip: 'Monitor Docker hosts and containers', + enabled: hasDockerHosts() || !!seenPlatforms()['docker'], + live: hasDockerHosts(), + icon: ( + + ), + }, + ]; + }); + + const handlePlatformClick = (platform: ReturnType[number]) => { + if (platform.enabled) { + navigate(platform.route); + } else { + navigate(platform.settingsRoute); } - return dockerTabPreference(); }; return ( @@ -692,44 +748,58 @@ function AppLayout(props: {
- + + +
-
navigate('/proxmox/overview')} - role="tab" - title="Proxmox overview, storage, and backups" - > - - - - - Proxmox -
- -
navigate('/docker')} - role="tab" - > - - - - Docker -
-
-
navigate('/alerts')} - role="tab" - > - - - - - - Alerts -
-
navigate('/settings')} - role="tab" - > - - - - - Settings - - - -
+ + {(platform) => { + const isActive = () => getActiveTab() === platform.id; + const disabled = () => !platform.enabled; + const className = () => { + if (isActive()) { + return 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'; + } + if (disabled()) { + return 'text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-60 border-transparent'; + } + return 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'; + }; + + const title = () => + disabled() + ? `${platform.label} is not configured yet. Click to open settings.` + : platform.tooltip; + + return ( + + ); + }} +
{/* Main Content */} diff --git a/frontend-modern/src/api/monitoring.ts b/frontend-modern/src/api/monitoring.ts index fab0247c2..09c4723ad 100644 --- a/frontend-modern/src/api/monitoring.ts +++ b/frontend-modern/src/api/monitoring.ts @@ -20,4 +20,40 @@ export class MonitoringAPI { const response = await apiFetch(`${this.baseUrl}/diagnostics/export`); return response.blob(); } + + static async deleteDockerHost(hostId: string): Promise { + const response = await apiFetch( + `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}`, + { + method: 'DELETE', + }, + ); + + if (!response.ok) { + if (response.status === 404) { + // Host already gone; treat as success so UI state stays consistent + return; + } + + let message = `Failed with status ${response.status}`; + try { + const text = await response.text(); + if (text?.trim()) { + message = text.trim(); + try { + const parsed = JSON.parse(text); + if (typeof parsed?.error === 'string' && parsed.error.trim()) { + message = parsed.error.trim(); + } + } catch (_jsonErr) { + // ignore JSON parse errors, fallback to raw text + } + } + } catch (_err) { + // ignore read error, keep default message + } + + throw new Error(message); + } + } } diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx index 13209285e..b781004ad 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx @@ -1,4 +1,5 @@ -import { For, Show } from 'solid-js'; +import { For, Show, createSignal } from 'solid-js'; +import { TogglePrimitive } from '@/components/shared/Toggle'; import type { Alert } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; @@ -6,8 +7,11 @@ import { SectionHeader } from '@/components/shared/SectionHeader'; export interface Resource { id: string; name: string; + displayName?: string; + rawName?: string; node?: string; instance?: string; + host?: string; type?: string; resourceType?: string; thresholds?: Record; @@ -19,9 +23,22 @@ export interface Resource { vmid?: number; cpu?: number; memory?: number; + uptime?: number; + clusterName?: string; + isClusterMember?: boolean; [key: string]: unknown; } +export interface GroupHeaderMeta { + type?: 'node' | 'default'; + displayName?: string; + rawName?: string; + host?: string; + status?: string; + clusterName?: string; + isClusterMember?: boolean; +} + interface ResourceTableProps { title: string; resources?: Resource[]; @@ -39,11 +56,20 @@ interface ResourceTableProps { onRemoveOverride: (resourceId: string) => void; onToggleDisabled?: (resourceId: string) => void; onToggleNodeConnectivity?: (nodeId: string) => void; + showOfflineAlertsColumn?: boolean; // Show separate column for offline/connectivity alerts editingId: () => string | null; editingThresholds: () => Record; setEditingThresholds: (value: Record) => void; formatMetricValue: (metric: string, value: number | undefined) => string; hasActiveAlert: (resourceId: string, metric: string) => boolean; + globalDefaults?: Record; + setGlobalDefaults?: (value: Record | ((prev: Record) => Record)) => void; + setHasUnsavedChanges?: (value: boolean) => void; + globalDisableFlag?: () => boolean; + onToggleGlobalDisable?: () => void; + globalDisableOfflineFlag?: () => boolean; + onToggleGlobalDisableOffline?: () => void; + groupHeaderMeta?: Record; } export function ResourceTable(props: ResourceTableProps) { @@ -56,27 +82,76 @@ export function ResourceTable(props: ResourceTableProps) { const hasRows = () => flattenResources().length > 0; + const [activeMetricInput, setActiveMetricInput] = createSignal<{ resourceId: string; metric: string } | null>(null); + + const renderGroupHeader = (groupKey: string, meta?: GroupHeaderMeta) => { + if (!meta || meta.type !== 'node') { + return {groupKey}; + } + + return ( + + ); + }; + const MetricValueWithHeat = (metricProps: { resourceId: string; metric: string; value: number; isOverridden: boolean; - }) => ( -
- { + const isDisabledMetric = metricProps.value <= 0; + const displayText = isDisabledMetric + ? 'Off' + : props.formatMetricValue(metricProps.metric, metricProps.value); + + return ( +
- {props.formatMetricValue(metricProps.metric, metricProps.value)} - - -
- -
- ); + + {displayText} + + +
+ +
+ ); + }; return ( + + Alerts + Resource Type - - Status - {(column) => ( @@ -107,35 +182,195 @@ export function ResourceTable(props: ResourceTableProps) { )} - - Alerts - + + + Offline Alerts + + Actions + {/* Global Defaults Row */} + + + + -}> +
+ { + props.onToggleGlobalDisable?.(); + props.setHasUnsavedChanges?.(true); + }} + checkedClass="bg-emerald-500/80 border-emerald-600/70 dark:bg-emerald-500/60 dark:border-emerald-500/70" + uncheckedClass="bg-rose-500/80 border-rose-600/70 dark:bg-rose-500/60 dark:border-rose-500/70" + class="my-[1px]" + title="Global alerts toggle - disable all alerts for this resource type" + ariaLabel="Global alerts toggle" + /> +
+
+ + + + Global Defaults + + + + {(column) => { + const normalizedColumn = column.trim().toLowerCase(); + const metric = ( + { + 'cpu %': 'cpu', + 'memory %': 'memory', + 'disk %': 'disk', + 'disk r mb/s': 'diskRead', + 'disk w mb/s': 'diskWrite', + 'net in mb/s': 'networkIn', + 'net out mb/s': 'networkOut', + 'usage %': 'usage', + 'temp °c': 'temperature', + 'temperature °c': 'temperature', + temperature: 'temperature', + } as Record + )[normalizedColumn] + ?? normalizedColumn + .replace(' %', '') + .replace(' °c', '') + .replace(' mb/s', '') + .replace('disk r', 'diskRead') + .replace('disk w', 'diskWrite') + .replace('net in', 'networkIn') + .replace('net out', 'networkOut'); + + // Get default value when enabling a disabled metric + const getEnabledDefault = (m: string): number => { + if (m.includes('Read') || m.includes('Write') || m.includes('In') || m.includes('Out')) { + return 100; // 100 MB/s for I/O metrics + } + if (m === 'temperature') { + return 80; // 80°C for temperature + } + return 80; // 80% for percentage metrics + }; + + const val = () => props.globalDefaults?.[metric] ?? 0; + const isOff = () => val() === -1; + + return ( + +
+ { + const value = parseInt(e.currentTarget.value, 10); + if (props.setGlobalDefaults) { + props.setGlobalDefaults((prev) => ({ + ...prev, + [metric]: Number.isNaN(value) ? 0 : value, + })); + } + if (props.setHasUnsavedChanges) { + props.setHasUnsavedChanges(true); + } + }} + class={`w-16 px-2 py-0.5 text-xs text-center border rounded ${ + isOff() + ? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 italic placeholder:text-gray-400 dark:placeholder:text-gray-500 placeholder:opacity-60 pointer-events-none' + : 'border-blue-300 dark:border-blue-600 bg-white dark:bg-gray-700' + }`} + title={isOff() ? 'Click to enable this metric' : ''} + /> + + + +
+ + ); + }} +
+ + + -}> +
+ { + props.onToggleGlobalDisableOffline?.(); + props.setHasUnsavedChanges?.(true); + }} + checkedClass="bg-emerald-500/80 border-emerald-600/70 dark:bg-emerald-500/60 dark:border-emerald-500/70" + uncheckedClass="bg-rose-500/80 border-rose-600/70 dark:bg-rose-500/60 dark:border-rose-500/70" + class="my-[1px]" + title="Global offline alerts toggle" + ariaLabel="Global offline alerts toggle" + /> +
+
+ +
+ + - + + +
a.localeCompare(b), )} > - {([nodeName, resources]) => ( - <> - {/* Node group header */} - - - {nodeName} - - - {/* Resources in this group */} - - {(resource) => { + {([nodeName, resources]) => { + const headerMeta = props.groupHeaderMeta?.[nodeName]; + + return ( + <> + {/* Node group header */} + + + {renderGroupHeader(nodeName, headerMeta)} + + + {/* Resources in this group */} + + {(resource) => { const isEditing = () => props.editingId() === resource.id; const thresholds = (): Record => { if (isEditing()) { @@ -182,9 +417,35 @@ export function ResourceTable(props: ResourceTableProps) { return ( - + {/* Alert toggle column */} + + +
+ !props.globalDisableFlag?.() && props.onToggleDisabled?.(resource.id)} + checkedClass="bg-emerald-500/80 border-emerald-600/70 dark:bg-emerald-500/60 dark:border-emerald-500/70" + uncheckedClass="bg-rose-500/80 border-rose-600/70 dark:bg-rose-500/60 dark:border-rose-500/70" + disabledClass="bg-slate-400/60 border-slate-500/70 dark:bg-slate-600/60 dark:border-slate-600/70 cursor-not-allowed opacity-60" + class="my-[1px]" + title={ + props.globalDisableFlag?.() + ? 'Alerts disabled globally' + : resource.disabled + ? 'Click to enable alerts' + : 'Click to disable alerts' + } + ariaLabel={resource.disabled ? 'Alerts disabled for this resource' : 'Alerts enabled for this resource'} + /> +
+
+ + + ({resource.vmid}) - + + on {resource.node} + + + on {resource.node} + + Custom
- - - - {resource.resourceType} - - - - - {resource.status} - - - + }> + + + + + + {resource.resourceType} + + {/* Metric columns - dynamically rendered based on resource type */} {(column) => { @@ -289,8 +578,20 @@ export function ResourceTable(props: ResourceTableProps) { return true; }; + const isDisabled = () => thresholds()?.[metric] === -1; + + const openMetricEditor = (e: MouseEvent) => { + e.stopPropagation(); + setActiveMetricInput({ resourceId: resource.id, metric }); + props.onEdit( + resource.id, + resource.thresholds ? { ...resource.thresholds } : {}, + resource.defaults ? { ...resource.defaults } : {}, + ); + }; + return ( - + +
+ +
} > +
{ + if ( + isEditing() && + activeMetricInput()?.resourceId === resource.id && + activeMetricInput()?.metric === metric + ) { + queueMicrotask(() => { + el.focus(); + el.select(); + }); + } + }} onInput={(e) => { - const val = parseInt(e.currentTarget.value); + const raw = e.currentTarget.value; + if (raw === '') { + props.setEditingThresholds({ + ...props.editingThresholds(), + [metric]: undefined, + }); + return; + } + const val = parseInt(raw, 10); if (!Number.isNaN(val)) { props.setEditingThresholds({ ...props.editingThresholds(), @@ -333,9 +662,19 @@ export function ResourceTable(props: ResourceTableProps) { }); } }} - 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" + onBlur={() => { + if (props.editingId() === resource.id) { + props.onSaveEdit(resource.id); + } + setActiveMetricInput(null); + }} + class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${ + isDisabled() + ? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-600' + : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600' + }`} /> +
@@ -343,122 +682,37 @@ export function ResourceTable(props: ResourceTableProps) { }}
- {/* Alerts column */} - - - - - - - - - - - - - - - - - - - - - - - + {/* Offline Alerts column - Connectivity/powered-off alerts */} + + + + + !props.globalDisableFlag?.() && + !props.globalDisableOfflineFlag?.() && + props.onToggleNodeConnectivity?.(resource.id) + } + checkedClass="bg-emerald-500/80 border-emerald-600/70 dark:bg-emerald-500/60 dark:border-emerald-500/70" + uncheckedClass="bg-rose-500/80 border-rose-600/70 dark:bg-rose-500/60 dark:border-rose-500/70" + disabledClass="bg-slate-400/60 border-slate-500/70 dark:bg-slate-600/60 dark:border-slate-600/70 cursor-not-allowed opacity-60" + class="mx-auto my-[1px]" + title={ + props.globalDisableFlag?.() + ? 'Alerts disabled globally' + : props.globalDisableOfflineFlag?.() + ? 'Offline alerts disabled globally' + : resource.disableConnectivity + ? 'Offline alerts disabled for this resource' + : 'Offline alerts enabled' + } + ariaLabel={resource.disableConnectivity ? 'Offline alerts disabled for this resource' : 'Offline alerts enabled for this resource'} + /> + + + {/* Actions column */} @@ -466,48 +720,29 @@ export function ResourceTable(props: ResourceTableProps) { - - - + + + } > @@ -572,9 +807,10 @@ export function ResourceTable(props: ResourceTableProps) { ); }} - - - )} + + + ); + }} @@ -629,43 +865,97 @@ export function ResourceTable(props: ResourceTableProps) { return ( + {/* Alert toggle column */} + + +
+ !props.globalDisableFlag?.() && props.onToggleDisabled?.(resource.id)} + checkedClass="bg-emerald-500/80 border-emerald-600/70 dark:bg-emerald-500/60 dark:border-emerald-500/70" + uncheckedClass="bg-rose-500/80 border-rose-600/70 dark:bg-rose-500/60 dark:border-rose-500/70" + disabledClass="bg-slate-400/60 border-slate-500/70 dark:bg-slate-600/60 dark:border-slate-600/70 cursor-not-allowed opacity-60" + class="my-[1px]" + title={ + props.globalDisableFlag?.() + ? 'Alerts disabled globally' + : resource.disabled + ? 'Click to enable alerts' + : 'Click to disable alerts' + } + ariaLabel={resource.disabled ? 'Alerts disabled for this resource' : 'Alerts enabled for this resource'} + /> +
+
+ -
- - {resource.name} - - - ({resource.vmid}) - - - on {resource.node} - - - on {resource.node} - - - - Custom + + + {resource.name} - -
+ + ({resource.vmid}) + + + on {resource.node} + + + on {resource.node} + + + + Custom + + +
+ }> + + {resource.resourceType} - - - {resource.status} - - - {/* Metric columns - dynamically rendered based on resource type */} {(column) => { @@ -730,8 +1010,20 @@ export function ResourceTable(props: ResourceTableProps) { return true; }; + const isDisabled = () => thresholds()?.[metric] === -1; + + const openMetricEditor = (e: MouseEvent) => { + e.stopPropagation(); + setActiveMetricInput({ resourceId: resource.id, metric }); + props.onEdit( + resource.id, + resource.thresholds ? { ...resource.thresholds } : {}, + resource.defaults ? { ...resource.defaults } : {}, + ); + }; + return ( - + +
+ +
} > - { - const currentThresholds = thresholds(); - const rawValue = currentThresholds[metric]; - return rawValue ?? ''; - })()} - onInput={(e) => { - const parsed = e.currentTarget.value.trim(); - let nextValue: number | undefined; - if (parsed !== '') { - const numeric = Number(parsed); - nextValue = Number.isFinite(numeric) - ? numeric - : undefined; +
+ + value={thresholds()?.[metric] ?? ''} + placeholder={isDisabled() ? 'Off' : ''} + ref={(el) => { + if ( + isEditing() && + activeMetricInput()?.resourceId === resource.id && + activeMetricInput()?.metric === metric + ) { + queueMicrotask(() => { + el.focus(); + el.select(); + }); + } + }} + onInput={(e) => { + const raw = e.currentTarget.value; + if (raw === '') { + props.setEditingThresholds({ + ...props.editingThresholds(), + [metric]: undefined, + }); + return; + } + const val = parseInt(raw, 10); + if (!Number.isNaN(val)) { + props.setEditingThresholds({ + ...props.editingThresholds(), + [metric]: val, + }); + } + }} + onBlur={() => { + if (props.editingId() === resource.id) { + props.onSaveEdit(resource.id); + } + setActiveMetricInput(null); + }} + class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${ + isDisabled() + ? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-600' + : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600' + }`} + /> +
@@ -791,81 +1112,39 @@ export function ResourceTable(props: ResourceTableProps) { }}
- {/* Alerts column */} - - - - - - - - - - - - - - - - - + {/* Offline Alerts column - Connectivity/powered-off alerts */} + + + +
+ + !props.globalDisableFlag?.() && + !props.globalDisableOfflineFlag?.() && + props.onToggleNodeConnectivity?.(resource.id) + } + checkedClass="bg-emerald-500/80 border-emerald-600/70 dark:bg-emerald-500/60 dark:border-emerald-500/70" + uncheckedClass="bg-rose-500/80 border-rose-600/70 dark:bg-rose-500/60 dark:border-rose-500/70" + disabledClass="bg-slate-400/60 border-slate-500/70 dark:bg-slate-600/60 dark:border-slate-600/70 cursor-not-allowed opacity-60" + class="my-[1px]" + title={ + props.globalDisableFlag?.() + ? 'Alerts disabled globally' + : props.globalDisableOfflineFlag?.() + ? 'Offline alerts disabled globally' + : resource.disableConnectivity + ? 'Offline alerts disabled for this resource' + : 'Offline alerts enabled' + } + ariaLabel={resource.disableConnectivity ? 'Offline alerts disabled for this resource' : 'Offline alerts enabled for this resource'} + /> +
+
+ +
{/* Actions column */} @@ -873,48 +1152,29 @@ export function ResourceTable(props: ResourceTableProps) { - - - + + + } > -
- - - - - {/* Tab Navigation */} -
- -
- {/* Search Bar */}
>((pr 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" + class="w-full pl-10 pr-10 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> >((pr
+ {/* Tab Navigation */} +
+ +
+
@@ -1612,12 +1156,21 @@ const dockerContainersGroupedByHost = createMemo>((pr onSaveEdit={saveEdit} onCancelEdit={cancelEdit} onRemoveOverride={removeOverride} + onToggleDisabled={toggleDisabled} onToggleNodeConnectivity={toggleNodeConnectivity} + showOfflineAlertsColumn={true} editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} + globalDefaults={props.nodeDefaults} + setGlobalDefaults={props.setNodeDefaults} + setHasUnsavedChanges={props.setHasUnsavedChanges} + globalDisableFlag={props.disableAllNodes} + onToggleGlobalDisable={() => props.setDisableAllNodes(!props.disableAllNodes())} + globalDisableOfflineFlag={props.disableAllNodesOffline} + onToggleGlobalDisableOffline={() => props.setDisableAllNodesOffline(!props.disableAllNodesOffline())} />
@@ -1635,11 +1188,24 @@ const dockerContainersGroupedByHost = createMemo>((pr onCancelEdit={cancelEdit} onRemoveOverride={removeOverride} onToggleDisabled={toggleDisabled} + showOfflineAlertsColumn={false} editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} + globalDefaults={{ usage: props.storageDefault() }} + setGlobalDefaults={(value) => { + if (typeof value === 'function') { + const newValue = value({ usage: props.storageDefault() }); + props.setStorageDefault(newValue.usage ?? 85); + } else { + props.setStorageDefault(value.usage ?? 85); + } + }} + setHasUnsavedChanges={props.setHasUnsavedChanges} + globalDisableFlag={props.disableAllStorage} + onToggleGlobalDisable={() => props.setDisableAllStorage(!props.disableAllStorage())} /> @@ -1656,12 +1222,28 @@ const dockerContainersGroupedByHost = createMemo>((pr onSaveEdit={saveEdit} onCancelEdit={cancelEdit} onRemoveOverride={removeOverride} + onToggleDisabled={toggleDisabled} onToggleNodeConnectivity={toggleNodeConnectivity} + showOfflineAlertsColumn={true} editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} + globalDefaults={{ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory }} + setGlobalDefaults={(value) => { + if (typeof value === 'function') { + const newValue = value({ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory }); + props.setNodeDefaults((prev) => ({ ...prev, cpu: newValue.cpu ?? prev.cpu, memory: newValue.memory ?? prev.memory })); + } else { + props.setNodeDefaults((prev) => ({ ...prev, cpu: value.cpu ?? prev.cpu, memory: value.memory ?? prev.memory })); + } + }} + setHasUnsavedChanges={props.setHasUnsavedChanges} + globalDisableFlag={props.disableAllPBS} + onToggleGlobalDisable={() => props.setDisableAllPBS(!props.disableAllPBS())} + globalDisableOfflineFlag={props.disableAllPBSOffline} + onToggleGlobalDisableOffline={() => props.setDisableAllPBSOffline(!props.disableAllPBSOffline())} /> @@ -1671,6 +1253,7 @@ const dockerContainersGroupedByHost = createMemo>((pr >((pr onCancelEdit={cancelEdit} onRemoveOverride={removeOverride} onToggleDisabled={toggleDisabled} + onToggleNodeConnectivity={toggleNodeConnectivity} + showOfflineAlertsColumn={true} editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} + globalDefaults={props.guestDefaults} + setGlobalDefaults={props.setGuestDefaults} + setHasUnsavedChanges={props.setHasUnsavedChanges} + globalDisableFlag={props.disableAllGuests} + onToggleGlobalDisable={() => props.setDisableAllGuests(!props.disableAllGuests())} + globalDisableOfflineFlag={props.disableAllGuestsOffline} + onToggleGlobalDisableOffline={() => props.setDisableAllGuestsOffline(!props.disableAllGuestsOffline())} /> + {/* Docker Global Settings */} + +
+ +
+
+
+
+ + { + const value = parseInt(e.currentTarget.value, 10); + props.setDockerDefaults((prev) => ({ ...prev, restartCount: Number.isNaN(value) ? 3 : value })); + props.setHasUnsavedChanges(true); + }} + class="w-16 px-2 py-1 text-xs text-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700" + /> + restarts to trigger alert +
+
+ + { + const value = parseInt(e.currentTarget.value, 10); + props.setDockerDefaults((prev) => ({ ...prev, restartWindow: Number.isNaN(value) ? 300 : value })); + props.setHasUnsavedChanges(true); + }} + class="w-16 px-2 py-1 text-xs text-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700" + /> + seconds +
+
+ + { + const value = parseInt(e.currentTarget.value, 10); + props.setDockerDefaults((prev) => ({ ...prev, memoryWarnPct: Number.isNaN(value) ? 90 : value })); + props.setHasUnsavedChanges(true); + }} + class="w-16 px-2 py-1 text-xs text-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700" + /> + % of container limit +
+
+ + { + const value = parseInt(e.currentTarget.value, 10); + props.setDockerDefaults((prev) => ({ ...prev, memoryCriticalPct: Number.isNaN(value) ? 95 : value })); + props.setHasUnsavedChanges(true); + }} + class="w-16 px-2 py-1 text-xs text-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700" + /> + % of container limit +
+
+ + Always Enabled + (monitor container health status) +
+
+ + Always Enabled + (alert on exit code 137) +
+
+
+
+
>((pr onSaveEdit={saveEdit} onCancelEdit={cancelEdit} onRemoveOverride={removeOverride} + onToggleDisabled={toggleDisabled} onToggleNodeConnectivity={toggleNodeConnectivity} + showOfflineAlertsColumn={true} editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} + globalDisableFlag={props.disableAllDockerHosts} + onToggleGlobalDisable={() => props.setDisableAllDockerHosts(!props.disableAllDockerHosts())} + globalDisableOfflineFlag={props.disableAllDockerHostsOffline} + onToggleGlobalDisableOffline={() => props.setDisableAllDockerHostsOffline(!props.disableAllDockerHostsOffline())} />
@@ -1725,11 +1425,24 @@ const dockerContainersGroupedByHost = createMemo>((pr onCancelEdit={cancelEdit} onRemoveOverride={removeOverride} onToggleDisabled={toggleDisabled} + showOfflineAlertsColumn={false} editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} + globalDefaults={{ cpu: props.dockerDefaults.cpu, memory: props.dockerDefaults.memory }} + setGlobalDefaults={(value) => { + if (typeof value === 'function') { + const newValue = value({ cpu: props.dockerDefaults.cpu, memory: props.dockerDefaults.memory }); + props.setDockerDefaults((prev) => ({ ...prev, cpu: newValue.cpu ?? prev.cpu, memory: newValue.memory ?? prev.memory })); + } else { + props.setDockerDefaults((prev) => ({ ...prev, cpu: value.cpu ?? prev.cpu, memory: value.memory ?? prev.memory })); + } + }} + setHasUnsavedChanges={props.setHasUnsavedChanges} + globalDisableFlag={props.disableAllDockerContainers} + onToggleGlobalDisable={() => props.setDisableAllDockerContainers(!props.disableAllDockerContainers())} />
diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index df5ab5fb8..31e12af22 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -1,11 +1,10 @@ -import { For, Show, createMemo, createSignal, createEffect, onMount, on } from 'solid-js'; +import { createMemo, createSignal, createEffect, on, Show, For } from 'solid-js'; import type { VM, Container } from '@/types/api'; import { formatBytes, formatUptime } from '@/utils/format'; import { MetricBar } from './MetricBar'; import { IOMetric } from './IOMetric'; import { TagBadges } from './TagBadges'; import { DiskList } from './DiskList'; -import { GuestMetadataAPI } from '@/api/guestMetadata'; import { isGuestRunning, shouldDisplayGuestMetrics } from '@/utils/status'; type Guest = VM | Container; @@ -67,23 +66,6 @@ export function GuestRow(props: GuestRowProps) { }); - // Load custom URL from backend if not provided via props - onMount(async () => { - if (!props.customUrl) { - const startTime = performance.now(); - try { - const metadata = await GuestMetadataAPI.getMetadata(guestId()); - const endTime = performance.now(); - console.log(`[PERF] Individual metadata call for ${guestId()} took ${(endTime - startTime).toFixed(2)}ms`); - if (metadata && metadata.customUrl) { - setCustomUrl(metadata.customUrl); - } - } catch (err) { - // Silently fail - not critical for display - console.debug('Failed to load guest metadata:', err); - } - } - }); const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100); const memPercent = createMemo(() => { if (!props.guest.memory) return 0; diff --git a/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx b/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx index 41ad7d728..f77cba479 100644 --- a/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx +++ b/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx @@ -1,5 +1,6 @@ import type { Component, JSX } from 'solid-js'; import { useNavigate } from '@solidjs/router'; +import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; type ProxmoxSection = 'overview' | 'storage' | 'backups'; @@ -18,12 +19,7 @@ const sections: Array<{ id: 'overview', label: 'Overview', path: '/proxmox/overview', - icon: () => ( - - - - - ), + icon: () => , }, { id: 'storage', diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index ee0f7c5b5..b8888d0b3 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -3,7 +3,8 @@ import { useWebSocket } from '@/App'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; -import { Toggle } from '@/components/shared/Toggle'; +import { MonitoringAPI } from '@/api/monitoring'; +import { notificationStore } from '@/stores/notifications'; export const DockerAgents: Component = () => { const { state } = useWebSocket(); @@ -11,32 +12,7 @@ export const DockerAgents: Component = () => { const dockerHosts = () => state.dockerHosts || []; - const STORAGE_KEY = 'pulse-show-docker-tab'; - const readPreference = () => { - if (typeof window === 'undefined') return true; - const stored = window.localStorage.getItem(STORAGE_KEY); - return stored !== 'false'; - }; - - const [showDockerTab, setShowDockerTab] = createSignal(readPreference()); - - const persistPreference = (value: boolean) => { - setShowDockerTab(value); - if (typeof window !== 'undefined') { - window.localStorage.setItem(STORAGE_KEY, value ? 'true' : 'false'); - window.dispatchEvent( - new CustomEvent('pulse:docker-tab-visibility', { - detail: { value }, - }), - ); - } - }; - - createEffect(() => { - if (dockerHosts().length > 0 && !showDockerTab()) { - persistPreference(true); - } - }); + const [removingHostId, setRemovingHostId] = createSignal(null); const pulseUrl = () => { if (typeof window !== 'undefined') { @@ -110,6 +86,30 @@ WantedBy=multi-user.target`; } }; + const isRemovingHost = (hostId: string) => removingHostId() === hostId; + + const handleRemoveHost = async (hostId: string, displayName: string) => { + if (isRemovingHost(hostId)) return; + + const confirmed = window.confirm( + `Remove Docker host "${displayName}"? This clears it from Pulse until the agent reports again.`, + ); + if (!confirmed) return; + + setRemovingHostId(hostId); + + try { + await MonitoringAPI.deleteDockerHost(hostId); + notificationStore.success(`Removed Docker host ${displayName}`, 3500); + } catch (error) { + console.error('Failed to remove Docker host', error); + const message = error instanceof Error ? error.message : 'Failed to remove Docker host'; + notificationStore.error(message, 8000); + } finally { + setRemovingHostId(null); + } + }; + return (
@@ -123,27 +123,6 @@ WantedBy=multi-user.target`;
- - -
-
- Show Docker tab in navigation -
- persistPreference((event.currentTarget as HTMLInputElement).checked)} - /> -
-

- Preference is saved per browser. Hiding the tab won’t stop existing Docker hosts from reporting metrics. -

-
- {/* Deployment Instructions */} @@ -315,6 +294,7 @@ WantedBy=multi-user.target`; Docker Version Agent Version Last Seen + @@ -322,6 +302,7 @@ WantedBy=multi-user.target`; {(host) => { const isOnline = host.status?.toLowerCase() === 'online'; const runningContainers = host.containers?.filter(c => c.state?.toLowerCase() === 'running').length || 0; + const displayName = host.displayName || host.hostname || host.id; return ( @@ -372,6 +353,16 @@ WantedBy=multi-user.target`; {host.lastSeen ? formatAbsoluteTime(host.lastSeen) : '—'}
+ + + ); }} diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 2101d5409..696fb866d 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -112,7 +112,12 @@ type NodeConfigWithStatus = NodeConfig & { status: 'connected' | 'disconnected' | 'error' | 'pending'; }; -const Settings: Component = () => { +interface SettingsProps { + darkMode: () => boolean; + toggleDarkMode: () => void; +} + +const Settings: Component = (props) => { const { state, connected } = useWebSocket(); const navigate = useNavigate(); const location = useLocation(); @@ -2114,6 +2119,32 @@ const Settings: Component = () => { + + +
+
+

Dark mode

+

+ Toggle to match your environment. Pulse remembers this preference on each browser. +

+
+ { + const desired = (event.currentTarget as HTMLInputElement).checked; + if (desired !== props.darkMode()) { + props.toggleDarkMode(); + } + }} + /> +
+
+
diff --git a/frontend-modern/src/components/icons/DockerIcon.tsx b/frontend-modern/src/components/icons/DockerIcon.tsx new file mode 100644 index 000000000..cadd9a87b --- /dev/null +++ b/frontend-modern/src/components/icons/DockerIcon.tsx @@ -0,0 +1,20 @@ +import type { Component } from 'solid-js'; +import { siDocker } from 'simple-icons'; + +interface DockerIconProps { + class?: string; + title?: string; +} + +export const DockerIcon: Component = (props) => ( + + {props.title ?? 'Docker'} + + +); diff --git a/frontend-modern/src/components/icons/ProxmoxIcon.tsx b/frontend-modern/src/components/icons/ProxmoxIcon.tsx new file mode 100644 index 000000000..d6be853ab --- /dev/null +++ b/frontend-modern/src/components/icons/ProxmoxIcon.tsx @@ -0,0 +1,20 @@ +import type { Component } from 'solid-js'; +import { siProxmox } from 'simple-icons'; + +interface ProxmoxIconProps { + class?: string; + title?: string; +} + +export const ProxmoxIcon: Component = (props) => ( + + {props.title ?? 'Proxmox'} + + +); diff --git a/frontend-modern/src/components/shared/Toggle.tsx b/frontend-modern/src/components/shared/Toggle.tsx index 24f481570..0c633e57c 100644 --- a/frontend-modern/src/components/shared/Toggle.tsx +++ b/frontend-modern/src/components/shared/Toggle.tsx @@ -1,60 +1,89 @@ -import { JSX, mergeProps, splitProps } from 'solid-js'; +import { JSX } from 'solid-js'; -export type ToggleProps = { - label?: JSX.Element; - description?: JSX.Element; - containerClass?: string; -} & JSX.InputHTMLAttributes; +type ToggleSize = 'xs' | 'sm' | 'md'; -export function Toggle(props: ToggleProps) { - const merged = mergeProps({ containerClass: '' }, props); - const [local, rest] = splitProps(merged, [ - 'label', - 'description', - 'containerClass', - 'class', - 'disabled', - ]); +interface ToggleChangeEvent { + currentTarget: { + checked: boolean; + }; +} - const isDisabled = () => Boolean(local.disabled); - const isChecked = () => { - const value = rest.checked as unknown; - if (typeof value === 'function') { - try { - return Boolean((value as () => unknown)()); - } catch { - return false; - } - } - return Boolean(value); +interface BaseToggleProps { + checked: boolean; + disabled?: boolean; + onToggle?: () => void; + onChange?: (event: ToggleChangeEvent) => void; + size?: ToggleSize; + class?: string; + title?: string; + ariaLabel?: string; + checkedClass?: string; + uncheckedClass?: string; + disabledClass?: string; + knobClass?: string; +} + +const sizeConfig: Record = { + xs: { track: 'h-4 w-8', knob: 'h-3 w-3', translate: '14px' }, + sm: { track: 'h-5 w-10', knob: 'h-4 w-4', translate: '18px' }, + md: { track: 'h-6 w-11', knob: 'h-5 w-5', translate: '20px' }, +}; + +export function TogglePrimitive(props: BaseToggleProps): JSX.Element { + const size = props.size ?? 'sm'; + const config = sizeConfig[size]; + const isDisabled = () => Boolean(props.disabled); + const checkedClass = props.checkedClass ?? 'bg-emerald-500/80 border-emerald-600/70 dark:bg-emerald-500/60 dark:border-emerald-500/70'; + const uncheckedClass = props.uncheckedClass ?? 'bg-rose-500/80 border-rose-600/70 dark:bg-rose-500/60 dark:border-rose-500/70'; + const disabledClass = props.disabledClass ?? 'bg-slate-400/60 border-slate-500/70 dark:bg-slate-600/60 dark:border-slate-600/70 cursor-not-allowed opacity-60'; + const knobBase = props.knobClass ?? 'bg-white shadow'; + + const handleClick = () => { + if (isDisabled()) return; + const next = !props.checked; + props.onToggle?.(); + props.onChange?.({ currentTarget: { checked: next } }); }; return ( -