mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-20 02:09:41 +00:00
Revamp alerts and Docker host management
This commit is contained in:
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
@@ -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 don’t plan to monitor container hosts. We’ll 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 won’t 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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user