mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
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:
parent
958d6218c2
commit
4838793677
16 changed files with 3691 additions and 90 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
2343
frontend-modern/package-lock.json
generated
2343
frontend-modern/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
162
frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts
Normal file
162
frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1
frontend-modern/src/test/setup.ts
Normal file
1
frontend-modern/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue