From d10aedd4194f9c3ed3bfc42629ce2171e2cffa87 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 20 Mar 2026 16:21:40 +0000 Subject: [PATCH] Extract alerts thresholds data owner --- .../v6/internal/subsystems/alerts.md | 6 + .../subsystems/frontend-primitives.md | 7 + .../src/components/Alerts/ThresholdsTable.tsx | 1272 +---------------- .../thresholds/hooks/useThresholdsData.ts | 268 +++- .../pages/__tests__/Alerts.helpers.test.ts | 7 + .../frontendResourceTypeBoundaries.test.ts | 7 + 6 files changed, 282 insertions(+), 1285 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 6901b6dbe..3876a6743 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -111,6 +111,12 @@ Alert threshold tables now route their visible resource row labels, search labels, and persisted override display names through the same shared helper so governed agent, guest, and storage rows do not leak raw names when the threshold editor saves or re-renders them. +That threshold editor data shaping now lives under +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`, +with `frontend-modern/src/components/Alerts/ThresholdsTable.tsx` limited to +table interaction and presentation. New threshold row grouping, override-ID +compatibility, and resource normalization logic should land in the hook rather +than being rebuilt inside the table component. Alert incident timeline event cards now route through `frontend-modern/src/components/Alerts/IncidentTimelineEventCard.tsx`, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 2ced9abf7..bd1455422 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -231,6 +231,13 @@ continue by extracting page-local tab blocks into feature modules rather than expanding the top-level page file again, and history-table behavior or thresholds-table adapter logic should stay feature-owned unless it graduates into a shared primitive used by more than one alert surface. +Within that thresholds surface, `frontend-modern/src/components/Alerts/ThresholdsTable.tsx` +is now explicitly a feature consumer rather than the data owner. Canonical +threshold row shaping, override-ID compatibility, and grouped resource +normalization live in +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`, +so future cleanup should extend that feature hook instead of rebuilding +resource normalization inside the table component. The alerts page now also applies the same shell-versus-feature rule to configuration orchestration. `frontend-modern/src/pages/Alerts.tsx` is the page diff --git a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx index 115fd975a..e90e3524c 100644 --- a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx +++ b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx @@ -15,17 +15,6 @@ import Camera from 'lucide-solid/icons/camera'; import Mail from 'lucide-solid/icons/mail'; import Users from 'lucide-solid/icons/users'; import Boxes from 'lucide-solid/icons/boxes'; -import { unwrap } from 'solid-js/store'; -import { requiresGovernedResourceDisplay } from '@/types/resource'; -import type { Resource } from '@/types/resource'; -import { - getAgentDiscoveryResourceId, - isAppContainerDiscoveryResourceType, -} from '@/utils/discoveryTarget'; -import { - getPreferredResourceDisplayName, - getPreferredResourceHostname, -} from '@/utils/resourceIdentity'; // Workaround for eslint false-positive when `For` is used only in JSX const __ensureForUsage = For; @@ -39,7 +28,7 @@ import type { } from '@/types/alerts'; import { ResourceTable } from './ResourceTable'; import { BulkEditDialog } from './BulkEditDialog'; -import type { GroupHeaderMeta, Resource as TableResource } from './ResourceTable'; +import type { Resource as TableResource } from './ResourceTable'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { logger } from '@/utils/logger'; import { @@ -78,8 +67,6 @@ import type { import { matchesAlertIdentifier } from '@/features/alerts/identity'; import { PMG_THRESHOLD_COLUMNS, - PMG_NORMALIZED_TO_KEY, - PMG_KEY_TO_NORMALIZED, DEFAULT_SNAPSHOT_WARNING, DEFAULT_SNAPSHOT_CRITICAL, DEFAULT_SNAPSHOT_WARNING_SIZE, @@ -89,8 +76,8 @@ import { DEFAULT_BACKUP_FRESH_HOURS, DEFAULT_BACKUP_STALE_HOURS, } from '@/features/alerts/thresholds/constants'; -import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers'; import { normalizeDockerIgnoredInput, formatMetricValue } from '@/features/alerts/thresholds/helpers'; +import { useThresholdsData } from '@/features/alerts/thresholds/hooks/useThresholdsData'; export function ThresholdsTable(props: ThresholdsTableProps) { const navigate = useNavigate(); @@ -99,70 +86,6 @@ export function ThresholdsTable(props: ThresholdsTableProps) { const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); const sectionTitles = getAlertThresholdsSectionTitles(); - const pd = (r: Resource): Record | undefined => - r.platformData ? (unwrap(r.platformData) as Record) : undefined; - const asRecord = (value: unknown): Record | undefined => - value && typeof value === 'object' ? (value as Record) : undefined; - const asString = (value: unknown): string | undefined => - typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; - const uniqueIds = (...values: unknown[]): string[] => { - const ids: string[] = []; - const seen = new Set(); - values.forEach((value) => { - const normalized = asString(value); - if (!normalized || seen.has(normalized)) return; - seen.add(normalized); - ids.push(normalized); - }); - return ids; - }; - const hostOverrideIdCandidates = (resource: Resource): string[] => { - const platformData = pd(resource); - const agent = asRecord(platformData?.agent); - const discoveryTarget = resource.discoveryTarget ?? null; - return uniqueIds( - getAgentDiscoveryResourceId(discoveryTarget), - discoveryTarget?.agentId, - resource.agent?.agentId, - agent?.agentId, - platformData?.agentId, - resource.id, - ); - }; - const hostActionId = (resource: Resource): string => - hostOverrideIdCandidates(resource)[0] || resource.id; - const dockerHostOverrideIdCandidates = (resource: Resource): string[] => { - const platformData = pd(resource); - const docker = asRecord(platformData?.docker); - const discoveryTarget = resource.discoveryTarget; - return uniqueIds( - isAppContainerDiscoveryResourceType(discoveryTarget?.resourceType) - ? discoveryTarget?.resourceId - : undefined, - docker?.hostSourceId, - platformData?.hostSourceId, - discoveryTarget?.agentId, - resource.id, - ); - }; - const dockerContainerOverrideIdCandidates = (host: Resource, shortId: string): string[] => { - return uniqueIds( - ...dockerHostOverrideIdCandidates(host).map((hostId) => `docker:${hostId}/${shortId}`), - ); - }; - const findOverrideByCandidates = ( - overridesMap: Map, - candidates: string[], - ): Override | undefined => { - for (const candidate of candidates) { - const override = overridesMap.get(candidate); - if (override) { - return override; - } - } - return undefined; - }; - // Collapsible section state management const { isCollapsed, toggleSection, expandAll, collapseAll } = useCollapsedSections(); @@ -284,663 +207,35 @@ export function ThresholdsTable(props: ThresholdsTableProps) { return alertKey in props.activeAlerts; }; - // Process nodes with their overrides - const getFriendlyNodeName = (value: string, clusterName?: string): string => { - if (!value) return value; - - const clusterLower = clusterName?.toLowerCase().trim(); - - const normalizeToken = (token?: string | null): string => { - if (!token) return ''; - let result = token - .replace(/\(.*?\)/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (clusterLower) { - result = result - .split(' ') - .filter((part) => part.toLowerCase() !== clusterLower) - .join(' ') - .trim(); - } - if (!result) return ''; - const firstWord = result.split(/\s+/)[0] || result; - const withoutDomain = firstWord.includes('.') - ? (firstWord.split('.')[0] ?? firstWord) - : firstWord; - return withoutDomain.trim(); - }; - - const parentheticalMatch = value.match(/\(([^)]+)\)/); - const parentheticalRaw = parentheticalMatch?.[1]?.trim(); - - let base = normalizeToken(value); - if (!base) { - base = value.trim(); - } - - const parenthetical = normalizeToken(parentheticalRaw); - if (parenthetical && parenthetical.toLowerCase() !== base.toLowerCase()) { - return parenthetical; - } - - return base; - }; - const getFriendlyAlertNodeName = ( - value: string, - policy?: Resource['policy'], - clusterName?: string, - ): string => (requiresGovernedResourceDisplay(policy) ? value : getFriendlyNodeName(value, clusterName)); - - const buildNodeHeaderMeta = (node: Resource) => { - const data = pd(node); - const clusterName = (data?.clusterName as string | undefined) ?? undefined; - const isClusterMember = - (data?.isClusterMember as boolean | undefined) ?? Boolean(node.clusterId); - - const originalDisplayName = getAlertResourceDisplayLabel(node); - const friendlyName = getFriendlyAlertNodeName(originalDisplayName, node.policy, clusterName); - - // Prioritize guestURL over host (same as NodeGroupHeader) - const guestUrlValue = - typeof data?.guestURL === 'string' ? (data.guestURL as string).trim() : ''; - const hostValue = typeof data?.host === 'string' ? (data.host as string).trim() : ''; - - let host: string | undefined; - if (guestUrlValue && guestUrlValue !== '') { - host = guestUrlValue.startsWith('http') ? guestUrlValue : `https://${guestUrlValue}`; - } else if (hostValue && hostValue !== '') { - host = hostValue.startsWith('http') - ? hostValue - : `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`; - } else if (node.name) { - host = `https://${node.name.includes(':') ? node.name : `${node.name}:8006`}`; - } - - const headerMeta: GroupHeaderMeta = { - type: 'node', - displayName: friendlyName, - rawName: originalDisplayName, - host, - status: node.status, - clusterName: isClusterMember ? clusterName?.trim() || 'Cluster' : undefined, - isClusterMember, - }; - - const keys = new Set(); - [node.name, originalDisplayName, friendlyName].forEach((value) => { - if (value && value.trim()) { - keys.add(value.trim()); - } - }); - - return { headerMeta, keys }; - }; - - const nodesWithOverrides = createMemo((prev = []) => { - // If we're currently editing, return the previous value to avoid re-renders - if (editingId()) { - return prev; - } - - const search = searchTerm().toLowerCase(); - const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o])); - - const nodes = (props.nodes ?? []).map((node) => { - const override = overridesMap.get(node.id); - const data = pd(node); - const clusterName = (data?.clusterName as string | undefined) ?? undefined; - const isClusterMember = - (data?.isClusterMember as boolean | undefined) ?? Boolean(node.clusterId); - - // Check if any threshold values actually differ from defaults - const hasCustomThresholds = - override?.thresholds && - Object.keys(override.thresholds).some((key) => { - const k = key as keyof typeof override.thresholds; - return ( - override.thresholds[k] !== undefined && - override.thresholds[k] !== (props.nodeDefaults as any)[k] - ); - }); - - const note = typeof override?.note === 'string' ? override.note : undefined; - const hasNote = Boolean(note && note.trim().length > 0); - - const originalDisplayName = getPreferredResourceDisplayName(node); - const friendlyName = getFriendlyAlertNodeName(originalDisplayName, node.policy, clusterName); - const rawName = node.name; - const sanitizedName = friendlyName || originalDisplayName || rawName.split('.')[0] || rawName; - // Build a best-effort management URL for the node - // Prioritize guestURL over host (same as NodeGroupHeader) - const guestUrlValue = - typeof data?.guestURL === 'string' ? (data.guestURL as string).trim() : ''; - const hostValue = - (typeof data?.host === 'string' ? (data.host as string).trim() : '') || rawName; - let normalizedHost: string; - if (guestUrlValue && guestUrlValue !== '') { - normalizedHost = guestUrlValue.startsWith('http') - ? guestUrlValue - : `https://${guestUrlValue}`; - } else { - normalizedHost = - hostValue.startsWith('http://') || hostValue.startsWith('https://') - ? hostValue - : `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`; - } - - return { - id: node.id, - name: sanitizedName, - displayName: sanitizedName, - rawName: node.name, - host: normalizedHost, - type: 'agent' as const, - resourceType: 'Agent', - status: node.status, - uptime: node.uptime, - cpu: (node.cpu?.current ?? 0) / 100, - memory: node.memory?.current, - hasOverride: - hasCustomThresholds || hasNote || Boolean(override?.disableConnectivity) || false, - disabled: false, - disableConnectivity: override?.disableConnectivity || false, - thresholds: override?.thresholds || {}, - defaults: props.nodeDefaults, - clusterName: isClusterMember ? clusterName?.trim() : undefined, - isClusterMember, - instance: node.platformId, - note, - } satisfies TableResource; - }); - - if (search) { - return nodes.filter((n) => n.name.toLowerCase().includes(search)); - } - return nodes; - }, []); - - const agentsWithOverrides = createMemo((prev = []) => { - if (editingId()) { - return prev; - } - - const search = searchTerm().toLowerCase(); - const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o])); - const seen = new Set(); - - const agents: TableResource[] = (props.agents ?? []).map((agentResource) => { - const idCandidates = hostOverrideIdCandidates(agentResource); - const override = findOverrideByCandidates(overridesMap, idCandidates); - const resourceId = override?.id || idCandidates[0] || agentResource.id; - const hasCustomThresholds = - override?.thresholds && - Object.keys(override.thresholds).some((key) => { - const k = key as keyof typeof override.thresholds; - return ( - override.thresholds[k] !== undefined && - override.thresholds[k] !== (props.agentDefaults as any)[k] - ); - }); - - const displayName = getAlertResourceDisplayLabel(agentResource); - const status = agentResource.status; - const data = pd(agentResource); - const agentData = asRecord(data?.agent); - - seen.add(resourceId); - - return { - id: resourceId, - name: displayName, - displayName, - rawName: agentResource.identity?.hostname ?? agentResource.name, - type: 'agent' as const, - resourceType: 'Agent', - node: displayName, - instance: - asString(agentData?.platform) || - asString(agentData?.osName) || - asString(data?.platform) || - asString(data?.osName) || - '', - status, - hasOverride: - hasCustomThresholds || - Boolean(override?.disabled) || - Boolean(override?.disableConnectivity), - disabled: override?.disabled || false, - disableConnectivity: override?.disableConnectivity || false, - thresholds: override?.thresholds || {}, - defaults: props.agentDefaults, - } satisfies TableResource; - }); - - (props.overrides() ?? []) - .filter((override) => override.type === 'agent' && !seen.has(override.id)) - .forEach((override) => { - const name = override.name?.trim() || override.id; - agents.push({ - id: override.id, - name, - displayName: name, - rawName: name, - type: 'agent' as const, - resourceType: 'Agent', - node: '', - instance: '', - status: 'unknown', - hasOverride: true, - disabled: override.disabled || false, - disableConnectivity: override.disableConnectivity || false, - thresholds: override.thresholds || {}, - defaults: props.agentDefaults, - } satisfies TableResource); - }); - - if (search) { - return agents.filter((agent) => agent.name.toLowerCase().includes(search)); - } - - return agents; - }, []); - - // Helper function to create agent disk resource ID (matches backend sanitizeHostComponent) - const agentDiskResourceID = (agentId: string, mountpoint: string, device?: string): string => { - // Use mountpoint if available, otherwise device - let label = (mountpoint?.trim() || device?.trim() || 'disk').toLowerCase(); - // Replicate backend sanitizeHostComponent: keep a-z 0-9, replace everything else with '-', collapse consecutive hyphens - label = label - .replace(/[^a-z0-9]/g, '-') - .replace(/-{2,}/g, '-') - .replace(/^-|-$/g, ''); - if (!label) label = 'unknown'; - return `agent:${agentId}/disk:${label}`; - }; - - // Process agent disks with their overrides - const agentDisksWithOverrides = createMemo((prev = []) => { - if (editingId()) { - return prev; - } - - const search = searchTerm().toLowerCase(); - const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o])); - const seen = new Set(); - const disks: TableResource[] = []; - - // Extract disks from all agents - (props.agents ?? []).forEach((agentResource) => { - const agentDisplayName = getAlertResourceDisplayLabel(agentResource); - const agentIdCandidates = hostOverrideIdCandidates(agentResource); - const agentIdForActions = hostActionId(agentResource); - const platformData = pd(agentResource); - const platformAgent = asRecord(platformData?.agent); - const disksFromPlatformRoot = Array.isArray(platformData?.disks) ? platformData.disks : null; - const disksFromPlatformAgent = Array.isArray(platformAgent?.disks) - ? platformAgent.disks - : null; - const disksFromResourceAgent = Array.isArray(agentResource.agent?.disks) - ? agentResource.agent.disks - : null; - - const disksForAgent = (disksFromPlatformRoot || - disksFromPlatformAgent || - disksFromResourceAgent || - []) as Array<{ - mountpoint?: string; - device?: string; - used?: number; - total?: number; - type?: string; - }>; - - disksForAgent.forEach((disk) => { - const diskLabel = disk.mountpoint?.trim() || disk.device?.trim() || 'disk'; - const resourceIdCandidates = uniqueIds( - ...agentIdCandidates.map((agentId) => - agentDiskResourceID(agentId, disk.mountpoint || '', disk.device), - ), - ); - const override = findOverrideByCandidates(overridesMap, resourceIdCandidates); - const resourceId = override?.id || resourceIdCandidates[0]; - if (!resourceId) return; - - const hasCustomThresholds = - override?.thresholds?.disk !== undefined && - override.thresholds.disk !== props.agentDefaults.disk; - - seen.add(resourceId); - - disks.push({ - id: resourceId, - name: diskLabel, - displayName: diskLabel, - rawName: disk.device || diskLabel, - type: 'agentDisk' as const, - resourceType: 'Agent Disk', - host: agentIdForActions, - node: agentDisplayName, - instance: disk.type || '', - status: agentResource.status, - hasOverride: hasCustomThresholds || Boolean(override?.disabled), - disabled: override?.disabled || false, - thresholds: override?.thresholds || {}, - defaults: { disk: props.agentDefaults.disk }, - subtitle: `${((disk.used || 0) / 1024 / 1024 / 1024).toFixed(1)} / ${((disk.total || 0) / 1024 / 1024 / 1024).toFixed(1)} GB`, - } satisfies TableResource); - }); - }); - - // Include any agentDisk overrides for disks that are no longer present - (props.overrides() ?? []) - .filter((override) => override.type === 'agentDisk' && !seen.has(override.id)) - .forEach((override) => { - const name = override.name || override.id; - disks.push({ - id: override.id, - name, - displayName: name, - rawName: name, - type: 'agentDisk' as const, - resourceType: 'Agent Disk', - host: '', - node: 'Unknown Agent', - instance: '', - status: 'unknown', - hasOverride: true, - disabled: override.disabled || false, - thresholds: override.thresholds || {}, - defaults: { disk: props.agentDefaults.disk }, - }); - }); - - if (search) { - return disks.filter( - (d) => d.name.toLowerCase().includes(search) || d.node?.toLowerCase().includes(search), - ); - } - - return disks; - }, []); - - // Group host disks by their host - const agentDisksGroupedByAgent = createMemo>(() => { - const grouped: Record = {}; - agentDisksWithOverrides().forEach((disk) => { - const key = disk.node?.trim() || 'Unknown Agent'; - if (!grouped[key]) { - grouped[key] = []; - } - grouped[key].push(disk); - }); - - // Sort disks within each host by name - Object.values(grouped).forEach((resources) => { - resources.sort((a, b) => a.name.localeCompare(b.name)); - }); - - return grouped; - }); - - // Process Docker hosts with their overrides (primarily for connectivity toggles) - const dockerHostsWithOverrides = createMemo((prev = []) => { - if (editingId()) { - return prev; - } - - const search = searchTerm().toLowerCase(); - const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o])); - const seen = new Set(); - - const hosts: TableResource[] = (props.dockerHosts ?? []).map((host) => { - const idCandidates = dockerHostOverrideIdCandidates(host); - const originalName = getPreferredResourceDisplayName(host); - const friendlyName = getFriendlyAlertNodeName(originalName, host.policy); - const override = findOverrideByCandidates(overridesMap, idCandidates); - const resourceId = override?.id || idCandidates[0] || host.id; - const disableConnectivity = override?.disableConnectivity || false; - const status = host.status; - - seen.add(resourceId); - - return { - id: resourceId, - name: friendlyName, - displayName: friendlyName, - rawName: originalName, - type: 'dockerHost' as const, - resourceType: 'Container Runtime', - node: getPreferredResourceHostname(host), - instance: (pd(host)?.platform as string) || (pd(host)?.osName as string) || '', - status, - hasOverride: disableConnectivity, - disableConnectivity, - thresholds: override?.thresholds || {}, - defaults: {}, - editable: false, - } satisfies TableResource; - }); - - // Include any overrides referencing Docker hosts that are no longer reporting - (props.overrides() ?? []) - .filter((override) => override.type === 'dockerHost' && !seen.has(override.id)) - .forEach((override) => { - const originalName = override.name || override.id; - const friendlyName = originalName; - hosts.push({ - id: override.id, - name: friendlyName, - displayName: friendlyName, - rawName: originalName, - type: 'dockerHost', - resourceType: 'Container Runtime', - node: override.node || '', - instance: override.instance || '', - status: 'unknown', - hasOverride: true, - disableConnectivity: override.disableConnectivity || false, - thresholds: override.thresholds || {}, - defaults: {}, - editable: false, - }); - }); - - if (search) { - return hosts.filter((host) => host.name.toLowerCase().includes(search)); - } - return hosts; - }, []); - - const dockerContainersByHostId = createMemo(() => { - const map = new Map(); - (props.allResources ?? []).forEach((resource) => { - if (resource.type !== 'app-container') return; - const parentId = resource.parentId; - if (!parentId) return; - const existing = map.get(parentId); - if (existing) { - existing.push(resource); - } else { - map.set(parentId, [resource]); - } - }); - return map; - }); - - // Process Docker containers grouped by host - const dockerContainersGroupedByHost = createMemo>((prev = {}) => { - if (editingId()) { - return prev; - } - - const search = searchTerm().toLowerCase(); - const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o])); - const groups: Record = {}; - const seen = new Set(); - - (props.dockerHosts ?? []).forEach((host) => { - const dockerHostIds = dockerHostOverrideIdCandidates(host); - const dockerHostIdForActions = dockerHostIds[0] || host.id; - const hostLabel = getPreferredResourceDisplayName(host); - const friendlyHostName = getFriendlyAlertNodeName(hostLabel, host.policy); - const hostLabelLower = hostLabel.toLowerCase(); - const friendlyHostNameLower = friendlyHostName.toLowerCase(); - - const hostHostname = getPreferredResourceHostname(host); - const containers = dockerContainersByHostId().get(host.id) ?? []; - - containers.forEach((container) => { - const shortId = container.id.includes('/') - ? (container.id.split('/').pop() ?? container.id) - : container.id; - const resourceIdCandidates = dockerContainerOverrideIdCandidates(host, shortId); - const override = findOverrideByCandidates(overridesMap, resourceIdCandidates); - const resourceId = - override?.id || resourceIdCandidates[0] || `docker:${dockerHostIdForActions}/${shortId}`; - const overrideSeverity = override?.poweredOffSeverity; - - const defaults = props.dockerDefaults as Record; - const hasCustomThresholds = - override?.thresholds && - Object.keys(override.thresholds).some((key) => { - const k = key as keyof typeof override.thresholds; - return ( - override.thresholds[k] !== undefined && - override.thresholds[k] !== defaults?.[k as keyof typeof defaults] - ); - }); - - const hasOverride = - hasCustomThresholds || - override?.disabled || - override?.disableConnectivity || - overrideSeverity !== undefined || - false; - - const containerName = getAlertResourceDisplayLabel(container, shortId); - const containerNameLower = containerName.toLowerCase(); - const image = (pd(container)?.image as string) ?? ''; - const imageLower = image.toLowerCase(); - - const matchesSearch = - !search || - containerNameLower.includes(search) || - hostLabelLower.includes(search) || - friendlyHostNameLower.includes(search) || - imageLower.includes(search); - if (!matchesSearch) { - return; - } - - const status = container.status; - const groupKey = friendlyHostName || hostLabel; - - const resource: TableResource = { - id: resourceId, - name: containerName, - type: 'dockerContainer', - resourceType: 'Container', - node: groupKey, - instance: hostHostname, - status, - hasOverride, - disabled: override?.disabled || false, - disableConnectivity: override?.disableConnectivity || false, - thresholds: override?.thresholds || {}, - defaults: props.dockerDefaults, - hostId: dockerHostIdForActions, - image, - poweredOffSeverity: overrideSeverity, - }; - - if (!groups[groupKey]) { - groups[groupKey] = []; - } - groups[groupKey].push(resource); - seen.add(resourceId); - }); - }); - - // Include overrides for Docker containers that aren't currently reporting - (props.overrides() ?? []) - .filter((override) => override.type === 'dockerContainer' && !seen.has(override.id)) - .forEach((override) => { - const fallbackName = override.name || override.id.split('/').pop() || override.id; - const group = 'Unassigned Containers'; - if (!groups[group]) { - groups[group] = []; - } - groups[group].push({ - id: override.id, - name: fallbackName, - type: 'dockerContainer', - resourceType: 'Container', - status: 'unknown', - hasOverride: true, - disabled: override.disabled || false, - disableConnectivity: override.disableConnectivity || false, - thresholds: override.thresholds || {}, - defaults: props.dockerDefaults, - poweredOffSeverity: override.poweredOffSeverity, - }); - }); - - Object.keys(groups).forEach((group) => { - groups[group].sort((a, b) => a.name.localeCompare(b.name)); - }); - - if (!search) { - return groups; - } - - // With search applied, remove empty groups (should already be filtered) - const filteredGroups: Record = {}; - Object.entries(groups).forEach(([group, resources]) => { - if (resources.length > 0) { - filteredGroups[group] = resources; - } - }); - return filteredGroups; - }, {}); - - const dockerContainersFlat = createMemo(() => - Object.values(dockerContainersGroupedByHost() ?? {}).flat(), - ); - - const totalDockerContainers = createMemo(() => - (props.dockerHosts ?? []).reduce( - (sum, host) => sum + (dockerContainersByHostId().get(host.id)?.length ?? 0), - 0, - ), - ); - - const dockerHostGroupMeta = createMemo>(() => { - const meta: Record = {}; - (props.dockerHosts ?? []).forEach((host) => { - const originalName = getPreferredResourceDisplayName(host); - const friendlyName = getFriendlyAlertNodeName(originalName, host.policy); - const headerMeta: GroupHeaderMeta = { - displayName: friendlyName, - rawName: originalName, - status: host.status, - }; - - const hostname = getPreferredResourceHostname(host); - [friendlyName, originalName, hostname, host.id] - .filter((key): key is string => Boolean(key && key.trim())) - .forEach((key) => { - meta[key.trim()] = headerMeta; - }); - }); - - meta['Unassigned Containers'] = { - displayName: 'Unassigned Containers', - status: 'unknown', - }; - - return meta; - }); + const { + nodesWithOverrides, + agentsWithOverrides, + agentDisksWithOverrides, + agentDisksGroupedByAgent, + dockerHostsWithOverrides, + dockerContainersGroupedByHost, + dockerContainersFlat, + totalDockerContainers, + dockerHostGroupMeta, + snapshotFactoryConfig, + sanitizeSnapshotConfig, + backupFactoryConfig, + sanitizeBackupConfig, + snapshotDefaultsRecord, + snapshotFactoryDefaultsRecord, + backupDefaultsRecord, + backupFactoryDefaultsRecord, + snapshotOverridesCount, + backupOverridesCount, + guestsGroupedByNode, + guestsFlat, + guestGroupHeaderMeta, + pbsServersWithOverrides, + pmgGlobalDefaults, + pmgServersWithOverrides, + storageWithOverrides, + storageGroupedByNode, + } = useThresholdsData(props, editingId, searchTerm); const countOverrides = (resources: TableResource[] | undefined) => resources?.filter( @@ -951,54 +246,6 @@ export function ThresholdsTable(props: ThresholdsTableProps) { /* no-op placeholder for future scroll restoration */ }; - const snapshotFactoryConfig = () => - props.snapshotFactoryDefaults ?? { - enabled: false, - warningDays: DEFAULT_SNAPSHOT_WARNING, - criticalDays: DEFAULT_SNAPSHOT_CRITICAL, - warningSizeGiB: DEFAULT_SNAPSHOT_WARNING_SIZE, - criticalSizeGiB: DEFAULT_SNAPSHOT_CRITICAL_SIZE, - }; - - const sanitizeSnapshotConfig = (config: SnapshotAlertConfig): SnapshotAlertConfig => { - let warning = Math.max(0, Math.round(config.warningDays ?? 0)); - let critical = Math.max(0, Math.round(config.criticalDays ?? 0)); - - if (critical > 0 && warning > critical) { - warning = critical; - } - if (critical === 0 && warning > 0) { - critical = warning; - } - - const rawWarningSize = Number.isFinite(config.warningSizeGiB) - ? Number(config.warningSizeGiB) - : DEFAULT_SNAPSHOT_WARNING_SIZE; - const rawCriticalSize = Number.isFinite(config.criticalSizeGiB) - ? Number(config.criticalSizeGiB) - : DEFAULT_SNAPSHOT_CRITICAL_SIZE; - - const roundSize = (value: number) => Math.round(Math.max(0, value) * 10) / 10; - - let warningSize = roundSize(rawWarningSize); - let criticalSize = roundSize(rawCriticalSize); - - if (criticalSize > 0 && warningSize > criticalSize) { - warningSize = criticalSize; - } - if (criticalSize === 0 && warningSize > 0) { - criticalSize = warningSize; - } - - return { - enabled: !!config.enabled, - warningDays: warning, - criticalDays: critical, - warningSizeGiB: warningSize, - criticalSizeGiB: criticalSize, - }; - }; - const updateSnapshotDefaults = ( updater: SnapshotAlertConfig | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig), ) => { @@ -1012,72 +259,6 @@ export function ThresholdsTable(props: ThresholdsTableProps) { props.setHasUnsavedChanges(true); }; - const snapshotDefaultsRecord = createMemo(() => { - const current = props.snapshotDefaults(); - return { - 'warning days': current.warningDays ?? 0, - 'critical days': current.criticalDays ?? 0, - 'warning size (gib)': current.warningSizeGiB ?? 0, - 'critical size (gib)': current.criticalSizeGiB ?? 0, - }; - }); - - const snapshotFactoryDefaultsRecord = createMemo(() => { - const factory = snapshotFactoryConfig(); - return { - 'warning days': factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING, - 'critical days': factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL, - 'warning size (gib)': factory.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE, - 'critical size (gib)': factory.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE, - }; - }); - - const backupFactoryConfig = () => - props.backupFactoryDefaults ?? { - enabled: false, - warningDays: DEFAULT_BACKUP_WARNING, - criticalDays: DEFAULT_BACKUP_CRITICAL, - freshHours: DEFAULT_BACKUP_FRESH_HOURS, - staleHours: DEFAULT_BACKUP_STALE_HOURS, - alertOrphaned: true, - ignoreVMIDs: [], - }; - - const sanitizeBackupConfig = (config: BackupAlertConfig): BackupAlertConfig => { - let warning = Math.max(0, Math.round(config.warningDays ?? 0)); - let critical = Math.max(0, Math.round(config.criticalDays ?? 0)); - let fresh = Math.max(0, Math.round(config.freshHours ?? DEFAULT_BACKUP_FRESH_HOURS)); - let stale = Math.max(0, Math.round(config.staleHours ?? DEFAULT_BACKUP_STALE_HOURS)); - const alertOrphaned = config.alertOrphaned ?? true; - const ignoreVMIDs = Array.from( - new Set( - (config.ignoreVMIDs ?? []).map((value) => value.trim()).filter((value) => value.length > 0), - ), - ); - - if (critical > 0 && warning > critical) { - warning = critical; - } - if (critical === 0 && warning > 0) { - critical = warning; - } - - // Ensure stale is at least fresh - if (stale < fresh) { - stale = fresh; - } - - return { - enabled: !!config.enabled, - warningDays: warning, - criticalDays: critical, - freshHours: fresh, - staleHours: stale, - alertOrphaned, - ignoreVMIDs, - }; - }; - const updateBackupDefaults = ( updater: BackupAlertConfig | ((prev: BackupAlertConfig) => BackupAlertConfig), ) => { @@ -1091,249 +272,6 @@ export function ThresholdsTable(props: ThresholdsTableProps) { props.setHasUnsavedChanges(true); }; - const backupDefaultsRecord = createMemo(() => { - const current = props.backupDefaults(); - return { - 'fresh hours': current.freshHours ?? DEFAULT_BACKUP_FRESH_HOURS, - 'stale hours': current.staleHours ?? DEFAULT_BACKUP_STALE_HOURS, - 'warning days': current.warningDays ?? 0, - 'critical days': current.criticalDays ?? 0, - }; - }); - - const backupFactoryDefaultsRecord = createMemo(() => { - const factory = backupFactoryConfig(); - return { - 'fresh hours': factory.freshHours ?? DEFAULT_BACKUP_FRESH_HOURS, - 'stale hours': factory.staleHours ?? DEFAULT_BACKUP_STALE_HOURS, - 'warning days': factory.warningDays ?? DEFAULT_BACKUP_WARNING, - 'critical days': factory.criticalDays ?? DEFAULT_BACKUP_CRITICAL, - }; - }); - - const snapshotOverridesCount = createMemo(() => { - const current = props.snapshotDefaults(); - const factory = snapshotFactoryConfig(); - const differs = - current.enabled !== factory.enabled || - (current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !== - (factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) || - (current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !== - (factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) || - (current.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) !== - (factory.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) || - (current.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE) !== - (factory.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE); - return differs ? 1 : 0; - }); - - const backupOverridesCount = createMemo(() => { - const backupCurrent = props.backupDefaults(); - const backupFactory = backupFactoryConfig(); - const currentIgnore = backupCurrent.ignoreVMIDs ?? []; - const factoryIgnore = backupFactory.ignoreVMIDs ?? []; - const ignoreDiff = - currentIgnore.length !== factoryIgnore.length || - currentIgnore.some((value, index) => value !== factoryIgnore[index]); - return backupCurrent.enabled !== backupFactory.enabled || - (backupCurrent.warningDays ?? DEFAULT_BACKUP_WARNING) !== - (backupFactory.warningDays ?? DEFAULT_BACKUP_WARNING) || - (backupCurrent.criticalDays ?? DEFAULT_BACKUP_CRITICAL) !== - (backupFactory.criticalDays ?? DEFAULT_BACKUP_CRITICAL) || - (backupCurrent.freshHours ?? DEFAULT_BACKUP_FRESH_HOURS) !== - (backupFactory.freshHours ?? DEFAULT_BACKUP_FRESH_HOURS) || - (backupCurrent.staleHours ?? DEFAULT_BACKUP_STALE_HOURS) !== - (backupFactory.staleHours ?? DEFAULT_BACKUP_STALE_HOURS) || - (backupCurrent.alertOrphaned ?? true) !== (backupFactory.alertOrphaned ?? true) || - ignoreDiff - ? 1 - : 0; - }); - - // Process guests with their overrides and group by node - const guestsGroupedByNode = createMemo>((prev = {}) => { - // If we're currently editing, return the previous value to avoid re-renders - if (editingId()) { - return prev; - } - - const search = searchTerm().toLowerCase(); - const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o])); - - const guests = (props.allGuests() ?? []).map((guest) => { - const gpd = guest.platformData - ? (unwrap(guest.platformData) as Record) - : undefined; - const vmid = (gpd?.vmid as number | undefined) ?? undefined; - const node = (gpd?.node as string | undefined) ?? ''; - const instance = (gpd?.instance as string | undefined) ?? guest.platformId ?? ''; - const guestId = guest.id; - const override = overridesMap.get(guestId); - const overrideSeverity = override?.poweredOffSeverity; - - // Check if any threshold values actually differ from defaults - const hasCustomThresholds = - override?.thresholds && - Object.keys(override.thresholds).some((key) => { - const k = key as keyof typeof override.thresholds; - return ( - override.thresholds[k] !== undefined && - override.thresholds[k] !== (props.guestDefaults as any)[k] - ); - }); - - // A guest has an override if it has custom thresholds OR is disabled OR has connectivity disabled - const hasOverride = - hasCustomThresholds || - override?.disabled || - override?.disableConnectivity || - overrideSeverity !== undefined || - false; - - return { - id: guestId, - name: getPreferredResourceDisplayName(guest), - displayName: getPreferredResourceDisplayName(guest), - rawName: guest.name, - type: 'guest' as const, - resourceType: guest.type === 'vm' ? 'VM' : 'Container', - vmid, - node, - instance, - status: guest.status, - hasOverride: hasOverride, - disabled: override?.disabled || false, - disableConnectivity: override?.disableConnectivity || false, - thresholds: override?.thresholds || {}, - defaults: props.guestDefaults, - backup: override?.backup || props.backupDefaults(), - snapshot: override?.snapshot || props.snapshotDefaults(), - poweredOffSeverity: overrideSeverity, - }; - }); - - const filteredGuests = search - ? guests.filter( - (g) => - g.name.toLowerCase().includes(search) || - g.vmid?.toString().includes(search) || - g.node?.toLowerCase().includes(search), - ) - : guests; - - // Group by instance (not node - node is just the hostname which may be duplicated) - // Instance is the disambiguated name like "px1" or "px1 (198.51.100.224)" - const grouped: Record = {}; - filteredGuests.forEach((guest) => { - const groupKey = guest.instance || guest.node || 'Unknown'; - if (!grouped[groupKey]) { - grouped[groupKey] = []; - } - grouped[groupKey].push(guest); - }); - - // Sort guests within each group by vmid - Object.keys(grouped).forEach((node) => { - grouped[node].sort((a, b) => { - if (a.vmid && b.vmid) return a.vmid - b.vmid; - return a.name.localeCompare(b.name); - }); - }); - - return grouped; - }, {}); - - const guestsFlat = createMemo(() => - Object.values(guestsGroupedByNode() ?? {}).flat(), - ); - - const guestGroupHeaderMeta = createMemo>(() => { - const meta: Record = {}; - (props.nodes ?? []).forEach((node) => { - const { headerMeta, keys } = buildNodeHeaderMeta(node); - keys.forEach((key) => { - meta[key] = headerMeta; - }); - }); - return meta; - }); - - // Process PBS servers with their overrides - const pbsServersWithOverrides = createMemo((prev = []) => { - // If we're currently editing, return the previous value to avoid re-renders - if (editingId()) { - return prev; - } - - const search = searchTerm().toLowerCase(); - const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o])); - - // Get PBS instances from props - const pbsInstances = props.pbsInstances || []; - - const pbsServers = pbsInstances.map((pbs) => { - // Offline PBS instances report zero metrics; keep them visible so connectivity toggles stay usable - // PBS IDs already have "pbs-" prefix from backend, don't double it - const pbsId = pbs.id; - const override = overridesMap.get(pbsId); - - // Check if any threshold values actually differ from defaults - const hasCustomThresholds = - override?.thresholds && - Object.keys(override.thresholds).some((key) => { - const k = key as keyof typeof override.thresholds; - // PBS uses pbsDefaults for CPU/Memory (not nodeDefaults) - return ( - override.thresholds[k] !== undefined && - override.thresholds[k] !== - (props.pbsDefaults?.[k as keyof typeof props.pbsDefaults] ?? (k === 'cpu' ? 80 : 85)) - ); - }); - - const disableConnectivity = override?.disableConnectivity || false; - const hasOverride = hasCustomThresholds || disableConnectivity; - - return { - id: pbsId, - name: pbs.name, - type: 'pbs' as const, - resourceType: 'PBS', - host: pbs.host, - status: pbs.status, - cpu: pbs.cpu, - memory: pbs.memory, - memoryUsed: pbs.memoryUsed, - memoryTotal: pbs.memoryTotal, - uptime: pbs.uptime, - hasOverride, - disabled: false, - disableConnectivity, - thresholds: override?.thresholds || {}, - defaults: { - cpu: props.pbsDefaults?.cpu ?? 80, - memory: props.pbsDefaults?.memory ?? 85, - }, - }; - }); - - if (search) { - return pbsServers.filter( - (p) => p.name.toLowerCase().includes(search) || p.host?.toLowerCase().includes(search), - ); - } - return pbsServers; - }, []); - - const pmgGlobalDefaults = createMemo>(() => { - const defaults = props.pmgThresholds(); - const record: Record = {}; - PMG_THRESHOLD_COLUMNS.forEach(({ key, normalized }) => { - const value = defaults[key]; - record[normalized] = typeof value === 'number' && Number.isFinite(value) ? value : 0; - }); - return record; - }); - const setPMGGlobalDefaults = ( value: | Record @@ -1364,154 +302,6 @@ export function ThresholdsTable(props: ThresholdsTableProps) { } }; - // Process PMG servers with their overrides - const pmgServersWithOverrides = createMemo((prev = []) => { - // If we're currently editing, return the previous value to avoid re-renders - if (editingId()) { - return prev; - } - - const search = searchTerm().toLowerCase(); - const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o])); - - // Get PMG instances from props - const pmgInstances = props.pmgInstances || []; - const defaultThresholds = pmgGlobalDefaults(); - - const pmgServers = pmgInstances.map((pmg) => { - // PMG IDs should already have appropriate prefix from backend - const pmgId = pmg.id; - const override = overridesMap.get(pmgId); - - const thresholdOverrides: Record = {}; - const overrideThresholds = (override?.thresholds ?? {}) as Record; - Object.entries(overrideThresholds).forEach(([rawKey, rawValue]) => { - if (typeof rawValue !== 'number' || Number.isNaN(rawValue)) return; - const normalizedKey = - PMG_KEY_TO_NORMALIZED.get(rawKey as keyof PMGThresholdDefaults) || - (PMG_NORMALIZED_TO_KEY.has(rawKey) ? rawKey : undefined); - if (!normalizedKey) return; - thresholdOverrides[normalizedKey] = rawValue; - }); - - const hasOverride = - override?.disableConnectivity || - override?.disabled || - Object.keys(thresholdOverrides).length > 0 || - false; - - return { - id: pmgId, - name: pmg.name, - type: 'pmg' as const, - resourceType: 'PMG', - host: pmg.host, - status: pmg.status, - hasOverride, - disabled: override?.disabled || false, - disableConnectivity: override?.disableConnectivity || false, - thresholds: thresholdOverrides, - defaults: { ...defaultThresholds }, - }; - }); - - if (search) { - return pmgServers.filter( - (p) => p.name.toLowerCase().includes(search) || p.host?.toLowerCase().includes(search), - ); - } - return pmgServers; - }, []); - - const storageCoords = (r: Resource): { node: string; instance: string } => { - const data = pd(r); - if (r.type === 'datastore') { - const instance = - (data?.pbsInstanceId as string | undefined) || r.parentId || r.platformId || 'pbs'; - const node = (data?.pbsInstanceName as string | undefined) || instance; - return { node, instance }; - } - return { - node: (data?.node as string | undefined) || '', - instance: (data?.instance as string | undefined) || r.platformId || '', - }; - }; - - const normalizeStorageStatus = (status: string | undefined): string => { - switch ((status ?? '').toLowerCase()) { - case 'online': - case 'running': - case 'available': - return 'available'; - default: - return 'offline'; - } - }; - - // Process storage with their overrides - const storageWithOverrides = createMemo((prev = []) => { - // If we're currently editing, return the previous value to avoid re-renders - if (editingId()) { - return prev; - } - - const search = searchTerm().toLowerCase(); - const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o])); - - const storageDevices = (props.storage ?? []).map((storage) => { - const override = overridesMap.get(storage.id); - const coords = storageCoords(storage); - - // Storage only has usage threshold - const hasCustomThresholds = - override?.thresholds?.usage !== undefined && - override.thresholds.usage !== props.storageDefault(); - - // A storage device has an override if it has custom thresholds OR is disabled - const hasOverride = hasCustomThresholds || override?.disabled || false; - - return { - id: storage.id, - name: getPreferredResourceDisplayName(storage), - displayName: getPreferredResourceDisplayName(storage), - rawName: storage.name, - type: 'storage' as const, - resourceType: 'Storage', - node: coords.node, - instance: coords.instance, - status: normalizeStorageStatus(storage.status), - hasOverride: hasOverride, - disabled: override?.disabled || false, - thresholds: override?.thresholds || {}, - defaults: { usage: props.storageDefault() }, - }; - }); - - if (search) { - return storageDevices.filter( - (s) => s.name.toLowerCase().includes(search) || s.node?.toLowerCase().includes(search), - ); - } - return storageDevices; - }, []); - - const storageGroupedByNode = createMemo>(() => { - const grouped: Record = {}; - storageWithOverrides().forEach((storage) => { - const key = storage.node?.trim() || 'Unassigned'; - if (!grouped[key]) { - grouped[key] = []; - } - grouped[key].push(storage); - }); - - Object.values(grouped).forEach((resources) => { - resources.sort((a, b) => a.name.localeCompare(b.name)); - }); - - return grouped; - }); - const summaryItems = createMemo(() => { try { const items = [ diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts index e9701e88b..0739447a5 100644 --- a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts @@ -1,12 +1,17 @@ import { createMemo } from 'solid-js'; import { unwrap } from 'solid-js/store'; +import { requiresGovernedResourceDisplay } from '@/types/resource'; import type { Resource } from '@/types/resource'; import type { GroupHeaderMeta } from '@/components/Alerts/ResourceTable'; import type { Resource as TableResource } from '@/components/Alerts/ResourceTable'; import { - getPreferredResourceDisplayName, + getAgentDiscoveryResourceId, + isAppContainerDiscoveryResourceType, +} from '@/utils/discoveryTarget'; +import { getPreferredResourceHostname, } from '@/utils/resourceIdentity'; +import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers'; import { PMG_THRESHOLD_COLUMNS, PMG_KEY_TO_NORMALIZED, @@ -27,10 +32,159 @@ export function useThresholdsData( props: ThresholdsTableProps, editingId: () => string | null, searchTerm: () => string, - pd: (r: Resource) => Record | undefined, - buildNodeHeaderMeta: (node: Resource) => { headerMeta: GroupHeaderMeta; keys: Set }, - getFriendlyNodeName: (value: string, clusterName?: string) => string, ) { + const pd = (r: Resource): Record | undefined => + r.platformData ? (unwrap(r.platformData) as Record) : undefined; + const asRecord = (value: unknown): Record | undefined => + value && typeof value === 'object' ? (value as Record) : undefined; + const asString = (value: unknown): string | undefined => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; + const uniqueIds = (...values: unknown[]): string[] => { + const ids: string[] = []; + const seen = new Set(); + values.forEach((value) => { + const normalized = asString(value); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + ids.push(normalized); + }); + return ids; + }; + const hostOverrideIdCandidates = (resource: Resource): string[] => { + const platformData = pd(resource); + const agent = asRecord(platformData?.agent); + const discoveryTarget = resource.discoveryTarget ?? null; + return uniqueIds( + getAgentDiscoveryResourceId(discoveryTarget), + discoveryTarget?.agentId, + resource.agent?.agentId, + agent?.agentId, + platformData?.agentId, + resource.id, + ); + }; + const hostActionId = (resource: Resource): string => + hostOverrideIdCandidates(resource)[0] || resource.id; + const dockerHostOverrideIdCandidates = (resource: Resource): string[] => { + const platformData = pd(resource); + const docker = asRecord(platformData?.docker); + const discoveryTarget = resource.discoveryTarget; + return uniqueIds( + isAppContainerDiscoveryResourceType(discoveryTarget?.resourceType) + ? discoveryTarget?.resourceId + : undefined, + docker?.hostSourceId, + platformData?.hostSourceId, + discoveryTarget?.agentId, + resource.id, + ); + }; + const dockerContainerOverrideIdCandidates = (host: Resource, shortId: string): string[] => + uniqueIds( + ...dockerHostOverrideIdCandidates(host).map((hostId) => `docker:${hostId}/${shortId}`), + ); + const findOverrideByCandidates = ( + overridesMap: Map, + candidates: string[], + ): Override | undefined => { + for (const candidate of candidates) { + const override = overridesMap.get(candidate); + if (override) { + return override; + } + } + return undefined; + }; + const getFriendlyNodeName = (value: string, clusterName?: string): string => { + if (!value) return value; + + const clusterLower = clusterName?.toLowerCase().trim(); + + const normalizeToken = (token?: string | null): string => { + if (!token) return ''; + let result = token + .replace(/\(.*?\)/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (clusterLower) { + result = result + .split(' ') + .filter((part) => part.toLowerCase() !== clusterLower) + .join(' ') + .trim(); + } + if (!result) return ''; + const firstWord = result.split(/\s+/)[0] || result; + const withoutDomain = firstWord.includes('.') + ? (firstWord.split('.')[0] ?? firstWord) + : firstWord; + return withoutDomain.trim(); + }; + + const parentheticalMatch = value.match(/\(([^)]+)\)/); + const parentheticalRaw = parentheticalMatch?.[1]?.trim(); + + let base = normalizeToken(value); + if (!base) { + base = value.trim(); + } + + const parenthetical = normalizeToken(parentheticalRaw); + if (parenthetical && parenthetical.toLowerCase() !== base.toLowerCase()) { + return parenthetical; + } + + return base; + }; + const getFriendlyAlertNodeName = ( + value: string, + policy?: Resource['policy'], + clusterName?: string, + ): string => (requiresGovernedResourceDisplay(policy) ? value : getFriendlyNodeName(value, clusterName)); + const buildNodeHeaderMeta = (node: Resource) => { + const data = pd(node); + const clusterName = (data?.clusterName as string | undefined) ?? undefined; + const isClusterMember = + (data?.isClusterMember as boolean | undefined) ?? Boolean(node.clusterId); + + const originalDisplayName = getAlertResourceDisplayLabel(node); + const friendlyName = getFriendlyAlertNodeName(originalDisplayName, node.policy, clusterName); + + const guestUrlValue = + typeof data?.guestURL === 'string' ? (data.guestURL as string).trim() : ''; + const hostValue = typeof data?.host === 'string' ? (data.host as string).trim() : ''; + + let host: string | undefined; + if (guestUrlValue && guestUrlValue !== '') { + host = guestUrlValue.startsWith('http') ? guestUrlValue : `https://${guestUrlValue}`; + } else if (hostValue && hostValue !== '') { + host = hostValue.startsWith('http') + ? hostValue + : `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`; + } else if (node.name) { + host = `https://${node.name.includes(':') ? node.name : `${node.name}:8006`}`; + } + + const headerMeta: GroupHeaderMeta = { + type: 'node', + displayName: friendlyName, + rawName: originalDisplayName, + host, + status: node.status, + clusterName: isClusterMember ? clusterName?.trim() || 'Cluster' : undefined, + isClusterMember, + }; + + const keys = new Set(); + [node.name, originalDisplayName, friendlyName].forEach((value) => { + if (value && value.trim()) { + keys.add(value.trim()); + } + }); + + return { headerMeta, keys }; + }; + // Passed-in blocks: const nodesWithOverrides = createMemo((prev = []) => { // If we're currently editing, return the previous value to avoid re-renders @@ -65,8 +219,8 @@ export function useThresholdsData( : undefined; const hasNote = Boolean(note && note.trim().length > 0); - const originalDisplayName = node.displayName?.trim() || node.name; - const friendlyName = getFriendlyNodeName(originalDisplayName, clusterName); + const originalDisplayName = getAlertResourceDisplayLabel(node); + const friendlyName = getFriendlyAlertNodeName(originalDisplayName, node.policy, clusterName); const rawName = node.name; const sanitizedName = friendlyName || originalDisplayName || rawName.split('.')[0] || rawName; // Build a best-effort management URL for the node @@ -131,7 +285,9 @@ export function useThresholdsData( const seen = new Set(); const agents: TableResource[] = (props.agents ?? []).map((agentResource) => { - const override = overridesMap.get(agentResource.id); + const idCandidates = hostOverrideIdCandidates(agentResource); + const override = findOverrideByCandidates(overridesMap, idCandidates); + const resourceId = override?.id || idCandidates[0] || agentResource.id; const hasCustomThresholds = (override as Override | undefined)?.thresholds && Object.keys((override as Override).thresholds).some((key: string) => { @@ -142,25 +298,27 @@ export function useThresholdsData( ); }); - const displayName = - agentResource.displayName?.trim() || - agentResource.identity?.hostname || - agentResource.name || - agentResource.id; + const displayName = getAlertResourceDisplayLabel(agentResource); const status = agentResource.status; + const data = pd(agentResource); + const agentData = asRecord(data?.agent); - seen.add(agentResource.id); + seen.add(resourceId); return { - id: agentResource.id, + id: resourceId, name: displayName, displayName, rawName: agentResource.identity?.hostname ?? agentResource.name, type: 'agent' as const, resourceType: 'Agent', - node: agentResource.identity?.hostname ?? agentResource.name, + node: displayName, instance: - (pd(agentResource)?.platform as string) || (pd(agentResource)?.osName as string) || '', + asString(agentData?.platform) || + asString(agentData?.osName) || + asString(data?.platform) || + asString(data?.osName) || + '', status, hasOverride: hasCustomThresholds || @@ -176,7 +334,7 @@ export function useThresholdsData( (props.overrides() ?? []) .filter( (override) => - (override as Override).type === 'agent' && !seen.has((override as Override).id), + (override as Override).type === 'agent' && !seen.has((override as Override).id), ) .forEach((override) => { const name = (override as Override).name?.trim() || (override as Override).id; @@ -231,13 +389,23 @@ export function useThresholdsData( // Extract disks from all agents (props.agents ?? []).forEach((agentResource) => { - const agentDisplayName = - agentResource.displayName?.trim() || - agentResource.identity?.hostname || - agentResource.name || - agentResource.id; + const agentDisplayName = getAlertResourceDisplayLabel(agentResource); + const agentIdCandidates = hostOverrideIdCandidates(agentResource); + const agentIdForActions = hostActionId(agentResource); + const platformData = pd(agentResource); + const platformAgent = asRecord(platformData?.agent); + const disksFromPlatformRoot = Array.isArray(platformData?.disks) ? platformData.disks : null; + const disksFromPlatformAgent = Array.isArray(platformAgent?.disks) + ? platformAgent.disks + : null; + const disksFromResourceAgent = Array.isArray(agentResource.agent?.disks) + ? agentResource.agent.disks + : null; - const disksForAgent = (pd(agentResource)?.disks ?? []) as Array<{ + const disksForAgent = (disksFromPlatformRoot || + disksFromPlatformAgent || + disksFromResourceAgent || + []) as Array<{ mountpoint?: string; device?: string; used?: number; @@ -247,12 +415,14 @@ export function useThresholdsData( disksForAgent.forEach((disk) => { const diskLabel = disk.mountpoint?.trim() || disk.device?.trim() || 'disk'; - const resourceId = agentDiskResourceID( - agentResource.id, - disk.mountpoint || '', - disk.device, + const resourceIdCandidates = uniqueIds( + ...agentIdCandidates.map((agentId) => + agentDiskResourceID(agentId, disk.mountpoint || '', disk.device), + ), ); - const override = overridesMap.get(resourceId); + const override = findOverrideByCandidates(overridesMap, resourceIdCandidates); + const resourceId = override?.id || resourceIdCandidates[0]; + if (!resourceId) return; const hasCustomThresholds = (override as Override | undefined)?.thresholds?.disk !== undefined && @@ -267,7 +437,7 @@ export function useThresholdsData( rawName: disk.device || diskLabel, type: 'agentDisk' as const, resourceType: 'Agent Disk', - host: agentResource.id, + host: agentIdForActions, node: agentDisplayName, instance: disk.type || '', status: agentResource.status, @@ -346,16 +516,18 @@ export function useThresholdsData( const seen = new Set(); const hosts: TableResource[] = (props.dockerHosts ?? []).map((host) => { - const originalName = getPreferredResourceDisplayName(host); - const friendlyName = getFriendlyNodeName(originalName); - const override = overridesMap.get(host.id); + const idCandidates = dockerHostOverrideIdCandidates(host); + const originalName = getAlertResourceDisplayLabel(host); + const friendlyName = getFriendlyAlertNodeName(originalName, host.policy); + const override = findOverrideByCandidates(overridesMap, idCandidates); + const resourceId = override?.id || idCandidates[0] || host.id; const disableConnectivity = (override as Override | undefined)?.disableConnectivity || false; const status = host.status; - seen.add(host.id); + seen.add(resourceId); return { - id: host.id, + id: resourceId, name: friendlyName, displayName: friendlyName, rawName: originalName, @@ -434,8 +606,10 @@ export function useThresholdsData( const seen = new Set(); (props.dockerHosts ?? []).forEach((host) => { - const hostLabel = getPreferredResourceDisplayName(host); - const friendlyHostName = getFriendlyNodeName(hostLabel); + const dockerHostIds = dockerHostOverrideIdCandidates(host); + const dockerHostIdForActions = dockerHostIds[0] || host.id; + const hostLabel = getAlertResourceDisplayLabel(host); + const friendlyHostName = getFriendlyAlertNodeName(hostLabel, host.policy); const hostLabelLower = hostLabel.toLowerCase(); const friendlyHostNameLower = friendlyHostName.toLowerCase(); @@ -446,8 +620,10 @@ export function useThresholdsData( const shortId = container.id.includes('/') ? (container.id.split('/').pop() ?? container.id) : container.id; - const resourceId = `docker:${host.id}/${shortId}`; - const override = overridesMap.get(resourceId); + const resourceIdCandidates = dockerContainerOverrideIdCandidates(host, shortId); + const override = findOverrideByCandidates(overridesMap, resourceIdCandidates); + const resourceId = + override?.id || resourceIdCandidates[0] || `docker:${dockerHostIdForActions}/${shortId}`; const overrideSeverity = (override as Override | undefined)?.poweredOffSeverity; const defaults = props.dockerDefaults as Record; @@ -468,7 +644,7 @@ export function useThresholdsData( overrideSeverity !== undefined || false; - const containerName = container.name?.replace(/^\/+/, '') || shortId; + const containerName = getAlertResourceDisplayLabel(container, shortId); const containerNameLower = containerName.toLowerCase(); const image = (pd(container)?.image as string) ?? ''; const imageLower = image.toLowerCase(); @@ -499,7 +675,7 @@ export function useThresholdsData( disableConnectivity: (override as Override | undefined)?.disableConnectivity || false, thresholds: (override as Override | undefined)?.thresholds || {}, defaults: props.dockerDefaults, - hostId: host.id, + hostId: dockerHostIdForActions, image, poweredOffSeverity: overrideSeverity, }; @@ -574,8 +750,8 @@ export function useThresholdsData( const dockerHostGroupMeta = createMemo>(() => { const meta: Record = {}; (props.dockerHosts ?? []).forEach((host) => { - const originalName = getPreferredResourceDisplayName(host); - const friendlyName = getFriendlyNodeName(originalName); + const originalName = getAlertResourceDisplayLabel(host); + const friendlyName = getFriendlyAlertNodeName(originalName, host.policy); const headerMeta: GroupHeaderMeta = { displayName: friendlyName, rawName: originalName, @@ -813,7 +989,9 @@ export function useThresholdsData( return { id: guestId, - name: guest.name, + name: getAlertResourceDisplayLabel(guest), + displayName: getAlertResourceDisplayLabel(guest), + rawName: guest.name, type: 'guest' as const, resourceType: guest.type === 'vm' ? 'VM' : 'Container', vmid, @@ -1066,7 +1244,9 @@ export function useThresholdsData( return { id: storage.id, - name: storage.name, + name: getAlertResourceDisplayLabel(storage), + displayName: getAlertResourceDisplayLabel(storage), + rawName: storage.name, type: 'storage' as const, resourceType: 'Storage', node: coords.node, diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 208fc4015..1a90323ec 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -5,6 +5,8 @@ import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.t import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw'; import alertScheduleTabSource from '@/features/alerts/tabs/ScheduleTab.tsx?raw'; import alertThresholdsTabSource from '@/features/alerts/tabs/ThresholdsTab.tsx?raw'; +import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw'; +import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw'; import { ALERT_TAB_SEGMENTS, @@ -186,6 +188,11 @@ describe('tab path helpers', () => { expect(alertHistoryTabSource).toContain('IncidentTimelinePanel'); expect(alertScheduleTabSource).toContain('getAlertConfigQuietHourSuppressOptions'); expect(alertThresholdsTabSource).toContain('ThresholdsTable'); + expect(thresholdsTableSource).toContain( + "import { useThresholdsData } from '@/features/alerts/thresholds/hooks/useThresholdsData';", + ); + expect(thresholdsTableSource).not.toContain('const nodesWithOverrides = createMemo'); + expect(thresholdsDataHookSource).toContain('export function useThresholdsData'); }); }); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 1a39b63e4..beeee0cbd 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -228,6 +228,8 @@ import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.t import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw'; import alertScheduleTabSource from '@/features/alerts/tabs/ScheduleTab.tsx?raw'; import alertThresholdsTabSource from '@/features/alerts/tabs/ThresholdsTab.tsx?raw'; +import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw'; +import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw'; import alertIncidentPresentationSource from '@/utils/alertIncidentPresentation.ts?raw'; import alertHistoryPresentationSource from '@/utils/alertHistoryPresentation.ts?raw'; import bulkEditDialogSource from '@/components/Alerts/BulkEditDialog.tsx?raw'; @@ -2258,6 +2260,11 @@ describe('frontend resource type boundaries', () => { expect(alertsPageSource).not.toContain('function ThresholdsTab('); expect(alertThresholdsTabSource).toContain("import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';"); expect(alertThresholdsTabSource).toContain('pmgThresholds={props.pmgThresholds}'); + expect(thresholdsTableSource).toContain( + "import { useThresholdsData } from '@/features/alerts/thresholds/hooks/useThresholdsData';", + ); + expect(thresholdsTableSource).not.toContain('const nodesWithOverrides = createMemo'); + expect(thresholdsDataHookSource).toContain('export function useThresholdsData'); expect(alertScheduleTabSource).toContain('getAlertGroupingCardClass'); expect(alertScheduleTabSource).toContain('getAlertGroupingCheckboxClass'); expect(alertScheduleTabSource).toContain('getAlertQuietDayButtonClass');