feat: Add guest filtering by tag and name prefix via Alert Configuration. Resolves #863

This commit is contained in:
rcourtman 2025-12-22 10:03:12 +00:00
parent c9fc827f4c
commit 71d0401c80
4 changed files with 214 additions and 2 deletions

View file

@ -244,6 +244,12 @@ interface ThresholdsTableProps {
) => void;
dockerIgnoredPrefixes: () => string[];
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
ignoredGuestPrefixes: () => string[];
setIgnoredGuestPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
guestTagWhitelist: () => string[];
setGuestTagWhitelist: (value: string[] | ((prev: string[]) => string[])) => void;
guestTagBlacklist: () => string[];
setGuestTagBlacklist: (value: string[] | ((prev: string[]) => string[])) => void;
storageDefault: () => number;
setStorageDefault: (value: number) => void;
resetGuestDefaults?: () => void;
@ -432,6 +438,67 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
props.setHasUnsavedChanges(true);
};
const [ignoredGuestInput, setIgnoredGuestInput] = createSignal(
props.ignoredGuestPrefixes().join('\n'),
);
const [guestTagWhitelistInput, setGuestTagWhitelistInput] = createSignal(
props.guestTagWhitelist().join('\n'),
);
const [guestTagBlacklistInput, setGuestTagBlacklistInput] = createSignal(
props.guestTagBlacklist().join('\n'),
);
createEffect(() => {
const remote = props.ignoredGuestPrefixes();
const local = ignoredGuestInput();
const normalizedLocal = normalizeDockerIgnoredInput(local);
const isSynced =
remote.length === normalizedLocal.length &&
remote.every((val, i) => val === normalizedLocal[i]);
if (!isSynced) setIgnoredGuestInput(remote.join('\n'));
});
createEffect(() => {
const remote = props.guestTagWhitelist();
const local = guestTagWhitelistInput();
const normalizedLocal = normalizeDockerIgnoredInput(local);
const isSynced =
remote.length === normalizedLocal.length &&
remote.every((val, i) => val === normalizedLocal[i]);
if (!isSynced) setGuestTagWhitelistInput(remote.join('\n'));
});
createEffect(() => {
const remote = props.guestTagBlacklist();
const local = guestTagBlacklistInput();
const normalizedLocal = normalizeDockerIgnoredInput(local);
const isSynced =
remote.length === normalizedLocal.length &&
remote.every((val, i) => val === normalizedLocal[i]);
if (!isSynced) setGuestTagBlacklistInput(remote.join('\n'));
});
const handleIgnoredGuestChange = (value: string) => {
setIgnoredGuestInput(value);
const normalized = normalizeDockerIgnoredInput(value);
props.setIgnoredGuestPrefixes(normalized);
props.setHasUnsavedChanges(true);
};
const handleGuestTagWhitelistChange = (value: string) => {
setGuestTagWhitelistInput(value);
const normalized = normalizeDockerIgnoredInput(value);
props.setGuestTagWhitelist(normalized);
props.setHasUnsavedChanges(true);
};
const handleGuestTagBlacklistChange = (value: string) => {
setGuestTagBlacklistInput(value);
const normalized = normalizeDockerIgnoredInput(value);
props.setGuestTagBlacklist(normalized);
props.setHasUnsavedChanges(true);
};
// Set up keyboard shortcuts
onMount(() => {
const isEditableElement = (el: HTMLElement | null | undefined): boolean => {
@ -2552,6 +2619,59 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
</CollapsibleSection>
</Show>
<Show when={activeTab() === 'proxmox'}>
<CollapsibleSection
id="guest-filtering"
title="Guest Filtering"
collapsed={isCollapsed('guest-filtering')}
onToggle={() => toggleSection('guest-filtering')}
icon={<Monitor class="w-5 h-5" />}
emptyMessage="Configure guest filtering rules."
>
<div class="grid grid-cols-1 gap-6 p-4 xl:grid-cols-3">
<Card padding="md" tone="glass">
<div class="mb-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Ignored Prefixes</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Skip metrics for guests starting with:</p>
</div>
<textarea
value={ignoredGuestInput()}
onInput={(e) => handleIgnoredGuestChange(e.currentTarget.value)}
rows={6}
class="w-full rounded-md border border-gray-300 bg-white p-2 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"
placeholder="dev-"
/>
</Card>
<Card padding="md" tone="glass">
<div class="mb-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Tag Whitelist</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Only monitor guests with at least one of these tags (leave empty to disable whitelist):</p>
</div>
<textarea
value={guestTagWhitelistInput()}
onInput={(e) => handleGuestTagWhitelistChange(e.currentTarget.value)}
rows={6}
class="w-full rounded-md border border-gray-300 bg-white p-2 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"
placeholder="production"
/>
</Card>
<Card padding="md" tone="glass">
<div class="mb-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Tag Blacklist</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Ignore guests with any of these tags:</p>
</div>
<textarea
value={guestTagBlacklistInput()}
onInput={(e) => handleGuestTagBlacklistChange(e.currentTarget.value)}
rows={6}
class="w-full rounded-md border border-gray-300 bg-white p-2 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"
placeholder="maintenance"
/>
</Card>
</div>
</CollapsibleSection>
</Show>
<Show when={hasSection('backups')}>
<CollapsibleSection
id="backups"

View file

@ -1033,6 +1033,9 @@ export function Alerts() {
setDockerPoweredOffSeverity(FACTORY_DOCKER_STATE_SEVERITY);
}
setDockerIgnoredPrefixes(config.dockerIgnoredContainerPrefixes ?? []);
setIgnoredGuestPrefixes(config.ignoredGuestPrefixes ?? []);
setGuestTagWhitelist(config.guestTagWhitelist ?? []);
setGuestTagBlacklist(config.guestTagBlacklist ?? []);
if (config.storageDefault) {
setStorageDefault(getTriggerValue(config.storageDefault) ?? 85);
@ -1434,6 +1437,9 @@ export function Alerts() {
FACTORY_DOCKER_STATE_SEVERITY,
);
const [dockerIgnoredPrefixes, setDockerIgnoredPrefixes] = createSignal<string[]>([]);
const [ignoredGuestPrefixes, setIgnoredGuestPrefixes] = createSignal<string[]>([]);
const [guestTagWhitelist, setGuestTagWhitelist] = createSignal<string[]>([]);
const [guestTagBlacklist, setGuestTagBlacklist] = createSignal<string[]>([]);
const [storageDefault, setStorageDefault] = createSignal(FACTORY_STORAGE_DEFAULT);
const [backupDefaults, setBackupDefaults] = createSignal<BackupAlertConfig>({
@ -1748,6 +1754,15 @@ export function Alerts() {
dockerIgnoredContainerPrefixes: dockerIgnoredPrefixes()
.map((prefix) => prefix.trim())
.filter((prefix) => prefix.length > 0),
ignoredGuestPrefixes: ignoredGuestPrefixes()
.map((prefix) => prefix.trim())
.filter((prefix) => prefix.length > 0),
guestTagWhitelist: guestTagWhitelist()
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
guestTagBlacklist: guestTagBlacklist()
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
storageDefault: createHysteresisThreshold(storageDefault()),
minimumDelta: 2.0,
suppressionWindow: 5,
@ -2034,6 +2049,12 @@ export function Alerts() {
setDockerDefaults={setDockerDefaults}
dockerIgnoredPrefixes={dockerIgnoredPrefixes}
setDockerIgnoredPrefixes={setDockerIgnoredPrefixes}
ignoredGuestPrefixes={ignoredGuestPrefixes}
setIgnoredGuestPrefixes={setIgnoredGuestPrefixes}
guestTagWhitelist={guestTagWhitelist}
setGuestTagWhitelist={setGuestTagWhitelist}
guestTagBlacklist={guestTagBlacklist}
setGuestTagBlacklist={setGuestTagBlacklist}
storageDefault={storageDefault}
setStorageDefault={setStorageDefault}
resetGuestDefaults={resetGuestDefaults}
@ -2192,7 +2213,7 @@ function OverviewTab(props: {
if (!status) return false;
return !status.features?.['ai_alerts'];
});
const aiAlertsUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulsemonitor.app/pro');
const aiAlertsUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulserelay.pro');
// Live streaming state for running patrol
const [expandedLiveStream, setExpandedLiveStream] = createSignal(false);
// Track streaming blocks for sequential display (like AI chat)
@ -2205,7 +2226,7 @@ function OverviewTab(props: {
const [currentThinking, setCurrentThinking] = createSignal('');
let liveStreamUnsubscribe: (() => void) | null = null;
const patrolRequiresLicense = createMemo(() => patrolStatus()?.license_required === true);
const patrolUpgradeURL = createMemo(() => patrolStatus()?.upgrade_url || 'https://pulsemonitor.app/pro');
const patrolUpgradeURL = createMemo(() => patrolStatus()?.upgrade_url || 'https://pulserelay.pro');
const patrolLicenseNote = createMemo(() => {
if (!patrolRequiresLicense()) return '';
const status = patrolStatus()?.license_status;
@ -4078,6 +4099,9 @@ interface ThresholdsTabProps {
dockerDisableConnectivity: () => boolean;
dockerPoweredOffSeverity: () => 'warning' | 'critical';
dockerIgnoredPrefixes: () => string[];
ignoredGuestPrefixes: () => string[];
guestTagWhitelist: () => string[];
guestTagBlacklist: () => string[];
storageDefault: () => number;
timeThresholds: () => { guest: number; node: number; storage: number; pbs: number };
metricTimeThresholds: () => Record<string, Record<string, number>>;
@ -4146,6 +4170,9 @@ interface ThresholdsTabProps {
setDockerDisableConnectivity: (value: boolean) => void;
setDockerPoweredOffSeverity: (value: 'warning' | 'critical') => void;
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
setIgnoredGuestPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
setGuestTagWhitelist: (value: string[] | ((prev: string[]) => string[])) => void;
setGuestTagBlacklist: (value: string[] | ((prev: string[]) => string[])) => void;
setStorageDefault: (value: number) => void;
setMetricTimeThresholds: (
value:
@ -4258,6 +4285,12 @@ function ThresholdsTab(props: ThresholdsTabProps) {
setDockerPoweredOffSeverity={props.setDockerPoweredOffSeverity}
dockerIgnoredPrefixes={props.dockerIgnoredPrefixes}
setDockerIgnoredPrefixes={props.setDockerIgnoredPrefixes}
ignoredGuestPrefixes={props.ignoredGuestPrefixes}
setIgnoredGuestPrefixes={props.setIgnoredGuestPrefixes}
guestTagWhitelist={props.guestTagWhitelist}
setGuestTagWhitelist={props.setGuestTagWhitelist}
guestTagBlacklist={props.guestTagBlacklist}
setGuestTagBlacklist={props.setGuestTagBlacklist}
storageDefault={props.storageDefault}
setStorageDefault={props.setStorageDefault}
timeThresholds={props.timeThresholds}

View file

@ -121,6 +121,9 @@ export interface AlertConfig {
storageDefault: HysteresisThreshold;
dockerDefaults?: DockerThresholdConfig;
dockerIgnoredContainerPrefixes?: string[];
ignoredGuestPrefixes?: string[];
guestTagWhitelist?: string[];
guestTagBlacklist?: string[];
pmgDefaults?: PMGThresholdDefaults;
snapshotDefaults?: SnapshotAlertConfig;
backupDefaults?: BackupAlertConfig;

View file

@ -377,6 +377,9 @@ type AlertConfig struct {
StorageDefault HysteresisThreshold `json:"storageDefault"`
DockerDefaults DockerThresholdConfig `json:"dockerDefaults"`
DockerIgnoredContainerPrefixes []string `json:"dockerIgnoredContainerPrefixes,omitempty"`
IgnoredGuestPrefixes []string `json:"ignoredGuestPrefixes,omitempty"`
GuestTagWhitelist []string `json:"guestTagWhitelist,omitempty"`
GuestTagBlacklist []string `json:"guestTagBlacklist,omitempty"`
PMGDefaults PMGThresholdConfig `json:"pmgDefaults"`
SnapshotDefaults SnapshotAlertConfig `json:"snapshotDefaults"`
BackupDefaults BackupAlertConfig `json:"backupDefaults"`
@ -2097,6 +2100,9 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
enabled := m.config.Enabled
disableAllGuests := m.config.DisableAllGuests
disableAllGuestsOffline := m.config.DisableAllGuestsOffline
ignoredGuestPrefixes := m.config.IgnoredGuestPrefixes
guestTagWhitelist := m.config.GuestTagWhitelist
guestTagBlacklist := m.config.GuestTagBlacklist
m.mu.RUnlock()
if !enabled {
@ -2166,6 +2172,18 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
return
}
// Check ignored prefixes
for _, prefix := range ignoredGuestPrefixes {
if prefix != "" && strings.HasPrefix(name, prefix) {
if cleared := m.suppressGuestAlerts(guestID); cleared {
m.saveActiveAlertsAsync("ignored-prefix")
}
return
}
}
settings := parsePulseTags(tags)
if settings.Suppress {
if cleared := m.suppressGuestAlerts(guestID); cleared {
@ -2177,6 +2195,44 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
return
}
// Custom Tag Filtering
if len(guestTagBlacklist) > 0 || len(guestTagWhitelist) > 0 {
// Normalize tags once for checking
normalizedTags := make(map[string]bool)
for _, tag := range tags {
normalizedTags[strings.ToLower(strings.TrimSpace(tag))] = true
}
// Check Blacklist
for _, block := range guestTagBlacklist {
if normalizedTags[strings.ToLower(strings.TrimSpace(block))] {
if cleared := m.suppressGuestAlerts(guestID); cleared {
m.saveActiveAlertsAsync("tag-blacklist")
}
log.Debug().Str("guestID", guestID).Msg("Guest suppressed by tag blacklist")
return
}
}
// Check Whitelist
if len(guestTagWhitelist) > 0 {
found := false
for _, allow := range guestTagWhitelist {
if normalizedTags[strings.ToLower(strings.TrimSpace(allow))] {
found = true
break
}
}
if !found {
if cleared := m.suppressGuestAlerts(guestID); cleared {
m.saveActiveAlertsAsync("tag-whitelist")
}
log.Debug().Str("guestID", guestID).Msg("Guest suppressed by tag whitelist (required tag not found)")
return
}
}
}
monitorOnly := settings.MonitorOnly
if monitorOnly || m.guestHasMonitorOnlyAlerts(guestID) {
log.Debug().