feat: enhance alerts system with tests and improved thresholds

- Add comprehensive test coverage for alerts package with 285+ new tests
- Implement ThresholdsTable component with metric thresholds display
- Enhance Alerts page UI with improved layout and metric filtering
- Add frontend component tests for Alerts page and ThresholdsTable
- Set up Vitest testing infrastructure for SolidJS components
- Improve config persistence with better validation
- Expand discovery tests with 333+ test cases
- Update API, configuration, and Docker monitoring documentation
This commit is contained in:
rcourtman 2025-10-15 22:25:04 +00:00
parent 958d6218c2
commit 4838793677
16 changed files with 3691 additions and 90 deletions

View file

@ -657,6 +657,7 @@ Alert configuration responses model Pulse's hysteresis thresholds and advanced b
- `guestDefaults`, `nodeDefaults`, `storageDefault`, `dockerDefaults`, `pmgThresholds` expose the baseline trigger/clear values applied globally. Each metric uses `{ "trigger": 90, "clear": 85 }`, so fractional thresholds (e.g. `12.5`) are supported.
- `overrides` is keyed by resource ID for bespoke thresholds. Setting a threshold to `-1` disables that signal for that resource.
- `timeThresholds` and `metricTimeThresholds` provide per-resource/per-metric grace periods, reducing alert noise on bursty workloads.
- `dockerIgnoredContainerPrefixes` suppresses alerts for ephemeral containers whose name or ID begins with a listed prefix. Matching is case-insensitive and controlled through the Alerts UI.
- `aggregation`, `flapping`, `schedule` configure deduplication, cooldown, and quiet hours. These values are shared with the notification pipeline.
- Active and historical alerts include `metadata.clearThreshold`, `resourceType`, and other context so UIs can render the trigger/clear pair and supply timeline explanations.

View file

@ -200,6 +200,10 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout
"restartCount": 3,
"restartWindow": 300
},
"dockerIgnoredContainerPrefixes": [
"runner-",
"ci-temp-"
],
"pmgThresholds": {
"queueTotalWarning": 500,
"oldestMessageWarnMins": 30
@ -249,6 +253,7 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout
- Set a metric to `-1` to disable it globally or per-resource (the UI shows “Off” and adds a **Custom** badge).
- `timeThresholds` apply a grace period before an alert fires; `metricTimeThresholds` allow per-metric overrides (e.g., delay network alerts longer than CPU).
- `overrides` are indexed by the stable resource ID returned from `/api/state` (VMs: `instance/qemu/vmid`, containers: `instance/lxc/ctid`, nodes: `instance/node`).
- `dockerIgnoredContainerPrefixes` lets you silence state/metric/restart alerts for ephemeral containers whose names or IDs share a common, case-insensitive prefix. The Docker tab in the UI keeps this list in sync.
- Quiet hours, escalation, deduplication, and restart loop detection are all managed here, and the UI keeps the JSON in sync automatically.
> Tip: Back up `alerts.json` alongside `.env` during exports. Restoring it preserves all overrides, quiet-hour schedules, and webhook routing.

View file

@ -137,6 +137,10 @@ docker run -d \
The agent automatically discovers the Docker socket via the usual environment variables. To use SSH tunnels or TCP sockets, export `DOCKER_HOST` as you would for the Docker CLI.
### Suppressing ephemeral containers
CI runners and short-lived build containers can generate noisy state alerts when they exit on schedule. In Pulse v4.24.0 and later you can provide a list of prefixes to ignore under **Alerts → Thresholds → Docker → Ignored container prefixes**. Any container whose name *or* ID begins with a configured prefix is skipped for state, health, metric, restart-loop, and OOM alerts. Matching is case-insensitive and the list is saved as `dockerIgnoredContainerPrefixes` inside `alerts.json`. Use one entry per family of ephemeral containers (for example, `runner-` or `gitlab-job-`).
## Testing and troubleshooting
- Run with `--interval 15s --insecure` in a terminal to see log output while testing.

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,7 @@
"build": "vite build",
"preview": "vite preview",
"generate-types": "cd ../scripts && go run generate-types.go",
"test": "vitest run",
"type-check": "tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
@ -39,11 +40,15 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-solid": "^0.14.0",
"@solidjs/testing-library": "^0.8.5",
"@testing-library/jest-dom": "^6.5.0",
"jsdom": "^24.1.0",
"postcss": "^8.4.0",
"prettier": "^3.3.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",
"vite": "^6.3.5",
"vite-plugin-solid": "^2.8.0"
"vite-plugin-solid": "^2.8.0",
"vitest": "^2.1.9"
}
}

View file

