Revamp alerts and Docker host management

This commit is contained in:
rcourtman
2025-10-07 14:51:55 +00:00
parent 15a9fc646e
commit 8aebbe3b54
22 changed files with 2105 additions and 1352 deletions
+14
View File
@@ -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",
+1
View File
@@ -26,6 +26,7 @@
},
"dependencies": {
"@solidjs/router": "^0.10.10",
"simple-icons": "^13.21.0",
"solid-js": "^1.8.0",
"ws": "^8.18.3"
},
+176 -168
View File
@@ -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<typeof getGlobalWebSocketStore>;
@@ -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() {
<Route path="/backups" component={() => <Navigate href="/proxmox/backups" />} />
<Route path="/docker" component={() => <DockerHosts hosts={state().dockerHosts} />} />
<Route path="/alerts/*" component={Alerts} />
<Route path="/settings/*" component={Settings} />
<Route
path="/settings/*"
component={() => <Settings darkMode={darkMode} toggleDarkMode={toggleDarkMode} />}
/>
</Router>
);
}
@@ -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<string, boolean> => {
if (typeof window === 'undefined') return {};
try {
const stored = window.localStorage.getItem(PLATFORM_SEEN_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Record<string, boolean>;
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<Record<string, boolean>>(readSeenPlatforms());
const refreshPreference = (value?: boolean) => {
if (typeof value === 'boolean') {
setDockerTabPreference(value);
return;
const persistSeenPlatforms = (map: Record<string, boolean>) => {
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: (
<ProxmoxIcon class="w-4 h-4 shrink-0" />
),
},
{
id: 'docker' as const,
label: 'Docker',
route: '/docker',
settingsRoute: '/settings/docker',
tooltip: 'Monitor Docker hosts and containers',
enabled: hasDockerHosts() || !!seenPlatforms()['docker'],
live: hasDockerHosts(),
icon: (
<DockerIcon class="w-4 h-4 shrink-0" />
),
},
];
});
const handlePlatformClick = (platform: ReturnType<typeof platformTabs>[number]) => {
if (platform.enabled) {
navigate(platform.route);
} else {
navigate(platform.settingsRoute);
}
return dockerTabPreference();
};
return (
@@ -692,44 +748,58 @@ function AppLayout(props: {
</Show>
</div>
<div class="header-controls flex justify-end items-center gap-4 md:flex-1">
<button
onClick={props.toggleDarkMode}
class="p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title={props.darkMode() ? 'Switch to light mode' : 'Switch to dark mode'}
>
<Show
when={props.darkMode()}
fallback={
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
}
<div class="flex items-center gap-2">
<button
onClick={() => navigate('/alerts')}
class={`p-2 rounded-md transition-colors focus:outline-none ${
location.pathname.startsWith('/alerts')
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/40'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
title="View alerts"
>
<svg
class="h-5 w-5"
fill="none"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</Show>
</button>
</button>
<button
onClick={() => navigate('/settings')}
class={`p-2 rounded-md transition-colors focus:outline-none relative ${
location.pathname.startsWith('/settings')
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/40'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
title="Settings"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<Show when={updateStore.isUpdateVisible()}>
<span class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
</Show>
</button>
</div>
<div class="flex items-center gap-2">
<div
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out px-1.5 ${
@@ -808,104 +878,42 @@ function AppLayout(props: {
class="tabs flex mb-2 border-b border-gray-300 dark:border-gray-700 overflow-x-auto overflow-y-hidden whitespace-nowrap scrollbar-hide"
role="tablist"
>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
getActiveTab() === 'proxmox'
? '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'
: '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'
}`}
onClick={() => navigate('/proxmox/overview')}
role="tab"
title="Proxmox overview, storage, and backups"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<span>Proxmox</span>
</div>
<Show when={shouldShowDockerTab()}>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
getActiveTab() === 'docker'
? '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'
: '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'
}`}
onClick={() => navigate('/docker')}
role="tab"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338 0-.676.03-1.01.07-.458-1.515-1.877-2.352-3.173-2.352-1.604 0-2.832 1.125-3.254 1.828a.18.18 0 01-.142.084H1.101a.17.17 0 00-.17.171c0 1.047.134 3.528 1.82 5.416.819.915 2.096 2.055 4.563 2.434.766.117 1.582.176 2.427.176 1.066 0 2.14-.118 3.153-.35.88-.202 1.72-.5 2.497-.885.28-.14.53-.295.776-.458.986-.656 1.732-1.5 2.24-2.542.507.362 1.07.546 1.657.546.452 0 .908-.117 1.328-.346.922-.506 1.4-1.528 1.4-2.98 0-.156-.047-.31-.129-.438"/>
</svg>
<span>Docker</span>
</div>
</Show>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
getActiveTab() === 'alerts'
? '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'
: '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'
}`}
onClick={() => navigate('/alerts')}
role="tab"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span>Alerts</span>
</div>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors relative ${
getActiveTab() === 'settings'
? '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'
: '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'
}`}
onClick={() => navigate('/settings')}
role="tab"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span>Settings</span>
<Show when={updateStore.isUpdateVisible()}>
<span class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
</Show>
</div>
<For each={platformTabs()}>
{(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 (
<div
class={`tab px-2 sm:px-3 py-1.5 text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${className()}`}
role="tab"
aria-disabled={disabled()}
onClick={() => handlePlatformClick(platform)}
title={title()}
>
{platform.icon}
<span>{platform.label}</span>
<Show when={disabled() && !platform.live}>
<span class="ml-1 text-[10px] uppercase tracking-wide text-gray-400 dark:text-gray-600">Add host</span>
</Show>
</div>
);
}}
</For>
</div>
{/* Main Content */}
+36
View File
@@ -20,4 +20,40 @@ export class MonitoringAPI {
const response = await apiFetch(`${this.baseUrl}/diagnostics/export`);
return response.blob();
}
static async deleteDockerHost(hostId: string): Promise<void> {
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);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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;
@@ -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: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
),
icon: () => <ProxmoxIcon class="w-3.5 h-3.5" />,
},
{
id: 'storage',
@@ -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<string | null>(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 (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
@@ -123,27 +123,6 @@ WantedBy=multi-user.target`;
</button>
</div>
<Card class="space-y-3" padding="lg">
<SectionHeader
title="Docker tab visibility"
description="Hide the Docker tab if you dont plan to monitor container hosts. Well show it automatically once an agent reports in."
size="sm"
align="left"
/>
<div class="flex items-center justify-between gap-3">
<div class="flex-1 text-sm text-gray-600 dark:text-gray-400">
Show Docker tab in navigation
</div>
<Toggle
checked={showDockerTab()}
onChange={(event) => persistPreference((event.currentTarget as HTMLInputElement).checked)}
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Preference is saved per browser. Hiding the tab wont stop existing Docker hosts from reporting metrics.
</p>
</Card>
{/* Deployment Instructions */}
<Show when={showInstructions()}>
<Card class="space-y-4">
@@ -315,6 +294,7 @@ WantedBy=multi-user.target`;
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Docker Version</th>
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Agent Version</th>
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Last Seen</th>
<th class="py-3 px-4" />
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@@ -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 (
<tr class={`${isOnline ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800/50 opacity-60'}`}>
@@ -372,6 +353,16 @@ WantedBy=multi-user.target`;
{host.lastSeen ? formatAbsoluteTime(host.lastSeen) : '—'}
</div>
</td>
<td class="py-3 px-4 text-right">
<button
type="button"
class="text-xs font-semibold text-red-600 hover:text-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleRemoveHost(host.id, displayName)}
disabled={isRemovingHost(host.id)}
>
{isRemovingHost(host.id) ? 'Removing…' : 'Remove'}
</button>
</td>
</tr>
);
}}
@@ -112,7 +112,12 @@ type NodeConfigWithStatus = NodeConfig & {
status: 'connected' | 'disconnected' | 'error' | 'pending';
};
const Settings: Component = () => {
interface SettingsProps {
darkMode: () => boolean;
toggleDarkMode: () => void;
}
const Settings: Component<SettingsProps> = (props) => {
const { state, connected } = useWebSocket();
const navigate = useNavigate();
const location = useLocation();
@@ -2114,6 +2119,32 @@ const Settings: Component = () => {
</div>
</Card>
<Card padding="lg" class="space-y-3">
<SectionHeader
title="Appearance"
description="Switch between light and dark themes. Stored per device and synced to the server when authenticated."
size="sm"
align="left"
/>
<div class="flex items-center justify-between gap-3">
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-900 dark:text-gray-100">Dark mode</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Toggle to match your environment. Pulse remembers this preference on each browser.
</p>
</div>
<Toggle
checked={props.darkMode()}
onChange={(event) => {
const desired = (event.currentTarget as HTMLInputElement).checked;
if (desired !== props.darkMode()) {
props.toggleDarkMode();
}
}}
/>
</div>
</Card>
<div class="grid gap-4 lg:grid-cols-5">
<Card padding="lg" class="space-y-6 lg:col-span-3">
<section class="space-y-3">
@@ -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<DockerIconProps> = (props) => (
<svg
class={props.class}
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-label={props.title ?? 'Docker'}
>
<title>{props.title ?? 'Docker'}</title>
<path d={siDocker.path} fill="currentColor" />
</svg>
);
@@ -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<ProxmoxIconProps> = (props) => (
<svg
class={props.class}
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-label={props.title ?? 'Proxmox'}
>
<title>{props.title ?? 'Proxmox'}</title>
<path d={siProxmox.path} fill="currentColor" />
</svg>
);
@@ -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<HTMLInputElement>;
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<ToggleSize, { track: string; knob: string; translate: string }> = {
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 (
<label
class={`flex items-center gap-3 ${local.containerClass ?? ''} ${local.class ?? ''}`.trim()}
<button
type="button"
class={`relative inline-flex ${config.track} items-center justify-start rounded-full border transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${
isDisabled() ? disabledClass : props.checked ? checkedClass : uncheckedClass
} ${props.class ?? ''}`.trim()}
onClick={handleClick}
disabled={props.disabled}
title={props.title}
aria-pressed={props.checked ? 'true' : 'false'}
aria-label={props.ariaLabel}
>
<span
class={`relative inline-flex h-6 w-11 flex-shrink-0 items-center ${isDisabled() ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
<input type="checkbox" class="sr-only" disabled={local.disabled} {...rest} />
<span
class={`absolute inset-0 rounded-full transition ${
isChecked()
? 'bg-blue-600 dark:bg-blue-500'
: isDisabled()
? 'bg-gray-300 dark:bg-gray-600'
: 'bg-gray-200 dark:bg-gray-700'
}`}
/>
<span
class="absolute left-1 top-1 h-4 w-4 rounded-full bg-white shadow transition-transform dark:bg-gray-100"
style={{ transform: isChecked() ? 'translateX(20px)' : 'translateX(0)' }}
/>
</span>
{(local.label || local.description) && (
class={`absolute left-[3px] inline-block ${config.knob} rounded-full transition-transform duration-200 ${knobBase} ${
isDisabled() ? 'opacity-85' : ''
} ${props.checked ? '' : ''}`}
style={{ transform: props.checked ? `translateX(${config.translate})` : 'translateX(0)' }}
/>
</button>
);
}
interface LabeledToggleProps extends BaseToggleProps {
label?: JSX.Element;
description?: JSX.Element;
containerClass?: string;
}
export function Toggle(props: LabeledToggleProps) {
const size = props.size ?? 'md';
return (
<label class={`flex items-center gap-3 ${props.containerClass ?? ''}`.trim()}>
<TogglePrimitive {...props} size={size} />
{(props.label || props.description) && (
<span class="flex flex-col text-sm text-gray-700 dark:text-gray-300">
{local.label}
<span class="text-xs text-gray-500 dark:text-gray-400">{local.description}</span>
{props.label}
{props.description && (
<span class="text-xs text-gray-500 dark:text-gray-400">{props.description}</span>
)}
</span>
)}
</label>
+152 -12
View File
@@ -472,10 +472,10 @@ export function Alerts() {
cpu: 80,
memory: 85,
disk: 90,
diskRead: 0,
diskWrite: 0,
networkIn: 0,
networkOut: 0,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
});
setGuestDisableConnectivity(false);
setNodeDefaults({
@@ -517,10 +517,10 @@ export function Alerts() {
cpu: getTriggerValue(config.guestDefaults.cpu) ?? 80,
memory: getTriggerValue(config.guestDefaults.memory) ?? 85,
disk: getTriggerValue(config.guestDefaults.disk) ?? 90,
diskRead: getTriggerValue(config.guestDefaults.diskRead) ?? 0,
diskWrite: getTriggerValue(config.guestDefaults.diskWrite) ?? 0,
networkIn: getTriggerValue(config.guestDefaults.networkIn) ?? 0,
networkOut: getTriggerValue(config.guestDefaults.networkOut) ?? 0,
diskRead: getTriggerValue(config.guestDefaults.diskRead) ?? -1,
diskWrite: getTriggerValue(config.guestDefaults.diskWrite) ?? -1,
networkIn: getTriggerValue(config.guestDefaults.networkIn) ?? -1,
networkOut: getTriggerValue(config.guestDefaults.networkOut) ?? -1,
});
setGuestDisableConnectivity(Boolean(config.guestDefaults.disableConnectivity));
} else {
@@ -536,6 +536,17 @@ export function Alerts() {
});
}
if (config.dockerDefaults) {
setDockerDefaults({
cpu: getTriggerValue(config.dockerDefaults.cpu) ?? 80,
memory: getTriggerValue(config.dockerDefaults.memory) ?? 85,
restartCount: config.dockerDefaults.restartCount ?? 3,
restartWindow: config.dockerDefaults.restartWindow ?? 300,
memoryWarnPct: config.dockerDefaults.memoryWarnPct ?? 90,
memoryCriticalPct: config.dockerDefaults.memoryCriticalPct ?? 95,
});
}
if (config.storageDefault) {
setStorageDefault(getTriggerValue(config.storageDefault) ?? 85);
}
@@ -558,6 +569,20 @@ export function Alerts() {
});
}
// Load global disable flags
setDisableAllNodes(config.disableAllNodes ?? false);
setDisableAllGuests(config.disableAllGuests ?? false);
setDisableAllStorage(config.disableAllStorage ?? false);
setDisableAllPBS(config.disableAllPBS ?? false);
setDisableAllDockerHosts(config.disableAllDockerHosts ?? false);
setDisableAllDockerContainers(config.disableAllDockerContainers ?? false);
// Load global disable offline alerts flags
setDisableAllNodesOffline(config.disableAllNodesOffline ?? false);
setDisableAllGuestsOffline(config.disableAllGuestsOffline ?? false);
setDisableAllPBSOffline(config.disableAllPBSOffline ?? false);
setDisableAllDockerHostsOffline(config.disableAllDockerHostsOffline ?? false);
setRawOverridesConfig(config.overrides || {});
if (config.schedule) {
@@ -748,10 +773,10 @@ export function Alerts() {
cpu: 80,
memory: 85,
disk: 90,
diskRead: 0,
diskWrite: 0,
networkIn: 0,
networkOut: 0,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
});
const [guestDisableConnectivity, setGuestDisableConnectivity] = createSignal(false);
@@ -762,6 +787,15 @@ export function Alerts() {
temperature: 80,
});
const [dockerDefaults, setDockerDefaults] = createSignal({
cpu: 80,
memory: 85,
restartCount: 3,
restartWindow: 300,
memoryWarnPct: 90,
memoryCriticalPct: 95,
});
const [storageDefault, setStorageDefault] = createSignal(85);
const [timeThreshold, setTimeThreshold] = createSignal(0); // Legacy
const [timeThresholds, setTimeThresholds] = createSignal({
@@ -771,6 +805,20 @@ export function Alerts() {
pbs: 30,
});
// Global disable flags per resource type
const [disableAllNodes, setDisableAllNodes] = createSignal(false);
const [disableAllGuests, setDisableAllGuests] = createSignal(false);
const [disableAllStorage, setDisableAllStorage] = createSignal(false);
const [disableAllPBS, setDisableAllPBS] = createSignal(false);
const [disableAllDockerHosts, setDisableAllDockerHosts] = createSignal(false);
const [disableAllDockerContainers, setDisableAllDockerContainers] = createSignal(false);
// Global disable offline alerts flags
const [disableAllNodesOffline, setDisableAllNodesOffline] = createSignal(false);
const [disableAllGuestsOffline, setDisableAllGuestsOffline] = createSignal(false);
const [disableAllPBSOffline, setDisableAllPBSOffline] = createSignal(false);
const [disableAllDockerHostsOffline, setDisableAllDockerHostsOffline] = createSignal(false);
const tabs: { id: AlertTab; label: string; icon: string }[] = [
{
id: 'overview',
@@ -846,6 +894,18 @@ export function Alerts() {
const alertConfig = {
enabled: true,
// Global disable flags per resource type
disableAllNodes: disableAllNodes(),
disableAllGuests: disableAllGuests(),
disableAllStorage: disableAllStorage(),
disableAllPBS: disableAllPBS(),
disableAllDockerHosts: disableAllDockerHosts(),
disableAllDockerContainers: disableAllDockerContainers(),
// Global disable offline alerts flags
disableAllNodesOffline: disableAllNodesOffline(),
disableAllGuestsOffline: disableAllGuestsOffline(),
disableAllPBSOffline: disableAllPBSOffline(),
disableAllDockerHostsOffline: disableAllDockerHostsOffline(),
guestDefaults: {
cpu: createHysteresisThreshold(guestDefaults().cpu),
memory: createHysteresisThreshold(guestDefaults().memory),
@@ -862,6 +922,14 @@ export function Alerts() {
disk: createHysteresisThreshold(nodeDefaults().disk),
temperature: createHysteresisThreshold(nodeDefaults().temperature),
},
dockerDefaults: {
cpu: createHysteresisThreshold(dockerDefaults().cpu),
memory: createHysteresisThreshold(dockerDefaults().memory),
restartCount: dockerDefaults().restartCount,
restartWindow: dockerDefaults().restartWindow,
memoryWarnPct: dockerDefaults().memoryWarnPct,
memoryCriticalPct: dockerDefaults().memoryCriticalPct,
},
storageDefault: createHysteresisThreshold(storageDefault()),
minimumDelta: 2.0,
suppressionWindow: 5,
@@ -991,6 +1059,8 @@ export function Alerts() {
setGuestDisableConnectivity={setGuestDisableConnectivity}
nodeDefaults={nodeDefaults}
setNodeDefaults={setNodeDefaults}
dockerDefaults={dockerDefaults}
setDockerDefaults={setDockerDefaults}
storageDefault={storageDefault}
setStorageDefault={setStorageDefault}
timeThreshold={timeThreshold}
@@ -999,7 +1069,28 @@ export function Alerts() {
setTimeThresholds={setTimeThresholds}
activeAlerts={activeAlerts}
setHasUnsavedChanges={setHasUnsavedChanges}
hasUnsavedChanges={hasUnsavedChanges}
removeAlerts={removeAlerts}
disableAllNodes={disableAllNodes}
setDisableAllNodes={setDisableAllNodes}
disableAllGuests={disableAllGuests}
setDisableAllGuests={setDisableAllGuests}
disableAllStorage={disableAllStorage}
setDisableAllStorage={setDisableAllStorage}
disableAllPBS={disableAllPBS}
setDisableAllPBS={setDisableAllPBS}
disableAllDockerHosts={disableAllDockerHosts}
setDisableAllDockerHosts={setDisableAllDockerHosts}
disableAllDockerContainers={disableAllDockerContainers}
setDisableAllDockerContainers={setDisableAllDockerContainers}
disableAllNodesOffline={disableAllNodesOffline}
setDisableAllNodesOffline={setDisableAllNodesOffline}
disableAllGuestsOffline={disableAllGuestsOffline}
setDisableAllGuestsOffline={setDisableAllGuestsOffline}
disableAllPBSOffline={disableAllPBSOffline}
setDisableAllPBSOffline={setDisableAllPBSOffline}
disableAllDockerHostsOffline={disableAllDockerHostsOffline}
setDisableAllDockerHostsOffline={setDisableAllDockerHostsOffline}
/>
</Show>
@@ -1397,6 +1488,7 @@ interface ThresholdsTabProps {
state: State;
guestDefaults: () => Record<string, number>;
nodeDefaults: () => Record<string, number>;
dockerDefaults: () => { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number };
storageDefault: () => number;
timeThreshold: () => number;
timeThresholds: () => { guest: number; node: number; storage: number; pbs: number };
@@ -1410,6 +1502,9 @@ interface ThresholdsTabProps {
setNodeDefaults: (
value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>),
) => void;
setDockerDefaults: (
value: { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number } | ((prev: { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number }) => { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number }),
) => void;
setStorageDefault: (value: number) => void;
setTimeThreshold: (value: number) => void;
setTimeThresholds: (value: { guest: number; node: number; storage: number; pbs: number }) => void;
@@ -1417,7 +1512,30 @@ interface ThresholdsTabProps {
setRawOverridesConfig: (value: Record<string, RawOverrideConfig>) => void;
activeAlerts: Record<string, Alert>;
setHasUnsavedChanges: (value: boolean) => void;
hasUnsavedChanges: () => boolean;
removeAlerts: (predicate: (alert: Alert) => boolean) => void;
// Global disable flags
disableAllNodes: () => boolean;
setDisableAllNodes: (value: boolean) => void;
disableAllGuests: () => boolean;
setDisableAllGuests: (value: boolean) => void;
disableAllStorage: () => boolean;
setDisableAllStorage: (value: boolean) => void;
disableAllPBS: () => boolean;
setDisableAllPBS: (value: boolean) => void;
disableAllDockerHosts: () => boolean;
setDisableAllDockerHosts: (value: boolean) => void;
disableAllDockerContainers: () => boolean;
setDisableAllDockerContainers: (value: boolean) => void;
// Global disable offline alerts flags
disableAllNodesOffline: () => boolean;
setDisableAllNodesOffline: (value: boolean) => void;
disableAllGuestsOffline: () => boolean;
setDisableAllGuestsOffline: (value: boolean) => void;
disableAllPBSOffline: () => boolean;
setDisableAllPBSOffline: (value: boolean) => void;
disableAllDockerHostsOffline: () => boolean;
setDisableAllDockerHostsOffline: (value: boolean) => void;
}
function ThresholdsTab(props: ThresholdsTabProps) {
@@ -1439,6 +1557,8 @@ function ThresholdsTab(props: ThresholdsTabProps) {
setGuestDisableConnectivity={props.setGuestDisableConnectivity}
nodeDefaults={props.nodeDefaults()}
setNodeDefaults={props.setNodeDefaults}
dockerDefaults={props.dockerDefaults()}
setDockerDefaults={props.setDockerDefaults}
storageDefault={props.storageDefault}
setStorageDefault={props.setStorageDefault}
timeThreshold={props.timeThreshold}
@@ -1448,6 +1568,26 @@ function ThresholdsTab(props: ThresholdsTabProps) {
setHasUnsavedChanges={props.setHasUnsavedChanges}
activeAlerts={props.activeAlerts}
removeAlerts={props.removeAlerts}
disableAllNodes={props.disableAllNodes}
setDisableAllNodes={props.setDisableAllNodes}
disableAllGuests={props.disableAllGuests}
setDisableAllGuests={props.setDisableAllGuests}
disableAllStorage={props.disableAllStorage}
setDisableAllStorage={props.setDisableAllStorage}
disableAllPBS={props.disableAllPBS}
setDisableAllPBS={props.setDisableAllPBS}
disableAllDockerHosts={props.disableAllDockerHosts}
setDisableAllDockerHosts={props.setDisableAllDockerHosts}
disableAllDockerContainers={props.disableAllDockerContainers}
setDisableAllDockerContainers={props.setDisableAllDockerContainers}
disableAllNodesOffline={props.disableAllNodesOffline}
setDisableAllNodesOffline={props.setDisableAllNodesOffline}
disableAllGuestsOffline={props.disableAllGuestsOffline}
setDisableAllGuestsOffline={props.setDisableAllGuestsOffline}
disableAllPBSOffline={props.disableAllPBSOffline}
setDisableAllPBSOffline={props.setDisableAllPBSOffline}
disableAllDockerHostsOffline={props.disableAllDockerHostsOffline}
setDisableAllDockerHostsOffline={props.setDisableAllDockerHostsOffline}
/>
);
}
+33 -2
View File
@@ -60,6 +60,11 @@ export function createWebSocketStore(url: string) {
const [recentlyResolved, setRecentlyResolved] = createStore<Record<string, ResolvedAlert>>({});
const [updateProgress, setUpdateProgress] = createSignal<unknown>(null);
// Track consecutive empty dockerHost payloads so we can tolerate transient
// blanks without spamming the UI.
let consecutiveEmptyDockerUpdates = 0;
let hasReceivedNonEmptyDockerHosts = false;
// Track alerts with pending acknowledgment changes to prevent race conditions
const pendingAckChanges = new Map<string, { ack: boolean; previousAckTime?: string }>();
@@ -136,6 +141,8 @@ export function createWebSocketStore(url: string) {
setReconnecting(false); // Clear reconnecting state
reconnectAttempt = 0; // Reset reconnect attempts on successful connection
isReconnecting = false;
consecutiveEmptyDockerUpdates = 0;
hasReceivedNonEmptyDockerHosts = false;
// Start heartbeat to keep connection alive
if (heartbeatInterval) {
@@ -239,8 +246,32 @@ export function createWebSocketStore(url: string) {
if (message.data.dockerHosts !== undefined && message.data.dockerHosts !== null) {
// Only update if dockerHosts is present and not null
if (Array.isArray(message.data.dockerHosts)) {
console.log('[WebSocket] Updating dockerHosts:', message.data.dockerHosts.length, 'hosts');
setState('dockerHosts', message.data.dockerHosts);
const incomingHosts = message.data.dockerHosts;
const currentHosts = state.dockerHosts ?? [];
if (incomingHosts.length === 0) {
consecutiveEmptyDockerUpdates += 1;
const shouldApplyEmptyState =
!hasReceivedNonEmptyDockerHosts ||
consecutiveEmptyDockerUpdates >= 3 ||
message.type === WEBSOCKET.MESSAGE_TYPES.INITIAL_STATE;
if (shouldApplyEmptyState) {
console.log('[WebSocket] Updating dockerHosts:', incomingHosts.length, 'hosts');
setState('dockerHosts', incomingHosts);
} else {
console.debug(
'[WebSocket] Skipping transient empty dockerHosts payload',
consecutiveEmptyDockerUpdates,
);
}
} else {
consecutiveEmptyDockerUpdates = 0;
hasReceivedNonEmptyDockerHosts = true;
console.log('[WebSocket] Updating dockerHosts:', incomingHosts.length, 'hosts');
setState('dockerHosts', incomingHosts);
}
} else {
console.warn('[WebSocket] Received non-array dockerHosts:', typeof message.data.dockerHosts);
}
+1
View File
@@ -29,6 +29,7 @@ export interface AlertThresholds {
export type RawOverrideConfig = AlertThresholds & {
disabled?: boolean;
disableConnectivity?: boolean;
// NOTE: To disable individual metrics, set threshold to -1
};
export interface CustomAlertRule {
+385 -9
View File
@@ -227,15 +227,33 @@ type CustomAlertRule struct {
UpdatedAt time.Time `json:"updatedAt"`
}
// DockerThresholdConfig represents Docker-specific alert thresholds
type DockerThresholdConfig struct {
CPU HysteresisThreshold `json:"cpu"` // CPU usage % threshold (default: 80%)
Memory HysteresisThreshold `json:"memory"` // Memory usage % threshold (default: 85%)
RestartCount int `json:"restartCount"` // Number of restarts to trigger alert (default: 3)
RestartWindow int `json:"restartWindow"` // Time window in seconds for restart loop detection (default: 300 = 5min)
MemoryWarnPct int `json:"memoryWarnPct"` // Memory limit % to trigger warning (default: 90)
MemoryCriticalPct int `json:"memoryCriticalPct"` // Memory limit % to trigger critical (default: 95)
}
// AlertConfig represents the complete alert configuration
type AlertConfig struct {
Enabled bool `json:"enabled"`
GuestDefaults ThresholdConfig `json:"guestDefaults"`
NodeDefaults ThresholdConfig `json:"nodeDefaults"`
StorageDefault HysteresisThreshold `json:"storageDefault"`
DockerDefaults DockerThresholdConfig `json:"dockerDefaults"`
Overrides map[string]ThresholdConfig `json:"overrides"` // keyed by resource ID
CustomRules []CustomAlertRule `json:"customRules,omitempty"`
Schedule ScheduleConfig `json:"schedule"`
// Global disable flags per resource type
DisableAllNodes bool `json:"disableAllNodes"` // Disable all alerts for Proxmox nodes
DisableAllGuests bool `json:"disableAllGuests"` // Disable all alerts for VMs/containers
DisableAllStorage bool `json:"disableAllStorage"` // Disable all alerts for storage
DisableAllPBS bool `json:"disableAllPBS"` // Disable all alerts for PBS servers
DisableAllDockerHosts bool `json:"disableAllDockerHosts"` // Disable all alerts for Docker hosts
DisableAllDockerContainers bool `json:"disableAllDockerContainers"` // Disable all alerts for Docker containers
// New configuration options
MinimumDelta float64 `json:"minimumDelta"` // Minimum % change to trigger new alert
SuppressionWindow int `json:"suppressionWindow"` // Minutes to suppress duplicate alerts
@@ -281,6 +299,8 @@ type Manager struct {
offlineConfirmations map[string]int // Track consecutive offline counts for all resources
dockerOfflineCount map[string]int // Track consecutive offline counts for Docker hosts
dockerStateConfirm map[string]int // Track consecutive state confirmations for Docker containers
dockerRestartTracking map[string]*dockerRestartRecord // Track restart counts and times for restart loop detection
dockerLastExitCode map[string]int // Track last exit code for OOM detection
// Persistent acknowledgement state so quick alert rebuilds keep user acknowledgements
ackState map[string]ackRecord
}
@@ -291,6 +311,13 @@ type ackRecord struct {
time time.Time
}
type dockerRestartRecord struct {
count int
lastCount int
times []time.Time // Track restart times for loop detection
lastChecked time.Time
}
// NewManager creates a new alert manager
func NewManager() *Manager {
alertsDir := filepath.Join(utils.GetDataDir(), "alerts")
@@ -305,9 +332,11 @@ func NewManager() *Manager {
pendingAlerts: make(map[string]time.Time),
nodeOfflineCount: make(map[string]int),
offlineConfirmations: make(map[string]int),
dockerOfflineCount: make(map[string]int),
dockerStateConfirm: make(map[string]int),
ackState: make(map[string]ackRecord),
dockerOfflineCount: make(map[string]int),
dockerStateConfirm: make(map[string]int),
dockerRestartTracking: make(map[string]*dockerRestartRecord),
dockerLastExitCode: make(map[string]int),
ackState: make(map[string]ackRecord),
config: AlertConfig{
Enabled: true,
GuestDefaults: ThresholdConfig{
@@ -325,6 +354,14 @@ func NewManager() *Manager {
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
Temperature: &HysteresisThreshold{Trigger: 80, Clear: 75}, // Warning at 80°C, clear at 75°C
},
DockerDefaults: DockerThresholdConfig{
CPU: HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: HysteresisThreshold{Trigger: 85, Clear: 80},
RestartCount: 3,
RestartWindow: 300, // 5 minutes
MemoryWarnPct: 90,
MemoryCriticalPct: 95,
},
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
MinimumDelta: 2.0, // 2% minimum change
SuppressionWindow: 5, // 5 minutes
@@ -449,6 +486,26 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
config.StorageDefault.Clear = 80
}
// Initialize Docker defaults if missing/zero
if config.DockerDefaults.CPU.Trigger <= 0 {
config.DockerDefaults.CPU = HysteresisThreshold{Trigger: 80, Clear: 75}
}
if config.DockerDefaults.Memory.Trigger <= 0 {
config.DockerDefaults.Memory = HysteresisThreshold{Trigger: 85, Clear: 80}
}
if config.DockerDefaults.RestartCount <= 0 {
config.DockerDefaults.RestartCount = 3
}
if config.DockerDefaults.RestartWindow <= 0 {
config.DockerDefaults.RestartWindow = 300 // 5 minutes
}
if config.DockerDefaults.MemoryWarnPct <= 0 {
config.DockerDefaults.MemoryWarnPct = 90
}
if config.DockerDefaults.MemoryCriticalPct <= 0 {
config.DockerDefaults.MemoryCriticalPct = 95
}
// Ensure minimums for other important fields
if config.MinimumDelta <= 0 {
config.MinimumDelta = 2.0
@@ -507,7 +564,12 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
}
}
if alert.Type == "docker-host-offline" || strings.HasPrefix(alertID, "docker-container-health-") || strings.HasPrefix(alertID, "docker-container-state-") {
if alert.Type == "docker-host-offline" ||
strings.HasPrefix(alertID, "docker-container-health-") ||
strings.HasPrefix(alertID, "docker-container-state-") ||
strings.HasPrefix(alertID, "docker-container-restart-loop-") ||
strings.HasPrefix(alertID, "docker-container-oom-") ||
strings.HasPrefix(alertID, "docker-container-memory-limit-") {
// Non-metric Docker alerts are not governed by thresholds
continue
}
@@ -855,6 +917,11 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
log.Debug().Msg("CheckGuest: alerts disabled globally")
return
}
if m.config.DisableAllGuests {
m.mu.RUnlock()
log.Debug().Msg("CheckGuest: all guest alerts disabled")
return
}
m.mu.RUnlock()
var guestID, name, node, guestType, status string
@@ -979,7 +1046,7 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
Interface("thresholds", thresholds).
Msg("Checking guest thresholds")
// Check thresholds
// Check thresholds (checkMetric will skip if threshold is nil or <= 0)
m.checkMetric(guestID, name, node, instanceName, guestType, "cpu", cpu, thresholds.CPU, nil)
m.checkMetric(guestID, name, node, instanceName, guestType, "memory", memUsage, thresholds.Memory, nil)
m.checkMetric(guestID, name, node, instanceName, guestType, "disk", diskUsage, thresholds.Disk, nil)
@@ -1046,7 +1113,7 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
}
}
// Check I/O metrics (convert bytes/s to MB/s)
// Check I/O metrics (convert bytes/s to MB/s) - checkMetric will skip if threshold is nil or <= 0
if thresholds.DiskRead != nil && thresholds.DiskRead.Trigger > 0 {
m.checkMetric(guestID, name, node, instanceName, guestType, "diskRead", float64(diskRead)/1024/1024, thresholds.DiskRead, nil)
}
@@ -1068,6 +1135,10 @@ func (m *Manager) CheckNode(node models.Node) {
m.mu.RUnlock()
return
}
if m.config.DisableAllNodes {
m.mu.RUnlock()
return
}
thresholds := m.config.NodeDefaults
if override, exists := m.config.Overrides[node.ID]; exists {
thresholds = m.applyThresholdOverride(thresholds, override)
@@ -1082,7 +1153,7 @@ func (m *Manager) CheckNode(node models.Node) {
m.clearNodeOfflineAlert(node)
}
// Check each metric (only if node is online)
// Check each metric (only if node is online) - checkMetric will skip if threshold is nil or <= 0
if node.Status != "offline" {
m.checkMetric(node.ID, node.Name, node.Name, node.Instance, "Node", "cpu", node.CPU*100, thresholds.CPU, nil)
m.checkMetric(node.ID, node.Name, node.Name, node.Instance, "Node", "memory", node.Memory.Usage, thresholds.Memory, nil)
@@ -1107,6 +1178,10 @@ func (m *Manager) CheckPBS(pbs models.PBSInstance) {
m.mu.RUnlock()
return
}
if m.config.DisableAllPBS {
m.mu.RUnlock()
return
}
// Check if there's an override for this PBS instance
override, hasOverride := m.config.Overrides[pbs.ID]
@@ -1168,7 +1243,7 @@ func (m *Manager) CheckPBS(pbs models.PBSInstance) {
}
}
// Check metrics only if PBS is online
// Check metrics only if PBS is online - checkMetric will skip if threshold is nil or <= 0
if pbs.Status != "offline" {
// PBS CPU is already a percentage
m.checkMetric(pbs.ID, pbs.Name, pbs.Host, pbs.Name, "PBS", "cpu", pbs.CPU, cpuThreshold, nil)
@@ -1232,10 +1307,14 @@ func (m *Manager) CheckDockerHost(host models.DockerHost) {
m.mu.RLock()
alertsEnabled := m.config.Enabled
disableAllHosts := m.config.DisableAllDockerHosts
m.mu.RUnlock()
if !alertsEnabled {
return
}
if disableAllHosts {
return
}
seen := make(map[string]struct{}, len(host.Containers))
for _, container := range host.Containers {
@@ -1248,12 +1327,21 @@ func (m *Manager) CheckDockerHost(host models.DockerHost) {
}
func (m *Manager) evaluateDockerContainer(host models.DockerHost, container models.DockerContainer, resourceID string) {
m.mu.RLock()
disableAllContainers := m.config.DisableAllDockerContainers
m.mu.RUnlock()
if disableAllContainers {
return
}
containerName := dockerContainerDisplayName(container)
nodeName := strings.TrimSpace(host.Hostname)
instanceName := dockerInstanceName(host)
resourceType := "Docker Container"
m.mu.RLock()
overrideConfig, hasOverride := m.config.Overrides[resourceID]
m.mu.RUnlock()
if hasOverride && overrideConfig.Disabled {
// Alerts disabled via override; clear any existing alerts and skip evaluation.
m.clearDockerContainerStateAlert(resourceID)
@@ -1273,7 +1361,11 @@ func (m *Manager) evaluateDockerContainer(host models.DockerHost, container mode
} else {
m.clearDockerContainerStateAlert(resourceID)
thresholds := m.config.GuestDefaults
// Use Docker-specific defaults for containers
thresholds := ThresholdConfig{
CPU: &m.config.DockerDefaults.CPU,
Memory: &m.config.DockerDefaults.Memory,
}
if hasOverride {
thresholds = m.applyThresholdOverride(thresholds, overrideConfig)
}
@@ -1320,6 +1412,11 @@ func (m *Manager) evaluateDockerContainer(host models.DockerHost, container mode
}
m.checkDockerContainerHealth(host, container, resourceID, containerName, instanceName, nodeName)
// Docker-specific checks
m.checkDockerContainerRestartLoop(host, container, resourceID, containerName, instanceName, nodeName)
m.checkDockerContainerOOMKill(host, container, resourceID, containerName, instanceName, nodeName)
m.checkDockerContainerMemoryLimit(host, container, resourceID, containerName, instanceName, nodeName)
}
// HandleDockerHostOnline clears offline tracking and alerts for a Docker host.
@@ -1584,6 +1681,267 @@ func (m *Manager) clearDockerContainerHealthAlert(resourceID string) {
m.clearAlert(alertID)
}
// checkDockerContainerRestartLoop detects containers stuck in a restart loop
func (m *Manager) checkDockerContainerRestartLoop(host models.DockerHost, container models.DockerContainer, resourceID, containerName, instanceName, nodeName string) {
alertID := fmt.Sprintf("docker-container-restart-loop-%s", resourceID)
now := time.Now()
// Get config values with defaults
restartThreshold := m.config.DockerDefaults.RestartCount
if restartThreshold == 0 {
restartThreshold = 3 // Default: 3 restarts
}
timeWindow := m.config.DockerDefaults.RestartWindow
if timeWindow == 0 {
timeWindow = 300 // Default: 5 minutes (300 seconds)
}
m.mu.Lock()
record, exists := m.dockerRestartTracking[resourceID]
if !exists {
record = &dockerRestartRecord{
count: container.RestartCount,
lastCount: container.RestartCount,
times: []time.Time{},
lastChecked: now,
}
m.dockerRestartTracking[resourceID] = record
m.mu.Unlock()
return
}
// If restart count increased, track it
if container.RestartCount > record.lastCount {
newRestarts := container.RestartCount - record.lastCount
for i := 0; i < newRestarts; i++ {
record.times = append(record.times, now)
}
record.lastCount = container.RestartCount
}
// Clean up old restart times outside the window
cutoff := now.Add(-time.Duration(timeWindow) * time.Second)
var recentRestarts []time.Time
for _, t := range record.times {
if t.After(cutoff) {
recentRestarts = append(recentRestarts, t)
}
}
record.times = recentRestarts
record.lastChecked = now
recentCount := len(record.times)
m.mu.Unlock()
// Check if we have a restart loop
if recentCount > restartThreshold {
level := AlertLevelCritical
alert := &Alert{
ID: alertID,
Type: "docker-container-restart-loop",
Level: level,
ResourceID: resourceID,
ResourceName: containerName,
Node: nodeName,
Instance: instanceName,
Message: fmt.Sprintf("Docker container '%s' has restarted %d times in the last %d minutes (restart loop detected)", containerName, recentCount, timeWindow/60),
StartTime: now,
LastSeen: now,
Metadata: map[string]interface{}{
"hostId": host.ID,
"hostName": host.DisplayName,
"containerId": container.ID,
"containerName": containerName,
"image": container.Image,
"state": container.State,
"status": container.Status,
"restartCount": container.RestartCount,
"recentRestarts": recentCount,
},
}
m.mu.Lock()
if existing, exists := m.activeAlerts[alertID]; exists && existing != nil {
alert.StartTime = existing.StartTime
}
m.preserveAlertState(alertID, alert)
m.activeAlerts[alertID] = alert
m.recentAlerts[alertID] = alert
m.historyManager.AddAlert(*alert)
m.dispatchAlert(alert, false)
m.mu.Unlock()
log.Warn().
Str("container", containerName).
Str("host", host.DisplayName).
Int("restarts", recentCount).
Msg("Docker container restart loop detected")
} else {
// Clear alert if restart loop has stopped
m.clearAlert(alertID)
}
}
// checkDockerContainerOOMKill detects when a container was killed due to out of memory
func (m *Manager) checkDockerContainerOOMKill(host models.DockerHost, container models.DockerContainer, resourceID, containerName, instanceName, nodeName string) {
alertID := fmt.Sprintf("docker-container-oom-%s", resourceID)
// Exit code 137 means the container was killed by SIGKILL, often due to OOM
// Only alert if the container exited (not running) with exit code 137
state := strings.ToLower(strings.TrimSpace(container.State))
if (state == "exited" || state == "dead") && container.ExitCode == 137 {
m.mu.Lock()
lastExitCode, tracked := m.dockerLastExitCode[resourceID]
// Only alert if this is a new OOM kill (exit code changed to 137)
if !tracked || lastExitCode != 137 {
m.dockerLastExitCode[resourceID] = 137
m.mu.Unlock()
level := AlertLevelCritical
alert := &Alert{
ID: alertID,
Type: "docker-container-oom-kill",
Level: level,
ResourceID: resourceID,
ResourceName: containerName,
Node: nodeName,
Instance: instanceName,
Message: fmt.Sprintf("Docker container '%s' was killed due to out of memory (OOM)", containerName),
StartTime: time.Now(),
LastSeen: time.Now(),
Metadata: map[string]interface{}{
"hostId": host.ID,
"hostName": host.DisplayName,
"containerId": container.ID,
"containerName": containerName,
"image": container.Image,
"state": container.State,
"status": container.Status,
"exitCode": container.ExitCode,
"memoryUsageBytes": container.MemoryUsage,
"memoryLimitBytes": container.MemoryLimit,
},
}
m.mu.Lock()
if existing, exists := m.activeAlerts[alertID]; exists && existing != nil {
alert.StartTime = existing.StartTime
}
m.preserveAlertState(alertID, alert)
m.activeAlerts[alertID] = alert
m.recentAlerts[alertID] = alert
m.historyManager.AddAlert(*alert)
m.dispatchAlert(alert, false)
m.mu.Unlock()
log.Error().
Str("container", containerName).
Str("host", host.DisplayName).
Int64("memoryUsage", container.MemoryUsage).
Int64("memoryLimit", container.MemoryLimit).
Msg("Docker container OOM killed")
} else {
m.mu.Unlock()
}
} else {
// Update last exit code if it changed
if container.ExitCode != 0 {
m.mu.Lock()
m.dockerLastExitCode[resourceID] = container.ExitCode
m.mu.Unlock()
}
// Clear OOM alert if container is running or exited with different code
m.clearAlert(alertID)
}
}
// checkDockerContainerMemoryLimit alerts when container approaches its memory limit
func (m *Manager) checkDockerContainerMemoryLimit(host models.DockerHost, container models.DockerContainer, resourceID, containerName, instanceName, nodeName string) {
// Only check if container is running and has a memory limit
state := strings.ToLower(strings.TrimSpace(container.State))
if state != "running" || container.MemoryLimit <= 0 {
return
}
alertID := fmt.Sprintf("docker-container-memory-limit-%s", resourceID)
// Get config values with defaults
warnThreshold := float64(m.config.DockerDefaults.MemoryWarnPct)
if warnThreshold == 0 {
warnThreshold = 90.0 // Default: 90%
}
criticalThreshold := float64(m.config.DockerDefaults.MemoryCriticalPct)
if criticalThreshold == 0 {
criticalThreshold = 95.0 // Default: 95%
}
// Calculate percentage of limit used
limitPercent := (float64(container.MemoryUsage) / float64(container.MemoryLimit)) * 100
if limitPercent >= warnThreshold {
level := AlertLevelWarning
if limitPercent >= criticalThreshold {
level = AlertLevelCritical
}
alert := &Alert{
ID: alertID,
Type: "docker-container-memory-limit",
Level: level,
ResourceID: resourceID,
ResourceName: containerName,
Node: nodeName,
Instance: instanceName,
Message: fmt.Sprintf("Docker container '%s' is using %.1f%% of its memory limit (%d MB / %d MB)", containerName, limitPercent, container.MemoryUsage/(1024*1024), container.MemoryLimit/(1024*1024)),
StartTime: time.Now(),
LastSeen: time.Now(),
Metadata: map[string]interface{}{
"hostId": host.ID,
"hostName": host.DisplayName,
"containerId": container.ID,
"containerName": containerName,
"image": container.Image,
"memoryUsageBytes": container.MemoryUsage,
"memoryLimitBytes": container.MemoryLimit,
"limitPercent": limitPercent,
},
}
m.mu.Lock()
if existing, exists := m.activeAlerts[alertID]; exists && existing != nil {
alert.StartTime = existing.StartTime
existing.LastSeen = time.Now()
existing.Level = level
existing.Message = alert.Message
existing.Metadata = alert.Metadata
m.mu.Unlock()
return
}
m.preserveAlertState(alertID, alert)
m.activeAlerts[alertID] = alert
m.recentAlerts[alertID] = alert
m.historyManager.AddAlert(*alert)
m.dispatchAlert(alert, false)
m.mu.Unlock()
log.Warn().
Str("container", containerName).
Str("host", host.DisplayName).
Float64("limitPercent", limitPercent).
Msg("Docker container approaching memory limit")
} else {
// Clear alert if below warning threshold minus 5% (hysteresis)
clearThreshold := warnThreshold - 5
if limitPercent < clearThreshold {
m.clearAlert(alertID)
}
}
}
func (m *Manager) clearDockerContainerMetricAlerts(resourceID string, metrics ...string) {
if len(metrics) == 0 {
metrics = []string{"cpu", "memory"}
@@ -1651,6 +2009,10 @@ func (m *Manager) CheckStorage(storage models.Storage) {
m.mu.RUnlock()
return
}
if m.config.DisableAllStorage {
m.mu.RUnlock()
return
}
// Check if there's an override for this storage device
override, hasOverride := m.config.Overrides[storage.ID]
@@ -1709,6 +2071,7 @@ func (m *Manager) CheckStorage(storage models.Storage) {
Bool("hasOverride", hasOverride).
Msg("Checking storage thresholds")
// Check usage if storage is online - checkMetric will skip if threshold is nil or <= 0
if storage.Status != "offline" && storage.Status != "unavailable" && storage.Usage > 0 {
m.checkMetric(storage.ID, storage.Name, storage.Node, storage.Instance, "Storage", "usage", storage.Usage, &threshold, nil)
}
@@ -3657,6 +4020,19 @@ func (m *Manager) CleanupAlertsForNodes(existingNodes map[string]bool) {
removedCount := 0
for alertID, alert := range m.activeAlerts {
if alert == nil {
continue
}
// Skip alerts that are not tied to Proxmox nodes. Docker and PBS resources use
// synthetic node identifiers that won't appear in the Proxmox node list, so we
// must preserve their alerts here.
if strings.HasPrefix(alertID, "docker-") || strings.HasPrefix(alert.ResourceID, "docker:") {
continue
}
if strings.HasPrefix(alertID, "pbs-") || alert.Type == "pbs-offline" {
continue
}
// Use the Node field from the alert itself, which is more reliable
node := alert.Node
+28 -6
View File
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
@@ -159,8 +160,15 @@ func (h *AlertHandlers) UnacknowledgeAlert(w http.ResponseWriter, r *http.Reques
return
}
// Extract alert ID by removing the suffix
alertID := strings.TrimSuffix(path, suffix)
// Extract alert ID by removing the suffix and decoding encoded characters
encodedID := strings.TrimSuffix(path, suffix)
alertID, err := url.PathUnescape(encodedID)
if err != nil {
log.Error().Err(err).Str("encodedID", encodedID).Msg("Failed to decode alert ID")
http.Error(w, "Invalid alert ID", http.StatusBadRequest)
return
}
if !validateAlertID(alertID) {
log.Error().
Str("path", r.URL.Path).
@@ -223,8 +231,15 @@ func (h *AlertHandlers) AcknowledgeAlert(w http.ResponseWriter, r *http.Request)
return
}
// Extract alert ID by removing the suffix
alertID := strings.TrimSuffix(path, suffix)
// Extract alert ID by removing the suffix and decoding encoded characters
encodedID := strings.TrimSuffix(path, suffix)
alertID, err := url.PathUnescape(encodedID)
if err != nil {
log.Error().Err(err).Str("encodedID", encodedID).Msg("Failed to decode alert ID")
http.Error(w, "Invalid alert ID", http.StatusBadRequest)
return
}
if !validateAlertID(alertID) {
log.Error().
Str("path", r.URL.Path).
@@ -295,8 +310,15 @@ func (h *AlertHandlers) ClearAlert(w http.ResponseWriter, r *http.Request) {
return
}
// Extract alert ID by removing the suffix
alertID := strings.TrimSuffix(path, suffix)
// Extract alert ID by removing the suffix and decoding encoded characters
encodedID := strings.TrimSuffix(path, suffix)
alertID, err := url.PathUnescape(encodedID)
if err != nil {
log.Error().Err(err).Str("encodedID", encodedID).Msg("Failed to decode alert ID")
http.Error(w, "Invalid alert ID", http.StatusBadRequest)
return
}
if !validateAlertID(alertID) {
log.Error().
Str("path", r.URL.Path).
+32
View File
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
@@ -67,3 +68,34 @@ func (h *DockerAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Reques
log.Error().Err(err).Msg("Failed to serialize docker agent response")
}
}
// HandleDeleteHost removes a docker host and its containers from the shared state.
func (h *DockerAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only DELETE is allowed", nil)
return
}
trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/hosts/")
hostID := strings.TrimSpace(trimmedPath)
if hostID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Docker host ID is required", nil)
return
}
host, err := h.monitor.RemoveDockerHost(hostID)
if err != nil {
writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", err.Error(), nil)
return
}
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"hostId": host.ID,
"message": "Docker host removed",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize docker host removal response")
}
}
+1
View File
@@ -95,6 +95,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/health", r.handleHealth)
r.mux.HandleFunc("/api/state", r.handleState)
r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, dockerAgentHandlers.HandleReport))
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, dockerAgentHandlers.HandleDeleteHost))
r.mux.HandleFunc("/api/version", r.handleVersion)
r.mux.HandleFunc("/api/storage/", r.handleStorage)
r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts)
+24
View File
@@ -741,6 +741,23 @@ func (s *State) UpsertDockerHost(host DockerHost) {
s.LastUpdate = time.Now()
}
// RemoveDockerHost removes a docker host by ID and returns the removed host.
func (s *State) RemoveDockerHost(hostID string) (DockerHost, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, host := range s.DockerHosts {
if host.ID == hostID {
// Remove the host while preserving slice order
s.DockerHosts = append(s.DockerHosts[:i], s.DockerHosts[i+1:]...)
s.LastUpdate = time.Now()
return host, true
}
}
return DockerHost{}, false
}
// SetDockerHostStatus updates the status of a docker host if present.
func (s *State) SetDockerHostStatus(hostID, status string) bool {
s.mu.Lock()
@@ -1064,6 +1081,13 @@ func (s *State) SetConnectionHealth(instanceID string, healthy bool) {
s.ConnectionHealth[instanceID] = healthy
}
// RemoveConnectionHealth removes a connection health entry if it exists.
func (s *State) RemoveConnectionHealth(instanceID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.ConnectionHealth, instanceID)
}
// UpdatePBSBackups updates PBS backups for a specific instance
func (s *State) UpdatePBSBackups(instanceName string, backups []PBSBackup) {
s.mu.Lock()
+25
View File
@@ -139,6 +139,31 @@ const (
dockerMaximumHealthWindow = 10 * time.Minute
)
// RemoveDockerHost removes a docker host from the shared state and clears related alerts.
func (m *Monitor) RemoveDockerHost(hostID string) (models.DockerHost, error) {
hostID = strings.TrimSpace(hostID)
if hostID == "" {
return models.DockerHost{}, fmt.Errorf("docker host id is required")
}
host, removed := m.state.RemoveDockerHost(hostID)
if !removed {
return models.DockerHost{}, fmt.Errorf("docker host %s not found", hostID)
}
m.state.RemoveConnectionHealth(dockerConnectionPrefix + hostID)
if m.alertManager != nil {
m.alertManager.HandleDockerHostOnline(host)
}
log.Info().
Str("dockerHost", host.Hostname).
Str("dockerHostID", hostID).
Msg("Docker host removed from state")
return host, nil
}
// ApplyDockerReport ingests a docker agent report into the shared state.
func (m *Monitor) ApplyDockerReport(report agentsdocker.Report) (models.DockerHost, error) {
identifier := strings.TrimSpace(report.AgentKey())