@ -97,6 +97,12 @@ const PMG_KEY_TO_NORMALIZED = new Map(
PMG_THRESHOLD_COLUMNS.map((column) => [column.key, column.normalized]),
);
export const normalizeDockerIgnoredInput = (value: string): string[] =>
value
.split('\n')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
// Simple threshold object for the UI
interface SimpleThresholds {
cpu?: number;
@ -143,11 +149,14 @@ interface ThresholdsTableProps {
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;
dockerIgnoredPrefixes: () => string[];
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
storageDefault: () => number;
setStorageDefault: (value: number) => void;
resetGuestDefaults?: () => void;
resetNodeDefaults?: () => void;
resetDockerDefaults?: () => void;
resetDockerIgnoredPrefixes?: () => void;
resetStorageDefault?: () => void;
factoryGuestDefaults?: Record<string, number | undefined>;
factoryNodeDefaults?: Record<string, number | undefined>;
@ -202,6 +211,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
>({});
const [activeTab, setActiveTab] = createSignal<'proxmox' | 'pmg' | 'docker'>('proxmox');
let searchInputRef: HTMLInputElement | undefined;
const [dockerIgnoredInput, setDockerIgnoredInput] = createSignal(
props.dockerIgnoredPrefixes().join('\n'),
);
createEffect(() => {
setDockerIgnoredInput(props.dockerIgnoredPrefixes().join('\n'));
});
// Determine active tab from URL
const getActiveTabFromRoute = (): 'proxmox' | 'pmg' | 'docker' => {
@ -235,6 +251,23 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
navigate(tabRoutes[tab]);
};
const handleDockerIgnoredChange = (value: string) => {
setDockerIgnoredInput(value);
const normalized = normalizeDockerIgnoredInput(value);
props.setDockerIgnoredPrefixes(normalized);
props.setHasUnsavedChanges(true);
};
const handleResetDockerIgnored = () => {
if (props.resetDockerIgnoredPrefixes) {
props.resetDockerIgnoredPrefixes();
} else {
props.setDockerIgnoredPrefixes([]);
}
setDockerIgnoredInput('');
props.setHasUnsavedChanges(true);
};
// Set up keyboard shortcuts
onMount(() => {
const isEditableElement = (el: HTMLElement | null | undefined): boolean => {
@ -1952,6 +1985,36 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
</Show>
<Show when={activeTab() === 'docker'}>
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Ignored container prefixes
</h3>
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
Containers whose name or ID starts with any prefix below are skipped for Docker
alerts. Enter one prefix per line; matching is case-insensitive.
</p>
</div>
<Show when={(props.dockerIgnoredPrefixes().length ?? 0) > 0}>
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-transparent bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
onClick={handleResetDockerIgnored}
>
Reset
</button>
</Show>
</div>
<textarea
value={dockerIgnoredInput()}
onInput={(event) => handleDockerIgnoredChange(event.currentTarget.value)}
placeholder="runner-"
rows={4}
class="mt-4 w-full rounded-md border border-gray-300 bg-white p-3 text-sm text-gray-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-sky-400 dark:focus:ring-sky-600/40"
/>
</div>
<Show when={hasSection('dockerHosts')}>
<div ref={registerSection('dockerHosts')} class="scroll-mt-24">
<ResourceTable

View file

@ -0,0 +1,216 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, fireEvent, screen, cleanup } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import {
ThresholdsTable,
normalizeDockerIgnoredInput,
} from '../ThresholdsTable';
import type { PMGThresholdDefaults } from '@/types/alerts';
vi.mock('@solidjs/router', () => ({
useNavigate: () => vi.fn(),
useLocation: () => ({ pathname: '/alerts/thresholds/docker' }),
}));
vi.mock('../ResourceTable', () => ({
ResourceTable: (props: { title?: string }) => (
<div data-testid={`resource-table-${props.title ?? 'unnamed'}`} />
),
Resource: () => null,
GroupHeaderMeta: () => null,
}));
afterEach(() => {
cleanup();
});
const DEFAULT_PMG_THRESHOLDS: PMGThresholdDefaults = {
queueTotalWarning: 100,
queueTotalCritical: 200,
oldestMessageWarnMins: 30,
oldestMessageCritMins: 60,
deferredQueueWarn: 50,
deferredQueueCritical: 75,
holdQueueWarn: 25,
holdQueueCritical: 50,
quarantineSpamWarn: 10,
quarantineSpamCritical: 20,
quarantineVirusWarn: 5,
quarantineVirusCritical: 10,
quarantineGrowthWarnPct: 25,
quarantineGrowthWarnMin: 10,
quarantineGrowthCritPct: 50,
quarantineGrowthCritMin: 20,
};
const DEFAULT_DOCKER_DEFAULTS = {
cpu: 80,
memory: 85,
restartCount: 3,
restartWindow: 300,
memoryWarnPct: 90,
memoryCriticalPct: 95,
};
const baseProps = () => ({
overrides: () => [],
setOverrides: vi.fn(),
rawOverridesConfig: () => ({}),
setRawOverridesConfig: vi.fn(),
allGuests: () => [],
nodes: [],
storage: [],
dockerHosts: [],
pbsInstances: [],
pmgInstances: [],
pmgThresholds: () => DEFAULT_PMG_THRESHOLDS,
setPMGThresholds: vi.fn(),
guestDefaults: {},
setGuestDefaults: vi.fn(),
guestDisableConnectivity: () => false,
setGuestDisableConnectivity: vi.fn(),
guestPoweredOffSeverity: () => 'warning' as const,
setGuestPoweredOffSeverity: vi.fn(),
nodeDefaults: {},
setNodeDefaults: vi.fn(),
dockerDefaults: DEFAULT_DOCKER_DEFAULTS,
setDockerDefaults: vi.fn(),
storageDefault: () => 85,
setStorageDefault: vi.fn(),
resetGuestDefaults: vi.fn(),
resetNodeDefaults: vi.fn(),
resetDockerDefaults: vi.fn(),
resetDockerIgnoredPrefixes: undefined as (() => void) | undefined,
resetStorageDefault: vi.fn(),
factoryGuestDefaults: {},
factoryNodeDefaults: {},
factoryDockerDefaults: {},
factoryStorageDefault: 85,
timeThresholds: () => ({ guest: 5, node: 5, storage: 5, pbs: 5 }),
metricTimeThresholds: () => ({}),
setMetricTimeThresholds: vi.fn(),
activeAlerts: {},
removeAlerts: vi.fn(),
disableAllNodes: () => false,
setDisableAllNodes: vi.fn(),
disableAllGuests: () => false,
setDisableAllGuests: vi.fn(),
disableAllStorage: () => false,
setDisableAllStorage: vi.fn(),
disableAllPBS: () => false,
setDisableAllPBS: vi.fn(),
disableAllPMG: () => false,
setDisableAllPMG: vi.fn(),
disableAllDockerHosts: () => false,
setDisableAllDockerHosts: vi.fn(),
disableAllDockerContainers: () => false,
setDisableAllDockerContainers: vi.fn(),
disableAllNodesOffline: () => false,
setDisableAllNodesOffline: vi.fn(),
disableAllGuestsOffline: () => false,
setDisableAllGuestsOffline: vi.fn(),
disableAllPBSOffline: () => false,
setDisableAllPBSOffline: vi.fn(),
disableAllPMGOffline: () => false,
setDisableAllPMGOffline: vi.fn(),
disableAllDockerHostsOffline: () => false,
setDisableAllDockerHostsOffline: vi.fn(),
});
const renderThresholdsTable = (options?: { initialPrefixes?: string[]; includeReset?: boolean }) => {
let setDockerIgnoredPrefixesMock!: ReturnType<typeof vi.fn>;
let resetDockerIgnoredPrefixesMock: ReturnType<typeof vi.fn> | undefined;
let setHasUnsavedChangesMock!: ReturnType<typeof vi.fn>;
let getPrefixes!: () => string[];
const result = render(() => {
const [prefixes, setPrefixes] = createSignal(options?.initialPrefixes ?? []);
getPrefixes = prefixes;
setHasUnsavedChangesMock = vi.fn();
setDockerIgnoredPrefixesMock = vi.fn((next: string[]) => {
setPrefixes(next);
});
resetDockerIgnoredPrefixesMock =
options?.includeReset === false
? undefined
: vi.fn(() => {
setPrefixes([]);
});
const props = {
...baseProps(),
dockerIgnoredPrefixes: () => prefixes(),
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => {
const next = typeof value === 'function' ? value(prefixes()) : value;
setDockerIgnoredPrefixesMock(next);
setPrefixes(next);
},
setHasUnsavedChanges: (value: boolean) => {
setHasUnsavedChangesMock(value);
},
resetDockerIgnoredPrefixes: resetDockerIgnoredPrefixesMock,
};
return <ThresholdsTable {...props} />;
});
return {
...result,
setDockerIgnoredPrefixesMock,
resetDockerIgnoredPrefixesMock,
setHasUnsavedChangesMock,
getPrefixes,
};
};
describe('normalizeDockerIgnoredInput', () => {
it('trims whitespace and removes empty lines', () => {
expect(
normalizeDockerIgnoredInput(' runner- \n\n #system \n\t \njob-'),
).toEqual(['runner-', '#system', 'job-']);
});
it('returns empty array for blank input', () => {
expect(normalizeDockerIgnoredInput(' \n ')).toEqual([]);
});
});
describe('ThresholdsTable docker ignored prefixes', () => {
it('updates prefixes when textarea is edited', () => {
const { setDockerIgnoredPrefixesMock, setHasUnsavedChangesMock, getPrefixes } =
renderThresholdsTable({ includeReset: false });
const textarea = screen.getByPlaceholderText('runner-') as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: ' runner- \n #system \n' } });
expect(setDockerIgnoredPrefixesMock).toHaveBeenCalledWith(['runner-', '#system']);
expect(getPrefixes()).toEqual(['runner-', '#system']);
expect(textarea).toHaveValue('runner-\n#system');
expect(setHasUnsavedChangesMock).toHaveBeenCalledWith(true);
});
it('invokes reset handler and clears prefixes', () => {
const {
resetDockerIgnoredPrefixesMock,
setDockerIgnoredPrefixesMock,
setHasUnsavedChangesMock,
getPrefixes,
} = renderThresholdsTable({ initialPrefixes: ['runner-'], includeReset: true });
const textarea = screen.getByPlaceholderText('runner-') as HTMLTextAreaElement;
expect(textarea).toHaveValue('runner-');
const resetButton = screen.getByRole('button', { name: /reset/i });
fireEvent.click(resetButton);
expect(resetDockerIgnoredPrefixesMock).toHaveBeenCalledTimes(1);
expect(setDockerIgnoredPrefixesMock).not.toHaveBeenCalled();
expect(getPrefixes()).toEqual([]);
expect(textarea).toHaveValue('');
expect(setHasUnsavedChangesMock).toHaveBeenCalledWith(true);
});
});

View file

@ -49,6 +49,52 @@ const ALERT_HEADER_META: Record<AlertTab, { title: string; description: string }
},
};
export const ALERT_TAB_SEGMENTS: Record<AlertTab, string> = {
overview: 'overview',
thresholds: 'thresholds',
destinations: 'destinations',
schedule: 'schedule',
history: 'history',
};
export const pathForTab = (
tab: AlertTab,
segments: Record<AlertTab, string> = ALERT_TAB_SEGMENTS,
): string => {
const segment = segments[tab];
return segment ? `/alerts/${segment}` : '/alerts';
};
export const tabFromPath = (
pathname: string,
segments: Record<AlertTab, string> = ALERT_TAB_SEGMENTS,
): AlertTab => {
const normalizedPath = pathname.replace(/\/+$/, '') || '/alerts';
const parts = normalizedPath.split('/').filter(Boolean);
if (parts[0] !== 'alerts') {
return 'overview';
}
const segment = parts[1] ?? '';
if (!segment) {
return 'overview';
}
const entry = (Object.entries(segments) as [AlertTab, string][])
.find(([, value]) => value === segment);
if (entry) {
return entry[0];
}
if (segment === 'custom-rules') {
return 'thresholds';
}
return 'overview';
};
// Store reference interfaces
interface DestinationsRef {
emailConfig?: () => EmailConfig;
@ -146,7 +192,7 @@ interface EscalationConfig {
const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const createDefaultQuietHours = (): QuietHoursConfig => ({
export const createDefaultQuietHours = (): QuietHoursConfig => ({
enabled: false,
start: '22:00',
end: '08:00',
@ -167,20 +213,20 @@ const createDefaultQuietHours = (): QuietHoursConfig => ({
},
});
const createDefaultCooldown = (): CooldownConfig => ({
export const createDefaultCooldown = (): CooldownConfig => ({
enabled: true,
minutes: 30,
maxAlerts: 3,
});
const createDefaultGrouping = (): GroupingConfig => ({
export const createDefaultGrouping = (): GroupingConfig => ({
enabled: true,
window: 5,
byNode: true,
byGuest: false,
});
const normalizeMetricDelayMap = (
export const normalizeMetricDelayMap = (
input: Record<string, Record<string, number>> | undefined | null,
): Record<string, Record<string, number>> => {
if (!input) return {};
@ -207,11 +253,39 @@ const normalizeMetricDelayMap = (
return normalized;
};
const createDefaultEscalation = (): EscalationConfig => ({
export const createDefaultEscalation = (): EscalationConfig => ({
enabled: false,
levels: [],
});
export const getTriggerValue = (
threshold: number | boolean | HysteresisThreshold | undefined,
): number => {
if (typeof threshold === 'number') {
return threshold; // Legacy format
}
if (typeof threshold === 'boolean') {
return 0;
}
if (threshold && typeof threshold === 'object' && 'trigger' in threshold) {
return threshold.trigger; // New hysteresis format
}
return 0; // Default fallback
};
export const extractTriggerValues = (
thresholds: RawOverrideConfig,
): Record<string, number> => {
const result: Record<string, number> = {};
Object.entries(thresholds).forEach(([key, value]) => {
// Skip non-threshold fields
if (key === 'disabled' || key === 'disableConnectivity' || key === 'poweredOffSeverity') return;
if (typeof value === 'string') return;
result[key] = getTriggerValue(value);
});
return result;
};
const DEFAULT_DELAY_SECONDS = 5;
export function Alerts() {
@ -219,46 +293,6 @@ export function Alerts() {
const navigate = useNavigate();
const location = useLocation();
const tabSegments: Record<AlertTab, string> = {
overview: 'overview',
thresholds: 'thresholds',
destinations: 'destinations',
schedule: 'schedule',
history: 'history',
};
const pathForTab = (tab: AlertTab) => {
const segment = tabSegments[tab];
return segment ? `/alerts/${segment}` : '/alerts';
};
const tabFromPath = (pathname: string): AlertTab => {
const normalizedPath = pathname.replace(/\/+$/, '') || '/alerts';
const segments = normalizedPath.split('/').filter(Boolean);
if (segments[0] !== 'alerts') {
return 'overview';
}
const segment = segments[1] ?? '';
if (!segment) {
return 'overview';
}
const entry = (Object.entries(tabSegments) as [AlertTab, string][])
.find(([, value]) => value === segment);
if (entry) {
return entry[0];
}
if (segment === 'custom-rules') {
return 'thresholds';
}
return 'overview';
};
const [activeTab, setActiveTab] = createSignal<AlertTab>(tabFromPath(location.pathname));
const headerMeta = () =>
@ -665,6 +699,7 @@ export function Alerts() {
memoryCriticalPct: config.dockerDefaults.memoryCriticalPct ?? 95,
});
}
setDockerIgnoredPrefixes(config.dockerIgnoredContainerPrefixes ?? []);
if (config.storageDefault) {
setStorageDefault(getTriggerValue(config.storageDefault) ?? 85);
@ -896,34 +931,6 @@ export function Alerts() {
},
);
// Helper function to extract trigger value from threshold
const getTriggerValue = (
threshold: number | boolean | HysteresisThreshold | undefined,
): number => {
if (typeof threshold === 'number') {
return threshold; // Legacy format
}
if (typeof threshold === 'boolean') {
return 0;
}
if (threshold && typeof threshold === 'object' && 'trigger' in threshold) {
return threshold.trigger; // New hysteresis format
}
return 0; // Default fallback
};
// Helper to extract trigger values for all thresholds
const extractTriggerValues = (thresholds: RawOverrideConfig): Record<string, number> => {
const result: Record<string, number> = {};
Object.entries(thresholds).forEach(([key, value]) => {
// Skip non-threshold fields
if (key === 'disabled' || key === 'disableConnectivity' || key === 'poweredOffSeverity') return;
if (typeof value === 'string') return;
result[key] = getTriggerValue(value);
});
return result;
};
// Factory defaults - constants for reset functionality
const FACTORY_GUEST_DEFAULTS = {
cpu: 80,
@ -961,8 +968,9 @@ export function Alerts() {
const [nodeDefaults, setNodeDefaults] = createSignal<Record<string, number | undefined>>({ ...FACTORY_NODE_DEFAULTS });
const [dockerDefaults, setDockerDefaults] = createSignal({ ...FACTORY_DOCKER_DEFAULTS });
const [dockerIgnoredPrefixes, setDockerIgnoredPrefixes] = createSignal<string[]>([]);
const [storageDefault, setStorageDefault] = createSignal(FACTORY_STORAGE_DEFAULT);
const [storageDefault, setStorageDefault] = createSignal(FACTORY_STORAGE_DEFAULT);
// Reset functions
const resetGuestDefaults = () => {
@ -980,6 +988,11 @@ const [storageDefault, setStorageDefault] = createSignal(FACTORY_STORAGE_DEFAULT
setHasUnsavedChanges(true);
};
const resetDockerIgnoredPrefixes = () => {
setDockerIgnoredPrefixes([]);
setHasUnsavedChanges(true);
};
const resetStorageDefault = () => {
setStorageDefault(FACTORY_STORAGE_DEFAULT);
setHasUnsavedChanges(true);
@ -1145,6 +1158,9 @@ const [timeThresholds, setTimeThresholds] = createSignal({
memoryWarnPct: dockerDefaults().memoryWarnPct,
memoryCriticalPct: dockerDefaults().memoryCriticalPct,
},
dockerIgnoredContainerPrefixes: dockerIgnoredPrefixes()
.map((prefix) => prefix.trim())
.filter((prefix) => prefix.length > 0),
storageDefault: createHysteresisThreshold(storageDefault()),
minimumDelta: 2.0,
suppressionWindow: 5,
@ -1332,11 +1348,14 @@ const [timeThresholds, setTimeThresholds] = createSignal({
setNodeDefaults={setNodeDefaults}
dockerDefaults={dockerDefaults}
setDockerDefaults={setDockerDefaults}
dockerIgnoredPrefixes={dockerIgnoredPrefixes}
setDockerIgnoredPrefixes={setDockerIgnoredPrefixes}
storageDefault={storageDefault}
setStorageDefault={setStorageDefault}
resetGuestDefaults={resetGuestDefaults}
resetNodeDefaults={resetNodeDefaults}
resetDockerDefaults={resetDockerDefaults}
resetDockerIgnoredPrefixes={resetDockerIgnoredPrefixes}
resetStorageDefault={resetStorageDefault}
factoryGuestDefaults={FACTORY_GUEST_DEFAULTS}
factoryNodeDefaults={FACTORY_NODE_DEFAULTS}
@ -1826,6 +1845,7 @@ interface ThresholdsTabProps {
guestDefaults: () => Record<string, number | undefined>;
nodeDefaults: () => Record<string, number | undefined>;
dockerDefaults: () => { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number };
dockerIgnoredPrefixes: () => string[];
storageDefault: () => number;
timeThresholds: () => { guest: number; node: number; storage: number; pbs: number };
metricTimeThresholds: () => Record<string, Record<string, number>>;
@ -1854,6 +1874,7 @@ interface ThresholdsTabProps {
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;
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
setStorageDefault: (value: number) => void;
setMetricTimeThresholds: (
value:
@ -1896,6 +1917,7 @@ interface ThresholdsTabProps {
resetGuestDefaults?: () => void;
resetNodeDefaults?: () => void;
resetDockerDefaults?: () => void;
resetDockerIgnoredPrefixes?: () => void;
resetStorageDefault?: () => void;
factoryGuestDefaults?: Record<string, number | undefined>;
factoryNodeDefaults?: Record<string, number | undefined>;
@ -1929,6 +1951,8 @@ function ThresholdsTab(props: ThresholdsTabProps) {
setNodeDefaults={props.setNodeDefaults}
dockerDefaults={props.dockerDefaults()}
setDockerDefaults={props.setDockerDefaults}
dockerIgnoredPrefixes={props.dockerIgnoredPrefixes}
setDockerIgnoredPrefixes={props.setDockerIgnoredPrefixes}
storageDefault={props.storageDefault}
setStorageDefault={props.setStorageDefault}
timeThresholds={props.timeThresholds}
@ -1964,6 +1988,7 @@ function ThresholdsTab(props: ThresholdsTabProps) {
resetGuestDefaults={props.resetGuestDefaults}
resetNodeDefaults={props.resetNodeDefaults}
resetDockerDefaults={props.resetDockerDefaults}
resetDockerIgnoredPrefixes={props.resetDockerIgnoredPrefixes}
resetStorageDefault={props.resetStorageDefault}
factoryGuestDefaults={props.factoryGuestDefaults}
factoryNodeDefaults={props.factoryNodeDefaults}

View file

@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest';
import {
ALERT_TAB_SEGMENTS,
createDefaultCooldown,
createDefaultEscalation,
createDefaultGrouping,
createDefaultQuietHours,
extractTriggerValues,
getTriggerValue,
normalizeMetricDelayMap,
pathForTab,
tabFromPath,
} from '../Alerts';
describe('normalizeMetricDelayMap', () => {
it('returns empty object when input is nullish', () => {
expect(normalizeMetricDelayMap(undefined)).toEqual({});
// @ts-expect-error - testing runtime null handling
expect(normalizeMetricDelayMap(null)).toEqual({});
});
it('normalizes resource and metric keys while discarding invalid values', () => {
const input = {
Guest: {
CPU: 10,
' ': 5,
memory: -1,
disk: Number.NaN,
},
node: {
Temperature: 30,
disk: 15.6,
},
' ': {
metric: 5,
},
};
const result = normalizeMetricDelayMap(input);
expect(result).toEqual({
guest: {
cpu: 10,
},
node: {
temperature: 30,
disk: 16,
},
});
});
it('drops metric groups that normalize to empty', () => {
const result = normalizeMetricDelayMap({
guest: {
cpu: -1,
mem: Number.NaN,
},
});
expect(result).toEqual({});
});
});
describe('tab path helpers', () => {
it('maps tab to path', () => {
expect(pathForTab('overview')).toBe('/alerts/overview');
expect(pathForTab('schedule')).toBe('/alerts/schedule');
});
it('resolves tab from path', () => {
expect(tabFromPath('/alerts')).toBe('overview');
expect(tabFromPath('/alerts/thresholds')).toBe('thresholds');
expect(tabFromPath('/alerts/thresholds/proxmox')).toBe('thresholds');
expect(tabFromPath('/alerts/custom-rules')).toBe('thresholds');
expect(tabFromPath('/foo/bar')).toBe('overview');
});
it('allows custom segments map', () => {
const custom = { ...ALERT_TAB_SEGMENTS, overview: 'summary' as const };
expect(pathForTab('overview', custom)).toBe('/alerts/summary');
expect(tabFromPath('/alerts/summary', custom)).toBe('overview');
});
});
describe('default schedule helpers', () => {
it('creates quiet hours defaults', () => {
const quiet = createDefaultQuietHours();
const expectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
expect(quiet).toMatchObject({
enabled: false,
start: '22:00',
end: '08:00',
suppress: {
performance: false,
storage: false,
offline: false,
},
});
expect(quiet.timezone).toBe(expectedTz);
expect(quiet.days).toEqual({
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
sunday: false,
});
});
it('creates cooldown defaults', () => {
expect(createDefaultCooldown()).toEqual({
enabled: true,
minutes: 30,
maxAlerts: 3,
});
});
it('creates grouping defaults', () => {
expect(createDefaultGrouping()).toEqual({
enabled: true,
window: 5,
byNode: true,
byGuest: false,
});
});
it('creates escalation defaults', () => {
expect(createDefaultEscalation()).toEqual({
enabled: false,
levels: [],
});
});
});
describe('threshold helper utilities', () => {
it('extracts trigger values and ignores non-threshold keys', () => {
const result = extractTriggerValues({
cpu: { trigger: 80, clear: 70 },
memory: 85,
disabled: true,
poweredOffSeverity: 'warning',
networkIn: false,
label: 'ignored',
});
expect(result).toEqual({
cpu: 80,
memory: 85,
networkIn: 0,
});
});
it('getTriggerValue handles multiple input shapes', () => {
expect(getTriggerValue(75)).toBe(75);
expect(getTriggerValue({ trigger: 90, clear: 80 })).toBe(90);
expect(getTriggerValue(true)).toBe(0);
expect(getTriggerValue(undefined)).toBe(0);
});
});

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View file

@ -91,6 +91,7 @@ export interface AlertConfig {
nodeDefaults: AlertThresholds;
storageDefault: HysteresisThreshold;
dockerDefaults?: DockerThresholdConfig;
dockerIgnoredContainerPrefixes?: string[];
pmgDefaults?: PMGThresholdDefaults;
customRules?: CustomAlertRule[];
overrides: Record<string, RawOverrideConfig>; // key: resource ID

View file

@ -285,15 +285,16 @@ type PMGThresholdConfig struct {
// 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"`
PMGDefaults PMGThresholdConfig `json:"pmgDefaults"`
Overrides map[string]ThresholdConfig `json:"overrides"` // keyed by resource ID
CustomRules []CustomAlertRule `json:"customRules,omitempty"`
Schedule ScheduleConfig `json:"schedule"`
Enabled bool `json:"enabled"`
GuestDefaults ThresholdConfig `json:"guestDefaults"`
NodeDefaults ThresholdConfig `json:"nodeDefaults"`
StorageDefault HysteresisThreshold `json:"storageDefault"`
DockerDefaults DockerThresholdConfig `json:"dockerDefaults"`
DockerIgnoredContainerPrefixes []string `json:"dockerIgnoredContainerPrefixes,omitempty"`
PMGDefaults PMGThresholdConfig `json:"pmgDefaults"`
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
@ -722,6 +723,7 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
if delay, ok := config.TimeThresholds["all"]; ok && delay <= 0 {
config.TimeThresholds["all"] = defaultDelaySeconds
}
config.DockerIgnoredContainerPrefixes = NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)
config.GuestDefaults.PoweredOffSeverity = normalizePoweredOffSeverity(config.GuestDefaults.PoweredOffSeverity)
config.NodeDefaults.PoweredOffSeverity = normalizePoweredOffSeverity(config.NodeDefaults.PoweredOffSeverity)
@ -783,6 +785,37 @@ func NormalizeMetricTimeThresholds(input map[string]map[string]int) map[string]m
return normalizeMetricTimeThresholds(input)
}
// NormalizeDockerIgnoredPrefixes trims, deduplicates, and lowercases comparison keys for ignored Docker containers.
// Returned values retain the user's original casing for display but guarantee uniqueness when compared case-insensitively.
func NormalizeDockerIgnoredPrefixes(prefixes []string) []string {
if len(prefixes) == 0 {
return nil
}
seen := make(map[string]struct{}, len(prefixes))
normalized := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
trimmed := strings.TrimSpace(prefix)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, trimmed)
}
if len(normalized) == 0 {
return nil
}
return normalized
}
// applyGlobalOfflineSettingsLocked clears tracking and active alerts for globally disabled offline detectors.
// Caller must hold m.mu.
func (m *Manager) applyGlobalOfflineSettingsLocked() {
@ -1814,6 +1847,30 @@ func dockerResourceID(hostID, containerID string) string {
return fmt.Sprintf("docker:%s/%s", hostID, containerID)
}
func matchesDockerIgnoredPrefix(name, id string, prefixes []string) bool {
if len(prefixes) == 0 {
return false
}
name = strings.ToLower(strings.TrimSpace(name))
id = strings.ToLower(strings.TrimSpace(id))
for _, raw := range prefixes {
prefix := strings.ToLower(strings.TrimSpace(raw))
if prefix == "" {
continue
}
if name != "" && strings.HasPrefix(name, prefix) {
return true
}
if id != "" && strings.HasPrefix(id, prefix) {
return true
}
}
return false
}
// CheckDockerHost evaluates Docker host telemetry and container metrics for alerts.
func (m *Manager) CheckDockerHost(host models.DockerHost) {
if host.ID == "" {
@ -1826,6 +1883,7 @@ func (m *Manager) CheckDockerHost(host models.DockerHost) {
m.mu.RLock()
alertsEnabled := m.config.Enabled
disableAllHosts := m.config.DisableAllDockerHosts
ignoredPrefixes := append([]string(nil), m.config.DockerIgnoredContainerPrefixes...)
m.mu.RUnlock()
if !alertsEnabled {
return
@ -1836,7 +1894,27 @@ func (m *Manager) CheckDockerHost(host models.DockerHost) {
seen := make(map[string]struct{}, len(host.Containers))
for _, container := range host.Containers {
containerName := dockerContainerDisplayName(container)
resourceID := dockerResourceID(host.ID, container.ID)
if matchesDockerIgnoredPrefix(containerName, container.ID, ignoredPrefixes) {
log.Debug().
Str("container", containerName).
Str("host", host.DisplayName).
Msg("Skipping Docker container alert evaluation due to ignored prefix")
m.clearDockerContainerStateAlert(resourceID)
m.clearDockerContainerHealthAlert(resourceID)
m.clearDockerContainerMetricAlerts(resourceID)
m.clearAlert(fmt.Sprintf("docker-container-restart-loop-%s", resourceID))
m.clearAlert(fmt.Sprintf("docker-container-oom-%s", resourceID))
m.clearAlert(fmt.Sprintf("docker-container-memory-limit-%s", resourceID))
m.mu.Lock()
delete(m.dockerRestartTracking, resourceID)
delete(m.dockerLastExitCode, resourceID)
m.mu.Unlock()
continue
}
seen[resourceID] = struct{}{}
m.evaluateDockerContainer(host, container, resourceID)
}

View file

@ -1,6 +1,8 @@
package alerts
import (
"fmt"
"reflect"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
@ -82,3 +84,286 @@ func TestHandleDockerHostRemovedClearsAlertsAndTracking(t *testing.T) {
t.Fatalf("expected last exit code tracking to be cleared")
}
}
func TestCheckDockerHostIgnoresContainersByPrefix(t *testing.T) {
m := NewManager()
m.mu.Lock()
m.config.DockerIgnoredContainerPrefixes = []string{"runner-"}
m.mu.Unlock()
container := models.DockerContainer{
ID: "1234567890ab",
Name: "runner-auto-1",
State: "exited",
Status: "Exited (0) 3 seconds ago",
}
host := models.DockerHost{
ID: "host-ephemeral",
Hostname: "ci-host",
DisplayName: "CI Host",
Containers: []models.DockerContainer{container},
}
resourceID := dockerResourceID(host.ID, container.ID)
alertID := fmt.Sprintf("docker-container-state-%s", resourceID)
// Run twice to satisfy the confirmation threshold when not ignored
m.CheckDockerHost(host)
m.CheckDockerHost(host)
if _, exists := m.activeAlerts[alertID]; exists {
t.Fatalf("expected no state alert for ignored container")
}
if _, exists := m.dockerStateConfirm[resourceID]; exists {
t.Fatalf("expected no state confirmation tracking for ignored container")
}
}
func TestNormalizeDockerIgnoredPrefixes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input []string
expected []string
}{
{
name: "nil input",
input: nil,
expected: nil,
},
{
name: "blank entries removed",
input: []string{"", " ", "\t"},
expected: nil,
},
{
name: "trims and deduplicates preserving first occurrence casing",
input: []string{" Foo ", "foo", "Bar", " bar ", "Baz"},
expected: []string{"Foo", "Bar", "Baz"},
},
{
name: "already normalized list remains unchanged",
input: []string{"alpha", "beta"},
expected: []string{"alpha", "beta"},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := NormalizeDockerIgnoredPrefixes(tc.input)
if !reflect.DeepEqual(got, tc.expected) {
t.Fatalf("expected %v, got %v", tc.expected, got)
}
})
}
}
func TestCheckDockerHostIgnoredPrefixClearsExistingAlerts(t *testing.T) {
m := NewManager()
container := models.DockerContainer{
ID: "abc123456789",
Name: "runner-job-1",
State: "exited",
Status: "Exited (1) 10 seconds ago",
}
host := models.DockerHost{
ID: "docker-host",
DisplayName: "Docker Host",
Hostname: "docker-host.local",
Containers: []models.DockerContainer{container},
}
resourceID := dockerResourceID(host.ID, container.ID)
stateAlertID := fmt.Sprintf("docker-container-state-%s", resourceID)
healthAlertID := fmt.Sprintf("docker-container-health-%s", resourceID)
restartAlertID := fmt.Sprintf("docker-container-restart-loop-%s", resourceID)
m.mu.Lock()
m.config.Enabled = true
m.config.DockerIgnoredContainerPrefixes = []string{"runner-"}
m.activeAlerts[stateAlertID] = &Alert{ID: stateAlertID, ResourceID: resourceID}
m.activeAlerts[healthAlertID] = &Alert{ID: healthAlertID, ResourceID: resourceID}
m.activeAlerts[restartAlertID] = &Alert{ID: restartAlertID, ResourceID: resourceID}
m.dockerStateConfirm[resourceID] = 2
m.dockerRestartTracking[resourceID] = &dockerRestartRecord{}
m.dockerLastExitCode[resourceID] = 137
m.mu.Unlock()
m.CheckDockerHost(host)
m.mu.RLock()
defer m.mu.RUnlock()
if _, exists := m.activeAlerts[stateAlertID]; exists {
t.Fatalf("expected state alert cleared for ignored container")
}
if _, exists := m.activeAlerts[healthAlertID]; exists {
t.Fatalf("expected health alert cleared for ignored container")
}
if _, exists := m.activeAlerts[restartAlertID]; exists {
t.Fatalf("expected restart alert cleared for ignored container")
}
if _, exists := m.dockerStateConfirm[resourceID]; exists {
t.Fatalf("expected state confirmation tracking cleared")
}
if _, exists := m.dockerRestartTracking[resourceID]; exists {
t.Fatalf("expected restart tracking cleared")
}
if _, exists := m.dockerLastExitCode[resourceID]; exists {
t.Fatalf("expected last exit code cleared")
}
}
func TestUpdateConfigNormalizesDockerIgnoredPrefixes(t *testing.T) {
t.Parallel()
t.Run("nil input remains nil", func(t *testing.T) {
t.Parallel()
m := NewManager()
m.UpdateConfig(AlertConfig{})
m.mu.RLock()
defer m.mu.RUnlock()
if m.config.DockerIgnoredContainerPrefixes != nil {
t.Fatalf("expected nil prefixes, got %v", m.config.DockerIgnoredContainerPrefixes)
}
})
t.Run("duplicates trimmed and deduplicated", func(t *testing.T) {
t.Parallel()
m := NewManager()
cfg := AlertConfig{
DockerIgnoredContainerPrefixes: []string{
" Foo ",
"foo",
"Bar",
},
}
m.UpdateConfig(cfg)
m.mu.RLock()
defer m.mu.RUnlock()
expected := []string{"Foo", "Bar"}
if !reflect.DeepEqual(m.config.DockerIgnoredContainerPrefixes, expected) {
t.Fatalf("expected normalized prefixes %v, got %v", expected, m.config.DockerIgnoredContainerPrefixes)
}
})
}
func TestMatchesDockerIgnoredPrefix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
containerName string
containerID string
prefixes []string
want bool
}{
{name: "empty prefixes", containerName: "runner-123", containerID: "abc", prefixes: nil, want: false},
{name: "match with name", containerName: "runner-123", containerID: "abc", prefixes: []string{"runner-"}, want: true},
{name: "match with id", containerName: "app", containerID: "abc123", prefixes: []string{"abc"}, want: true},
{name: "trimmed comparison", containerName: "runner-job", containerID: "abc", prefixes: []string{" runner- "}, want: true},
{name: "case insensitive", containerName: "Runner-Job", containerID: "abc", prefixes: []string{"runner-"}, want: true},
{name: "no match", containerName: "service", containerID: "xyz", prefixes: []string{"runner-"}, want: false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := matchesDockerIgnoredPrefix(tc.containerName, tc.containerID, tc.prefixes); got != tc.want {
t.Fatalf("matchesDockerIgnoredPrefix(%q, %q, %v) = %v, want %v", tc.containerName, tc.containerID, tc.prefixes, got, tc.want)
}
})
}
}
func TestDockerInstanceName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
host models.DockerHost
want string
}{
{name: "uses display name", host: models.DockerHost{DisplayName: "Prod Host"}, want: "Docker:Prod Host"},
{name: "falls back to hostname", host: models.DockerHost{Hostname: "docker.local"}, want: "Docker:docker.local"},
{name: "defaults when empty", host: models.DockerHost{}, want: "Docker"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := dockerInstanceName(tc.host); got != tc.want {
t.Fatalf("dockerInstanceName(%+v) = %q, want %q", tc.host, got, tc.want)
}
})
}
}
func TestDockerContainerDisplayName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
container models.DockerContainer
want string
}{
{name: "trims whitespace", container: models.DockerContainer{Name: " app "}, want: "app"},
{name: "strips leading slash", container: models.DockerContainer{Name: "/runner"}, want: "runner"},
{name: "falls back to id truncated", container: models.DockerContainer{ID: "0123456789abcdef"}, want: "0123456789ab"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := dockerContainerDisplayName(tc.container); got != tc.want {
t.Fatalf("dockerContainerDisplayName(%+v) = %q, want %q", tc.container, got, tc.want)
}
})
}
}
func TestDockerResourceID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
hostID string
containerID string
want string
}{
{name: "both ids present", hostID: "host1", containerID: "abc", want: "docker:host1/abc"},
{name: "missing host id", hostID: "", containerID: "abc", want: "docker:container/abc"},
{name: "missing container id", hostID: "host1", containerID: "", want: "docker:host1"},
{name: "both missing", hostID: "", containerID: "", want: "docker:unknown"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := dockerResourceID(tc.hostID, tc.containerID); got != tc.want {
t.Fatalf("dockerResourceID(%q, %q) = %q, want %q", tc.hostID, tc.containerID, got, tc.want)
}
})
}
}

View file

@ -183,6 +183,7 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
if delay, ok := config.TimeThresholds["all"]; ok && delay <= 0 {
config.TimeThresholds["all"] = config.TimeThreshold
}
config.DockerIgnoredContainerPrefixes = alerts.NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
@ -285,6 +286,7 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
config.TimeThresholds["all"] = config.TimeThreshold
}
config.MetricTimeThresholds = alerts.NormalizeMetricTimeThresholds(config.MetricTimeThresholds)
config.DockerIgnoredContainerPrefixes = alerts.NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)
// Migration: Set I/O metrics to Off (0) if they have the old default values
// This helps existing users avoid noisy I/O alerts

View file

@ -1,6 +1,10 @@
package config_test
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
@ -84,3 +88,88 @@ func TestSaveAlertConfig_DoesNotOverwriteExistingClear(t *testing.T) {
t.Fatalf("clear threshold changed unexpectedly: got %v want %v", got, want)
}
}
func TestAlertConfigPersistenceNormalizesDockerIgnoredPrefixes(t *testing.T) {
tempDir := t.TempDir()
cp := config.NewConfigPersistence(tempDir)
if err := cp.EnsureConfigDir(); err != nil {
t.Fatalf("EnsureConfigDir: %v", err)
}
cfg := alerts.AlertConfig{
Enabled: true,
StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80},
DockerIgnoredContainerPrefixes: []string{
" Foo ",
"foo",
"Bar",
" bar ",
},
}
if err := cp.SaveAlertConfig(cfg); err != nil {
t.Fatalf("SaveAlertConfig: %v", err)
}
loaded, err := cp.LoadAlertConfig()
if err != nil {
t.Fatalf("LoadAlertConfig: %v", err)
}
expected := []string{"Foo", "Bar"}
if !reflect.DeepEqual(loaded.DockerIgnoredContainerPrefixes, expected) {
t.Fatalf("unexpected prefixes: got %v want %v", loaded.DockerIgnoredContainerPrefixes, expected)
}
}
func TestLoadAlertConfigAppliesDefaults(t *testing.T) {
tempDir := t.TempDir()
cp := config.NewConfigPersistence(tempDir)
if err := cp.EnsureConfigDir(); err != nil {
t.Fatalf("EnsureConfigDir: %v", err)
}
raw := alerts.AlertConfig{
Enabled: false,
TimeThreshold: 0,
TimeThresholds: map[string]int{"guest": 0, "node": 0},
DockerIgnoredContainerPrefixes: []string{" Runner "},
NodeDefaults: alerts.ThresholdConfig{
Temperature: &alerts.HysteresisThreshold{Trigger: 0, Clear: 0},
},
}
data, err := json.Marshal(raw)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if err := os.WriteFile(filepath.Join(tempDir, "alerts.json"), data, 0600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
loaded, err := cp.LoadAlertConfig()
if err != nil {
t.Fatalf("LoadAlertConfig: %v", err)
}
if loaded.TimeThreshold != 5 {
t.Fatalf("expected time threshold default 5, got %d", loaded.TimeThreshold)
}
if got := loaded.TimeThresholds["guest"]; got != 5 {
t.Fatalf("expected guest threshold default 5, got %d", got)
}
if got := loaded.TimeThresholds["node"]; got != 5 {
t.Fatalf("expected node threshold default 5, got %d", got)
}
if loaded.NodeDefaults.Temperature == nil {
t.Fatalf("expected node temperature defaults to be set")
}
if loaded.NodeDefaults.Temperature.Trigger != 80 || loaded.NodeDefaults.Temperature.Clear != 75 {
t.Fatalf("expected temperature defaults 80/75, got %+v", loaded.NodeDefaults.Temperature)
}
expectedPrefixes := []string{"Runner"}
if !reflect.DeepEqual(loaded.DockerIgnoredContainerPrefixes, expectedPrefixes) {
t.Fatalf("expected normalized prefixes %v, got %v", expectedPrefixes, loaded.DockerIgnoredContainerPrefixes)
}
}

View file

@ -1,6 +1,20 @@
package discovery
import "testing"
import (
"context"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
)
func TestInferTypeFromMetadata(t *testing.T) {
t.Parallel()
@ -46,3 +60,320 @@ func TestInferTypeFromMetadata(t *testing.T) {
})
}
}
func TestInferTypeFromCertificate(t *testing.T) {
t.Parallel()
state := tls.ConnectionState{
PeerCertificates: []*x509.Certificate{
{
Subject: pkix.Name{
CommonName: "Proxmox Mail Gateway",
Organization: []string{"Proxmox"},
OrganizationalUnit: []string{"PMG"},
},
},
},
}
if got := inferTypeFromCertificate(state); got != "pmg" {
t.Fatalf("inferTypeFromCertificate returned %q, want %q", got, "pmg")
}
if got := inferTypeFromCertificate(tls.ConnectionState{}); got != "" {
t.Fatalf("expected empty result for missing certificates, got %q", got)
}
}
func TestDetectProductFromEndpoint(t *testing.T) {
t.Parallel()
var requestPaths []string
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestPaths = append(requestPaths, r.URL.Path)
switch {
case strings.Contains(r.URL.Path, "statistics/mail"):
w.Header().Set("Proxmox-Product", "Proxmox Mail Gateway")
w.WriteHeader(http.StatusOK)
case strings.Contains(r.URL.Path, "api2/json/version"):
w.Header().Set("Proxmox-Product", "Proxmox Backup Server")
w.WriteHeader(http.StatusOK)
case strings.Contains(r.URL.Path, "mail/queue"):
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer ts.Close()
scanner := &Scanner{
timeout: time.Second,
httpClient: ts.Client(),
}
address := strings.TrimPrefix(ts.URL, "https://")
if product := scanner.detectProductFromEndpoint(context.Background(), address, "api2/json/statistics/mail"); product != "pmg" {
t.Fatalf("detectProductFromEndpoint returned %q, want %q", product, "pmg")
}
if product := scanner.detectProductFromEndpoint(context.Background(), address, "api2/json/version"); product != "pbs" {
t.Fatalf("detectProductFromEndpoint returned %q, want %q", product, "pbs")
}
if product := scanner.detectProductFromEndpoint(context.Background(), address, "api2/json/unknown/path"); product != "" {
t.Fatalf("expected empty result for unknown endpoint, got %q", product)
}
if len(requestPaths) == 0 {
t.Fatalf("expected detectProductFromEndpoint to perform requests")
}
}
func TestIsPMGServer(t *testing.T) {
t.Parallel()
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "statistics/mail") {
w.Header().Set("Proxmox-Product", "Proxmox Mail Gateway")
w.WriteHeader(http.StatusOK)
return
}
http.NotFound(w, r)
}))
defer ts.Close()
scanner := &Scanner{
timeout: time.Second,
httpClient: ts.Client(),
}
address := strings.TrimPrefix(ts.URL, "https://")
if !scanner.isPMGServer(context.Background(), address) {
t.Fatalf("expected PMG detection to succeed")
}
tsNoMatch := httptest.NewTLSServer(http.NotFoundHandler())
defer tsNoMatch.Close()
scanner.httpClient = tsNoMatch.Client()
address = strings.TrimPrefix(tsNoMatch.URL, "https://")
if scanner.isPMGServer(context.Background(), address) {
t.Fatalf("expected PMG detection to fail for endpoints without markers")
}
}
func TestCheckServerRetrievesVersion(t *testing.T) {
t.Parallel()
const responseVersion = `{"data":{"version":"2.4.1","release":"1"}}`
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api2/json/version" {
w.Header().Set("Content-Type", "application/json")
http.SetCookie(w, &http.Cookie{Name: "PBSCookie", Value: "abc"})
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(responseVersion))
return
}
http.NotFound(w, r)
}))
defer ts.Close()
host, portStr, err := net.SplitHostPort(ts.Listener.Addr().String())
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("strconv.Atoi: %v", err)
}
scanner := &Scanner{
timeout: time.Second,
httpClient: ts.Client(),
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
server := scanner.checkServer(ctx, host, port, "pbs")
if server == nil {
t.Fatalf("checkServer returned nil")
}
if server.Type != "pbs" {
t.Fatalf("expected type pbs, got %q", server.Type)
}
if server.Version != "2.4.1" {
t.Fatalf("expected version 2.4.1, got %q", server.Version)
}
if server.Release != "1" {
t.Fatalf("expected release 1, got %q", server.Release)
}
}
func startTLSServerOn(t *testing.T, addr string, handler http.Handler) *httptest.Server {
t.Helper()
srv := httptest.NewUnstartedServer(handler)
ln, err := net.Listen("tcp", addr)
if err != nil {
t.Skipf("port %s unavailable: %v", addr, err)
}
srv.Listener = ln
srv.StartTLS()
t.Cleanup(func() { srv.Close() })
return srv
}
func TestCheckServerHandlesUnauthorized(t *testing.T) {
t.Parallel()
unauthorizedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", "PVEAuth realm=\"Proxmox Virtual Environment\"")
w.WriteHeader(http.StatusUnauthorized)
})
srv := startTLSServerOn(t, "127.0.0.1:9008", unauthorizedHandler)
_ = srv
scanner := &Scanner{
timeout: time.Second,
httpClient: &http.Client{
Timeout: 500 * time.Millisecond,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
},
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
server := scanner.checkServer(ctx, "127.0.0.1", 9008, "pve")
if server == nil {
t.Fatalf("expected server discovery despite unauthorized response")
}
if server.Type != "pve" {
t.Fatalf("expected type pve, got %q", server.Type)
}
if server.Version != "Unknown" {
t.Fatalf("expected version Unknown, got %q", server.Version)
}
}
func TestDiscoverServersWithCallback(t *testing.T) {
t.Parallel()
const subnet = "127.0.0.0/29"
noTLSListener, err := net.Listen("tcp", "127.0.0.1:9009")
if err != nil {
t.Fatalf("failed to listen on 9009: %v", err)
}
go func() {
for {
conn, err := noTLSListener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
t.Cleanup(func() { noTLSListener.Close() })
pveHandler := http.NewServeMux()
pveHandler.HandleFunc("/api2/json/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Proxmox-Product", "Proxmox Virtual Environment")
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]string{
"version": "8.1",
"release": "1",
},
})
})
pbsHandler := http.NewServeMux()
pbsHandler.HandleFunc("/api2/json/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Proxmox-Product", "Proxmox Backup Server")
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]string{
"version": "2.4.1",
"release": "2",
},
})
})
pveServer := startTLSServerOn(t, "127.0.0.1:8006", pveHandler)
_ = pveServer
pbsServer := startTLSServerOn(t, "127.0.0.1:8007", pbsHandler)
_ = pbsServer
scanner := NewScanner()
scanner.concurrent = 4
scanner.timeout = 200 * time.Millisecond
scanner.httpClient = &http.Client{
Timeout: 500 * time.Millisecond,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var mu sync.Mutex
var callbacks []DiscoveredServer
// Add a manual check for the TCP-only port.
// We call the unexported helper directly inside the package to ensure it does not panic.
if server := scanner.checkServer(ctx, "127.0.0.1", 9009, "pve"); server == nil {
// keep discovery results unaffected but verify we survive the TCP-only host
t.Fatalf("expected checkServer to handle TCP-only host without panic")
}
result, err := scanner.DiscoverServersWithCallback(ctx, subnet, func(server DiscoveredServer) {
mu.Lock()
callbacks = append(callbacks, server)
mu.Unlock()
})
if err != nil {
t.Fatalf("DiscoverServersWithCallback returned error: %v", err)
}
if len(result.Servers) != 2 {
t.Fatalf("expected 2 servers, got %d: %+v", len(result.Servers), result.Servers)
}
seen := make(map[string]DiscoveredServer, len(result.Servers))
for _, server := range result.Servers {
seen[server.Type] = server
}
pve, ok := seen["pve"]
if !ok {
t.Fatalf("expected to discover pve server")
}
if pve.Version != "8.1" {
t.Fatalf("expected pve version 8.1, got %q", pve.Version)
}
pbs, ok := seen["pbs"]
if !ok {
t.Fatalf("expected to discover pbs server")
}
if pbs.Version != "2.4.1" {
t.Fatalf("expected pbs version 2.4.1, got %q", pbs.Version)
}
mu.Lock()
callbackCount := len(callbacks)
mu.Unlock()
if callbackCount < 2 {
t.Fatalf("expected callbacks for both servers, got %d", callbackCount)
}
}