From 6408ab9decfeb81258a276170ff49026678eaf02 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 22 Mar 2026 17:49:44 +0000 Subject: [PATCH] Split thresholds family data owners --- .../v6/internal/subsystems/alerts.md | 17 +- .../subsystems/frontend-primitives.md | 20 +- .../__tests__/thresholdsResourceModel.test.ts | 60 + .../thresholds/hooks/useThresholdsData.ts | 1129 +---------------- .../hooks/useThresholdsDockerData.ts | 264 ++++ .../hooks/useThresholdsGuestData.ts | 112 ++ .../thresholds/hooks/useThresholdsHostData.ts | 277 ++++ .../hooks/useThresholdsInfrastructureData.ts | 186 +++ .../thresholds/thresholdsResourceModel.ts | 231 ++++ .../pages/__tests__/Alerts.helpers.test.ts | 22 + .../frontendResourceTypeBoundaries.test.ts | 20 + 11 files changed, 1213 insertions(+), 1125 deletions(-) create mode 100644 frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsResourceModel.test.ts create mode 100644 frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsDockerData.ts create mode 100644 frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts create mode 100644 frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsHostData.ts create mode 100644 frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts create mode 100644 frontend-modern/src/features/alerts/thresholds/thresholdsResourceModel.ts diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 7f535a23c..46e1a31f2 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -130,8 +130,17 @@ 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`, +That threshold editor data shaping now routes through +`frontend-modern/src/features/alerts/thresholds/thresholdsResourceModel.ts` +for shared override-ID compatibility, grouped resource normalization, and +storage status policy, while +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts` +stays the composition owner for the family-specific threshold projectors in +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsHostData.ts`, +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsDockerData.ts`, +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts`, +and +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts`. backup and snapshot default sanitization plus factory-drift policy now live in `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsRecoveryDefaultsState.ts`, while `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts` @@ -143,8 +152,8 @@ effects now route through `frontend-modern/src/components/Alerts/ThresholdsTable.tsx` is now limited to table interaction and presentation. New threshold row grouping, override-ID compatibility, resource normalization, or thresholds-table -controller logic should land in those hooks rather than being rebuilt inside -the table component. +controller logic should land in those threshold model and family hooks rather +than being rebuilt inside the table component. The alert resource thresholds editor now follows the same shape: shared metric normalization, bounds, value-resolution, and override-label logic live in `frontend-modern/src/components/Alerts/alertResourceTableModel.ts`, render-heavy diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 528febdab..837626b8c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -236,12 +236,15 @@ inline. The thresholds editor now follows that same split more tightly: `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts` must stay the table-shell owner for route sync and local UI state, while +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts` +stays the composition shell for threshold resource-family projectors, `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsRecoveryDefaultsState.ts` owns backup/snapshot default sanitization and factory-drift policy, and `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts` owns override save/bulk/toggle persistence and alert-removal side effects. The -table-shell hook should not re-accumulate raw override mutation logic or -recovery-threshold defaults policy inline. +table-shell hook should not re-accumulate raw override mutation logic, +recovery-threshold defaults policy, or resource-family projection engines +inline. The updates settings surface now follows the same presentation-owner rule. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` stays the @@ -430,10 +433,15 @@ 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 or controller owner. -Canonical threshold row shaping, override-ID compatibility, grouped resource -normalization, and thresholds-table controller state live in -`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`, -`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsRecoveryDefaultsState.ts`, +Canonical threshold row shaping now routes through +`frontend-modern/src/features/alerts/thresholds/thresholdsResourceModel.ts` +plus the family-owned feature hooks +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsHostData.ts`, +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsDockerData.ts`, +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts`, +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts`, +with `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts` +limited to composing them. Thresholds-table controller state lives in `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts`, so future cleanup should extend those feature hooks instead of rebuilding resource normalization or thresholds-table runtime state inside the table diff --git a/frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsResourceModel.test.ts b/frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsResourceModel.test.ts new file mode 100644 index 000000000..07c17b292 --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsResourceModel.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import type { Resource } from '@/types/resource'; + +import { + agentDiskResourceId, + hostOverrideIdCandidates, + normalizeStorageStatus, + uniqueIds, +} from '../thresholdsResourceModel'; + +describe('thresholdsResourceModel', () => { + it('deduplicates candidate identifiers while preserving order', () => { + expect(uniqueIds(' agent-1 ', undefined, 'agent-1', 'agent-2', '')).toEqual([ + 'agent-1', + 'agent-2', + ]); + }); + + it('builds host override candidates from the canonical resource identifiers', () => { + const resource = { + id: 'agent-runtime', + type: 'agent', + discoveryTarget: { + resourceType: 'agent', + resourceId: 'agent-discovery', + agentId: 'agent-discovery', + }, + agent: { + agentId: 'agent-runtime', + }, + platformData: { + agent: { + agentId: 'agent-platform', + }, + agentId: 'agent-platform', + }, + } as unknown as Resource; + + expect(hostOverrideIdCandidates(resource)).toEqual([ + 'agent-discovery', + 'agent-runtime', + 'agent-platform', + ]); + }); + + it('sanitizes agent disk ids with the backend-compatible label rules', () => { + expect(agentDiskResourceId('agent-1', '/var/lib/docker', '')).toBe( + 'agent:agent-1/disk:var-lib-docker', + ); + expect(agentDiskResourceId('agent-1', '', '/dev/sda1')).toBe('agent:agent-1/disk:dev-sda1'); + }); + + it('normalizes storage status to the table availability contract', () => { + expect(normalizeStorageStatus('online')).toBe('available'); + expect(normalizeStorageStatus('running')).toBe('available'); + expect(normalizeStorageStatus('offline')).toBe('offline'); + expect(normalizeStorageStatus(undefined)).toBe('offline'); + }); +}); diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts index b12d8b28d..be06305d4 100644 --- a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts @@ -1,1125 +1,24 @@ -import { createMemo } from 'solid-js'; -import { unwrap } from 'solid-js/store'; -import { requiresGovernedResourceDisplay } from '@/types/resource'; -import type { Resource } from '@/types/resource'; -import { - getAgentDiscoveryResourceId, - isAppContainerDiscoveryResourceType, -} from '@/utils/discoveryTarget'; -import { - getPreferredResourceHostname, -} from '@/utils/resourceIdentity'; -import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers'; -import { - PMG_THRESHOLD_COLUMNS, - PMG_KEY_TO_NORMALIZED, - PMG_NORMALIZED_TO_KEY, -} from '../constants'; -import type { PMGThresholdDefaults } from '@/types/alerts'; -import type { ThresholdsTableProps, Override } from '../types'; -import type { GroupHeaderMeta, Resource as TableResource } from '../tableTypes'; +import type { ThresholdsTableProps } from '../types'; +import { useThresholdsDockerData } from './useThresholdsDockerData'; +import { useThresholdsGuestData } from './useThresholdsGuestData'; +import { useThresholdsHostData } from './useThresholdsHostData'; +import { useThresholdsInfrastructureData } from './useThresholdsInfrastructureData'; export function useThresholdsData( props: ThresholdsTableProps, editingId: () => string | null, searchTerm: () => 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 - if (editingId()) { - return prev; - } - - const search = searchTerm().toLowerCase(); - const overridesMap = new Map((props.overrides() ?? []).map((o: Override) => [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 as Override | undefined)?.thresholds && - Object.keys((override as Override).thresholds).some((key: string) => { - const k = key as keyof Override['thresholds']; - return ( - (override as Override).thresholds[k] !== undefined && - (override as Override).thresholds[k] !== (props.nodeDefaults as any)[k] - ); - }); - - const note = - typeof (override as Override | undefined)?.note === 'string' - ? (override as Override).note - : undefined; - const hasNote = Boolean(note && note.trim().length > 0); - - 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 - // 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: originalDisplayName, - 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 as Override | undefined)?.disableConnectivity) || - false, - disabled: false, - disableConnectivity: (override as Override | undefined)?.disableConnectivity || false, - thresholds: (override as Override | undefined)?.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: Override) => [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 as Override | undefined)?.thresholds && - Object.keys((override as Override).thresholds).some((key: string) => { - const k = key as keyof Override['thresholds']; - return ( - (override as Override).thresholds[k] !== undefined && - (override as 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 as Override | undefined)?.disabled) || - Boolean((override as Override | undefined)?.disableConnectivity), - disabled: (override as Override | undefined)?.disabled || false, - disableConnectivity: (override as Override | undefined)?.disableConnectivity || false, - thresholds: (override as Override | undefined)?.thresholds || {}, - defaults: props.agentDefaults, - } satisfies TableResource; - }); - - (props.overrides() ?? []) - .filter( - (override) => - (override as Override).type === 'agent' && !seen.has((override as Override).id), - ) - .forEach((override) => { - const name = (override as Override).name?.trim() || (override as Override).id; - agents.push({ - id: (override as Override).id, - name, - displayName: name, - rawName: name, - type: 'agent' as const, - resourceType: 'Agent', - node: '', - instance: '', - status: 'unknown', - hasOverride: true, - disabled: (override as Override).disabled || false, - disableConnectivity: (override as Override).disableConnectivity || false, - thresholds: (override as 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: Override) => [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 as Override | undefined)?.thresholds?.disk !== undefined && - (override as 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 as Override | undefined)?.disabled), - disabled: (override as Override | undefined)?.disabled || false, - thresholds: (override as Override | undefined)?.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 as Override).type === 'agentDisk' && !seen.has((override as Override).id), - ) - .forEach((override) => { - const name = (override as Override).name || (override as Override).id; - disks.push({ - id: (override as 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 as Override).disabled || false, - thresholds: (override as 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: Override) => [o.id, o])); - const seen = new Set(); - - const hosts: TableResource[] = (props.dockerHosts ?? []).map((host) => { - 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(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 as Override | undefined)?.thresholds || {}, - defaults: {}, - editable: false, - } satisfies TableResource; - }); - - // Include any overrides referencing Docker hosts that are no longer reporting - (props.overrides() ?? []) - .filter( - (override) => - (override as Override).type === 'dockerHost' && !seen.has((override as Override).id), - ) - .forEach((override) => { - const originalName = (override as Override).name || (override as Override).id; - const friendlyName = getFriendlyNodeName(originalName); - hosts.push({ - id: (override as Override).id, - name: friendlyName, - displayName: friendlyName, - rawName: originalName, - type: 'dockerHost', - resourceType: 'Container Runtime', - node: (override as Override).node || '', - instance: (override as Override).instance || '', - status: 'unknown', - hasOverride: true, - disableConnectivity: (override as Override).disableConnectivity || false, - thresholds: (override as 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: Override) => [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 = getAlertResourceDisplayLabel(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 as Override | undefined)?.poweredOffSeverity; - - const defaults = props.dockerDefaults as Record; - const hasCustomThresholds = - (override as Override | undefined)?.thresholds && - Object.keys((override as Override).thresholds).some((key: string) => { - const k = key as keyof Override['thresholds']; - return ( - (override as Override).thresholds[k] !== undefined && - (override as Override).thresholds[k] !== defaults?.[k as keyof typeof defaults] - ); - }); - - const hasOverride = - hasCustomThresholds || - (override as Override | undefined)?.disabled || - (override as Override | undefined)?.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 as Override | undefined)?.disabled || false, - disableConnectivity: (override as Override | undefined)?.disableConnectivity || false, - thresholds: (override as Override | undefined)?.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 as Override).type === 'dockerContainer' && !seen.has((override as Override).id), - ) - .forEach((override) => { - const fallbackName = - (override as Override).name || - (override as Override).id.split('/').pop() || - (override as Override).id; - const group = 'Unassigned Containers'; - if (!groups[group]) { - groups[group] = []; - } - groups[group].push({ - id: (override as Override).id, - name: fallbackName, - type: 'dockerContainer', - resourceType: 'Container', - status: 'unknown', - hasOverride: true, - disabled: (override as Override).disabled || false, - disableConnectivity: (override as Override).disableConnectivity || false, - thresholds: (override as Override).thresholds || {}, - defaults: props.dockerDefaults, - poweredOffSeverity: (override as 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 = getAlertResourceDisplayLabel(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: string | undefined): key is string => Boolean(key && key.trim())) - .forEach((key: string) => { - meta[key.trim()] = headerMeta; - }); - }); - - meta['Unassigned Containers'] = { - displayName: 'Unassigned Containers', - status: 'unknown', - }; - - return meta; - }); - - // 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: Override) => [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 as Override | undefined)?.poweredOffSeverity; - - // Check if any threshold values actually differ from defaults - const hasCustomThresholds = - (override as Override | undefined)?.thresholds && - Object.keys((override as Override).thresholds).some((key: string) => { - const k = key as keyof Override['thresholds']; - return ( - (override as Override).thresholds[k] !== undefined && - (override as 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 as Override | undefined)?.disabled || - (override as Override | undefined)?.disableConnectivity || - overrideSeverity !== undefined || - false; - - return { - id: guestId, - name: getAlertResourceDisplayLabel(guest), - displayName: getAlertResourceDisplayLabel(guest), - rawName: guest.name, - type: 'guest' as const, - resourceType: guest.type === 'vm' ? 'VM' : 'Container', - vmid, - node, - instance, - status: guest.status, - hasOverride: hasOverride, - disabled: (override as Override | undefined)?.disabled || false, - disableConnectivity: (override as Override | undefined)?.disableConnectivity || false, - thresholds: (override as Override | undefined)?.thresholds || {}, - defaults: props.guestDefaults, - backup: (override as Override | undefined)?.backup || props.backupDefaults(), - snapshot: (override as Override | undefined)?.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 (10.0.2.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: string) => { - 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: Override) => [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 as Override | undefined)?.thresholds && - Object.keys((override as Override).thresholds).some((key: string) => { - const k = key as keyof Override['thresholds']; - // PBS uses pbsDefaults for CPU/Memory (not nodeDefaults) - return ( - (override as Override).thresholds[k] !== undefined && - (override as Override).thresholds[k] !== - (props.pbsDefaults?.[k as keyof typeof props.pbsDefaults] ?? (k === 'cpu' ? 80 : 85)) - ); - }); - - const disableConnectivity = (override as Override | undefined)?.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 as Override | undefined)?.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 }: { key: any; normalized: any }) => { - const value = defaults[key as keyof PMGThresholdDefaults]; - record[normalized] = typeof value === 'number' && Number.isFinite(value) ? value : 0; - }); - return record; - }); - - 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: Override) => [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 as Override | undefined)?.thresholds ?? {}) as Record< - string, - unknown - >; - 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 as Override | undefined)?.disableConnectivity || - (override as Override | undefined)?.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 as Override | undefined)?.disabled || false, - disableConnectivity: (override as Override | undefined)?.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: Override) => [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 as Override | undefined)?.thresholds?.usage !== undefined && - (override as Override).thresholds.usage !== props.storageDefault(); - - // A storage device has an override if it has custom thresholds OR is disabled - const hasOverride = - hasCustomThresholds || (override as Override | undefined)?.disabled || false; - - return { - id: storage.id, - name: getAlertResourceDisplayLabel(storage), - displayName: getAlertResourceDisplayLabel(storage), - rawName: storage.name, - type: 'storage' as const, - resourceType: 'Storage', - node: coords.node, - instance: coords.instance, - status: normalizeStorageStatus(storage.status), - hasOverride: hasOverride, - disabled: (override as Override | undefined)?.disabled || false, - thresholds: (override as Override | undefined)?.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 inputs = { props, editingId, searchTerm }; + const hostData = useThresholdsHostData(inputs); + const dockerData = useThresholdsDockerData(inputs); + const guestData = useThresholdsGuestData(inputs); + const infrastructureData = useThresholdsInfrastructureData(inputs); return { - nodesWithOverrides, - agentsWithOverrides, - agentDisksWithOverrides, - agentDisksGroupedByAgent, - dockerHostsWithOverrides, - dockerContainersByHostId, - dockerContainersGroupedByHost, - dockerContainersFlat, - totalDockerContainers, - dockerHostGroupMeta, - guestsGroupedByNode, - guestsFlat, - guestGroupHeaderMeta, - pbsServersWithOverrides, - pmgGlobalDefaults, - pmgServersWithOverrides, - storageWithOverrides, - storageGroupedByNode, + ...hostData, + ...dockerData, + ...guestData, + ...infrastructureData, }; } diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsDockerData.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsDockerData.ts new file mode 100644 index 000000000..99fc088fd --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsDockerData.ts @@ -0,0 +1,264 @@ +import { createMemo } from 'solid-js'; + +import { getPreferredResourceHostname } from '@/utils/resourceIdentity'; +import type { Resource } from '@/types/resource'; +import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers'; + +import type { GroupHeaderMeta, Resource as TableResource } from '../tableTypes'; +import { ThresholdsDataInputs } from '../thresholdsResourceModel'; +import { + createOverridesMap, + dockerContainerOverrideIdCandidates, + dockerHostOverrideIdCandidates, + findOverrideByCandidates, + getFriendlyAlertNodeName, + getFriendlyNodeName, + hasThresholdDiff, + platformData, +} from '../thresholdsResourceModel'; + +export function useThresholdsDockerData(inputs: ThresholdsDataInputs) { + const { props, editingId, searchTerm } = inputs; + + const dockerHostsWithOverrides = createMemo((prev = []) => { + if (editingId()) { + return prev; + } + + const search = searchTerm().toLowerCase(); + const overridesMap = createOverridesMap(props.overrides()); + const seen = new Set(); + + const hosts: TableResource[] = (props.dockerHosts ?? []).map((host) => { + 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?.disableConnectivity || false; + + seen.add(resourceId); + + return { + id: resourceId, + name: friendlyName, + displayName: friendlyName, + rawName: originalName, + type: 'dockerHost' as const, + resourceType: 'Container Runtime', + node: getPreferredResourceHostname(host), + instance: (platformData(host)?.platform as string) || (platformData(host)?.osName as string) || '', + status: host.status, + hasOverride: disableConnectivity, + disableConnectivity, + thresholds: override?.thresholds || {}, + defaults: {}, + editable: false, + } satisfies TableResource; + }); + + (props.overrides() ?? []) + .filter((override) => override.type === 'dockerHost' && !seen.has(override.id)) + .forEach((override) => { + const originalName = override.name || override.id; + const friendlyName = getFriendlyNodeName(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, + }); + }); + + return search ? hosts.filter((host) => host.name.toLowerCase().includes(search)) : 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; + }); + + const dockerContainersGroupedByHost = createMemo>((prev = {}) => { + if (editingId()) { + return prev; + } + + const search = searchTerm().toLowerCase(); + const overridesMap = createOverridesMap(props.overrides()); + const groups: Record = {}; + const seen = new Set(); + + (props.dockerHosts ?? []).forEach((host) => { + 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(); + 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 hasCustomThresholds = hasThresholdDiff( + override, + props.dockerDefaults as Record, + ); + const hasOverride = + hasCustomThresholds || + Boolean(override?.disabled) || + Boolean(override?.disableConnectivity) || + overrideSeverity !== undefined; + const containerName = getAlertResourceDisplayLabel(container, shortId); + const image = (platformData(container)?.image as string) ?? ''; + + const matchesSearch = + !search || + containerName.toLowerCase().includes(search) || + hostLabelLower.includes(search) || + friendlyHostNameLower.includes(search) || + image.toLowerCase().includes(search); + if (!matchesSearch) return; + + const groupKey = friendlyHostName || hostLabel; + const resource: TableResource = { + id: resourceId, + name: containerName, + type: 'dockerContainer', + resourceType: 'Container', + node: groupKey, + instance: hostHostname, + status: container.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); + }); + }); + + (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; + } + + 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 = getAlertResourceDisplayLabel(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: string | undefined): key is string => Boolean(key && key.trim())) + .forEach((key: string) => { + meta[key.trim()] = headerMeta; + }); + }); + + meta['Unassigned Containers'] = { + displayName: 'Unassigned Containers', + status: 'unknown', + }; + + return meta; + }); + + return { + dockerHostsWithOverrides, + dockerContainersByHostId, + dockerContainersGroupedByHost, + dockerContainersFlat, + totalDockerContainers, + dockerHostGroupMeta, + }; +} diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts new file mode 100644 index 000000000..9b75db253 --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts @@ -0,0 +1,112 @@ +import { createMemo } from 'solid-js'; + +import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers'; + +import type { GroupHeaderMeta, Resource as TableResource } from '../tableTypes'; +import { ThresholdsDataInputs } from '../thresholdsResourceModel'; +import { + buildNodeHeaderMeta, + createOverridesMap, + hasThresholdDiff, + platformData, +} from '../thresholdsResourceModel'; + +export function useThresholdsGuestData(inputs: ThresholdsDataInputs) { + const { props, editingId, searchTerm } = inputs; + + const guestsGroupedByNode = createMemo>((prev = {}) => { + if (editingId()) { + return prev; + } + + const search = searchTerm().toLowerCase(); + const overridesMap = createOverridesMap(props.overrides()); + + const guests = (props.allGuests() ?? []).map((guest) => { + const data = platformData(guest); + const vmid = (data?.vmid as number | undefined) ?? undefined; + const node = (data?.node as string | undefined) ?? ''; + const instance = (data?.instance as string | undefined) ?? guest.platformId ?? ''; + const override = overridesMap.get(guest.id); + const overrideSeverity = override?.poweredOffSeverity; + const hasCustomThresholds = hasThresholdDiff( + override, + props.guestDefaults as Record, + ); + const hasOverride = + hasCustomThresholds || + Boolean(override?.disabled) || + Boolean(override?.disableConnectivity) || + overrideSeverity !== undefined; + + return { + id: guest.id, + name: getAlertResourceDisplayLabel(guest), + displayName: getAlertResourceDisplayLabel(guest), + rawName: guest.name, + type: 'guest' as const, + resourceType: guest.type === 'vm' ? 'VM' : 'Container', + vmid, + node, + instance, + status: guest.status, + 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( + (guest) => + guest.name.toLowerCase().includes(search) || + guest.vmid?.toString().includes(search) || + guest.node?.toLowerCase().includes(search), + ) + : guests; + + const grouped: Record = {}; + filteredGuests.forEach((guest) => { + const groupKey = guest.instance || guest.node || 'Unknown'; + if (!grouped[groupKey]) { + grouped[groupKey] = []; + } + grouped[groupKey].push(guest); + }); + + 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: string) => { + meta[key] = headerMeta; + }); + }); + return meta; + }); + + return { + guestsGroupedByNode, + guestsFlat, + guestGroupHeaderMeta, + }; +} diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsHostData.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsHostData.ts new file mode 100644 index 000000000..7cea94460 --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsHostData.ts @@ -0,0 +1,277 @@ +import { createMemo } from 'solid-js'; + +import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers'; + +import type { Resource as TableResource } from '../tableTypes'; +import { ThresholdsDataInputs } from '../thresholdsResourceModel'; +import { + agentDiskResourceId, + createOverridesMap, + findOverrideByCandidates, + getFriendlyAlertNodeName, + hasThresholdDiff, + hostActionId, + hostOverrideIdCandidates, + platformData, + readRecord, + readString, +} from '../thresholdsResourceModel'; + +export function useThresholdsHostData(inputs: ThresholdsDataInputs) { + const { props, editingId, searchTerm } = inputs; + + const nodesWithOverrides = createMemo((prev = []) => { + if (editingId()) { + return prev; + } + + const search = searchTerm().toLowerCase(); + const overridesMap = createOverridesMap(props.overrides()); + + const nodes = (props.nodes ?? []).map((node) => { + const override = overridesMap.get(node.id); + const data = platformData(node); + const clusterName = (data?.clusterName as string | undefined) ?? undefined; + const isClusterMember = + (data?.isClusterMember as boolean | undefined) ?? Boolean(node.clusterId); + const hasCustomThresholds = hasThresholdDiff( + override, + props.nodeDefaults as Record, + ); + const note = typeof override?.note === 'string' ? override.note : undefined; + const hasNote = Boolean(note && note.trim().length > 0); + + const originalDisplayName = getAlertResourceDisplayLabel(node); + const friendlyName = getFriendlyAlertNodeName(originalDisplayName, node.policy, clusterName); + const rawName = node.name; + const sanitizedName = friendlyName || originalDisplayName || rawName.split('.')[0] || rawName; + const guestUrlValue = typeof data?.guestURL === 'string' ? data.guestURL.trim() : ''; + const hostValue = (typeof data?.host === 'string' ? data.host.trim() : '') || rawName; + const normalizedHost = + guestUrlValue && guestUrlValue !== '' + ? guestUrlValue.startsWith('http') + ? guestUrlValue + : `https://${guestUrlValue}` + : hostValue.startsWith('http://') || hostValue.startsWith('https://') + ? hostValue + : `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`; + + return { + id: node.id, + name: sanitizedName, + displayName: sanitizedName, + rawName: originalDisplayName, + 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), + disabled: false, + disableConnectivity: override?.disableConnectivity || false, + thresholds: override?.thresholds || {}, + defaults: props.nodeDefaults, + clusterName: isClusterMember ? clusterName?.trim() : undefined, + isClusterMember, + instance: node.platformId, + note, + } satisfies TableResource; + }); + + return search ? nodes.filter((node) => node.name.toLowerCase().includes(search)) : nodes; + }, []); + + const agentsWithOverrides = createMemo((prev = []) => { + if (editingId()) { + return prev; + } + + const search = searchTerm().toLowerCase(); + const overridesMap = createOverridesMap(props.overrides()); + 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 = hasThresholdDiff( + override, + props.agentDefaults as Record, + ); + const displayName = getAlertResourceDisplayLabel(agentResource); + const data = platformData(agentResource); + const agentData = readRecord(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: + readString(agentData?.platform) || + readString(agentData?.osName) || + readString(data?.platform) || + readString(data?.osName) || + '', + status: agentResource.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); + }); + + return search ? agents.filter((agent) => agent.name.toLowerCase().includes(search)) : agents; + }, []); + + const agentDisksWithOverrides = createMemo((prev = []) => { + if (editingId()) { + return prev; + } + + const search = searchTerm().toLowerCase(); + const overridesMap = createOverridesMap(props.overrides()); + const seen = new Set(); + const disks: TableResource[] = []; + + (props.agents ?? []).forEach((agentResource) => { + const agentDisplayName = getAlertResourceDisplayLabel(agentResource); + const agentIdCandidates = hostOverrideIdCandidates(agentResource); + const agentIdForActions = hostActionId(agentResource); + const data = platformData(agentResource); + const platformAgent = readRecord(data?.agent); + const disksFromPlatformRoot = Array.isArray(data?.disks) ? data.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 = 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 = hasThresholdDiff(override, { + 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); + }); + }); + + (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 }, + }); + }); + + return search + ? disks.filter( + (disk) => disk.name.toLowerCase().includes(search) || disk.node?.toLowerCase().includes(search), + ) + : disks; + }, []); + + 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); + }); + + Object.values(grouped).forEach((resources) => { + resources.sort((a, b) => a.name.localeCompare(b.name)); + }); + + return grouped; + }); + + return { + nodesWithOverrides, + agentsWithOverrides, + agentDisksWithOverrides, + agentDisksGroupedByAgent, + }; +} diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts new file mode 100644 index 000000000..a2f027056 --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts @@ -0,0 +1,186 @@ +import { createMemo } from 'solid-js'; + +import type { PMGThresholdDefaults } from '@/types/alerts'; +import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers'; + +import { PMG_KEY_TO_NORMALIZED, PMG_NORMALIZED_TO_KEY, PMG_THRESHOLD_COLUMNS } from '../constants'; +import type { Resource as TableResource } from '../tableTypes'; +import { ThresholdsDataInputs } from '../thresholdsResourceModel'; +import { + createOverridesMap, + hasThresholdDiff, + normalizeStorageStatus, + storageCoords, +} from '../thresholdsResourceModel'; + +export function useThresholdsInfrastructureData(inputs: ThresholdsDataInputs) { + const { props, editingId, searchTerm } = inputs; + + const pbsServersWithOverrides = createMemo((prev = []) => { + if (editingId()) { + return prev; + } + + const search = searchTerm().toLowerCase(); + const overridesMap = createOverridesMap(props.overrides()); + const pbsServers = (props.pbsInstances || []).map((pbs) => { + const override = overridesMap.get(pbs.id); + const hasCustomThresholds = hasThresholdDiff(override, { + cpu: props.pbsDefaults?.cpu ?? 80, + memory: props.pbsDefaults?.memory ?? 85, + }); + const disableConnectivity = override?.disableConnectivity || false; + + return { + id: pbs.id, + 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: hasCustomThresholds || disableConnectivity, + disabled: false, + disableConnectivity, + thresholds: override?.thresholds || {}, + defaults: { + cpu: props.pbsDefaults?.cpu ?? 80, + memory: props.pbsDefaults?.memory ?? 85, + }, + }; + }); + + return search + ? pbsServers.filter( + (pbs) => pbs.name.toLowerCase().includes(search) || pbs.host?.toLowerCase().includes(search), + ) + : pbsServers; + }, []); + + const pmgGlobalDefaults = createMemo>(() => { + const defaults = props.pmgThresholds(); + const record: Record = {}; + PMG_THRESHOLD_COLUMNS.forEach(({ key, normalized }) => { + const value = defaults[key as keyof PMGThresholdDefaults]; + record[normalized] = typeof value === 'number' && Number.isFinite(value) ? value : 0; + }); + return record; + }); + + const pmgServersWithOverrides = createMemo((prev = []) => { + if (editingId()) { + return prev; + } + + const search = searchTerm().toLowerCase(); + const overridesMap = createOverridesMap(props.overrides()); + const defaultThresholds = pmgGlobalDefaults(); + + const pmgServers = (props.pmgInstances || []).map((pmg) => { + const override = overridesMap.get(pmg.id); + 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 = + Boolean(override?.disableConnectivity) || + Boolean(override?.disabled) || + Object.keys(thresholdOverrides).length > 0; + + return { + id: pmg.id, + 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 }, + }; + }); + + return search + ? pmgServers.filter( + (pmg) => pmg.name.toLowerCase().includes(search) || pmg.host?.toLowerCase().includes(search), + ) + : pmgServers; + }, []); + + const storageWithOverrides = createMemo((prev = []) => { + if (editingId()) { + return prev; + } + + const search = searchTerm().toLowerCase(); + const overridesMap = createOverridesMap(props.overrides()); + + const storageDevices = (props.storage ?? []).map((storage) => { + const override = overridesMap.get(storage.id); + const coords = storageCoords(storage); + const hasCustomThresholds = hasThresholdDiff(override, { usage: props.storageDefault() }); + + return { + id: storage.id, + name: getAlertResourceDisplayLabel(storage), + displayName: getAlertResourceDisplayLabel(storage), + rawName: storage.name, + type: 'storage' as const, + resourceType: 'Storage', + node: coords.node, + instance: coords.instance, + status: normalizeStorageStatus(storage.status), + hasOverride: hasCustomThresholds || Boolean(override?.disabled), + disabled: override?.disabled || false, + thresholds: override?.thresholds || {}, + defaults: { usage: props.storageDefault() }, + }; + }); + + return search + ? storageDevices.filter( + (storage) => + storage.name.toLowerCase().includes(search) || storage.node?.toLowerCase().includes(search), + ) + : 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; + }); + + return { + pbsServersWithOverrides, + pmgGlobalDefaults, + pmgServersWithOverrides, + storageWithOverrides, + storageGroupedByNode, + }; +} diff --git a/frontend-modern/src/features/alerts/thresholds/thresholdsResourceModel.ts b/frontend-modern/src/features/alerts/thresholds/thresholdsResourceModel.ts new file mode 100644 index 000000000..ba7c4934f --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/thresholdsResourceModel.ts @@ -0,0 +1,231 @@ +import { unwrap } from 'solid-js/store'; + +import { requiresGovernedResourceDisplay } from '@/types/resource'; +import type { Resource } from '@/types/resource'; +import { + getAgentDiscoveryResourceId, + isAppContainerDiscoveryResourceType, +} from '@/utils/discoveryTarget'; +import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers'; + +import type { GroupHeaderMeta } from './tableTypes'; +import type { Override, ThresholdsTableProps } from './types'; + +export interface ThresholdsDataInputs { + props: ThresholdsTableProps; + editingId: () => string | null; + searchTerm: () => string; +} + +export const platformData = (resource: Resource): Record | undefined => + resource.platformData ? (unwrap(resource.platformData) as Record) : undefined; + +export const readRecord = (value: unknown): Record | undefined => + value && typeof value === 'object' ? (value as Record) : undefined; + +export const readString = (value: unknown): string | undefined => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; + +export const uniqueIds = (...values: unknown[]): string[] => { + const ids: string[] = []; + const seen = new Set(); + values.forEach((value) => { + const normalized = readString(value); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + ids.push(normalized); + }); + return ids; +}; + +export const createOverridesMap = (overrides: Override[] | undefined) => + new Map((overrides ?? []).map((override) => [override.id, override])); + +export const hasThresholdDiff = ( + override: Override | undefined, + defaults: Record, +) => + Boolean( + override?.thresholds && + Object.keys(override.thresholds).some((key) => { + const thresholdKey = key as keyof Override['thresholds']; + return ( + override.thresholds[thresholdKey] !== undefined && + override.thresholds[thresholdKey] !== defaults[key] + ); + }), + ); + +export function hostOverrideIdCandidates(resource: Resource): string[] { + const data = platformData(resource); + const agent = readRecord(data?.agent); + const discoveryTarget = resource.discoveryTarget ?? null; + return uniqueIds( + getAgentDiscoveryResourceId(discoveryTarget), + discoveryTarget?.agentId, + resource.agent?.agentId, + agent?.agentId, + data?.agentId, + resource.id, + ); +} + +export const hostActionId = (resource: Resource): string => + hostOverrideIdCandidates(resource)[0] || resource.id; + +export const dockerHostOverrideIdCandidates = (resource: Resource): string[] => { + const data = platformData(resource); + const docker = readRecord(data?.docker); + const discoveryTarget = resource.discoveryTarget; + return uniqueIds( + isAppContainerDiscoveryResourceType(discoveryTarget?.resourceType) + ? discoveryTarget?.resourceId + : undefined, + docker?.hostSourceId, + data?.hostSourceId, + discoveryTarget?.agentId, + resource.id, + ); +}; + +export const dockerContainerOverrideIdCandidates = (host: Resource, shortId: string): string[] => + uniqueIds( + ...dockerHostOverrideIdCandidates(host).map((hostId) => `docker:${hostId}/${shortId}`), + ); + +export const findOverrideByCandidates = ( + overridesMap: Map, + candidates: string[], +): Override | undefined => { + for (const candidate of candidates) { + const override = overridesMap.get(candidate); + if (override) { + return override; + } + } + return undefined; +}; + +export 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; +}; + +export const getFriendlyAlertNodeName = ( + value: string, + policy?: Resource['policy'], + clusterName?: string, +): string => (requiresGovernedResourceDisplay(policy) ? value : getFriendlyNodeName(value, clusterName)); + +export function buildNodeHeaderMeta(node: Resource) { + const data = platformData(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.trim() : ''; + const hostValue = typeof data?.host === 'string' ? data.host.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 }; +} + +export const agentDiskResourceId = (agentId: string, mountpoint: string, device?: string): string => { + let label = (mountpoint?.trim() || device?.trim() || 'disk').toLowerCase(); + label = label + .replace(/[^a-z0-9]/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-|-$/g, ''); + if (!label) label = 'unknown'; + return `agent:${agentId}/disk:${label}`; +}; + +export const storageCoords = (resource: Resource): { node: string; instance: string } => { + const data = platformData(resource); + if (resource.type === 'datastore') { + const instance = + (data?.pbsInstanceId as string | undefined) || resource.parentId || resource.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) || resource.platformId || '', + }; +}; + +export const normalizeStorageStatus = (status: string | undefined): string => { + switch ((status ?? '').toLowerCase()) { + case 'online': + case 'running': + case 'available': + return 'available'; + default: + return 'offline'; + } +}; diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index af7306123..9eda5e715 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -20,9 +20,14 @@ import alertThresholdsTabSource from '@/features/alerts/tabs/ThresholdsTab.tsx?r import recentAlertsPanelSource from '@/components/Alerts/RecentAlertsPanel.tsx?raw'; import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw'; import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw'; +import thresholdsHostDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsHostData.ts?raw'; +import thresholdsDockerDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsDockerData.ts?raw'; +import thresholdsGuestDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsGuestData.ts?raw'; +import thresholdsInfrastructureDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts?raw'; import thresholdsRecoveryDefaultsStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsRecoveryDefaultsState.ts?raw'; import thresholdsTableStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsTableState.ts?raw'; import thresholdsOverrideMutationsHookSource from '@/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts?raw'; +import thresholdsResourceModelSource from '@/features/alerts/thresholds/thresholdsResourceModel.ts?raw'; import { ALERT_TAB_SEGMENTS, @@ -325,6 +330,23 @@ describe('tab path helpers', () => { expect(thresholdsTableSource).not.toContain('const [searchTerm, setSearchTerm] = createSignal'); expect(thresholdsTableSource).not.toContain('const handleTabClick ='); expect(thresholdsDataHookSource).toContain('export function useThresholdsData'); + expect(thresholdsDataHookSource).toContain('useThresholdsHostData(inputs)'); + expect(thresholdsDataHookSource).toContain('useThresholdsDockerData(inputs)'); + expect(thresholdsDataHookSource).toContain('useThresholdsGuestData(inputs)'); + expect(thresholdsDataHookSource).toContain('useThresholdsInfrastructureData(inputs)'); + expect(thresholdsDataHookSource).not.toContain('const hostOverrideIdCandidates ='); + expect(thresholdsDataHookSource).not.toContain('const dockerContainersGroupedByHost = createMemo'); + expect(thresholdsHostDataHookSource).toContain('export function useThresholdsHostData'); + expect(thresholdsHostDataHookSource).toContain('hostOverrideIdCandidates(agentResource)'); + expect(thresholdsDockerDataHookSource).toContain('export function useThresholdsDockerData'); + expect(thresholdsDockerDataHookSource).toContain('dockerContainerOverrideIdCandidates'); + expect(thresholdsGuestDataHookSource).toContain('export function useThresholdsGuestData'); + expect(thresholdsInfrastructureDataHookSource).toContain( + 'export function useThresholdsInfrastructureData', + ); + expect(thresholdsResourceModelSource).toContain('export function hostOverrideIdCandidates'); + expect(thresholdsResourceModelSource).toContain('export function buildNodeHeaderMeta'); + expect(thresholdsResourceModelSource).toContain('export const normalizeStorageStatus'); expect(thresholdsTableStateHookSource).toContain('export function useThresholdsTableState'); expect(thresholdsTableStateHookSource).toContain('useThresholdsData(props, editingId, searchTerm)'); expect(thresholdsTableStateHookSource).toContain('useThresholdsRecoveryDefaultsState(props)'); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 8f29172ff..e424f00fd 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -342,9 +342,14 @@ 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 thresholdsHostDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsHostData.ts?raw'; +import thresholdsDockerDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsDockerData.ts?raw'; +import thresholdsGuestDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsGuestData.ts?raw'; +import thresholdsInfrastructureDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsInfrastructureData.ts?raw'; import thresholdsRecoveryDefaultsStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsRecoveryDefaultsState.ts?raw'; import thresholdsTableStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsTableState.ts?raw'; import thresholdsOverrideMutationsHookSource from '@/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts?raw'; +import thresholdsResourceModelSource from '@/features/alerts/thresholds/thresholdsResourceModel.ts?raw'; import alertIncidentPresentationSource from '@/utils/alertIncidentPresentation.ts?raw'; import alertHistoryPresentationSource from '@/utils/alertHistoryPresentation.ts?raw'; import bulkEditDialogSource from '@/components/Alerts/BulkEditDialog.tsx?raw'; @@ -2918,6 +2923,21 @@ describe('frontend resource type boundaries', () => { expect(thresholdsTableSource).not.toContain('const [searchTerm, setSearchTerm] = createSignal'); expect(thresholdsTableSource).not.toContain('const handleTabClick ='); expect(thresholdsDataHookSource).toContain('export function useThresholdsData'); + expect(thresholdsDataHookSource).toContain('useThresholdsHostData(inputs)'); + expect(thresholdsDataHookSource).toContain('useThresholdsDockerData(inputs)'); + expect(thresholdsDataHookSource).toContain('useThresholdsGuestData(inputs)'); + expect(thresholdsDataHookSource).toContain('useThresholdsInfrastructureData(inputs)'); + expect(thresholdsDataHookSource).not.toContain('const hostOverrideIdCandidates ='); + expect(thresholdsDataHookSource).not.toContain('const dockerContainersGroupedByHost = createMemo'); + expect(thresholdsHostDataHookSource).toContain('export function useThresholdsHostData'); + expect(thresholdsDockerDataHookSource).toContain('export function useThresholdsDockerData'); + expect(thresholdsGuestDataHookSource).toContain('export function useThresholdsGuestData'); + expect(thresholdsInfrastructureDataHookSource).toContain( + 'export function useThresholdsInfrastructureData', + ); + expect(thresholdsResourceModelSource).toContain('export function hostOverrideIdCandidates'); + expect(thresholdsResourceModelSource).toContain('export function buildNodeHeaderMeta'); + expect(thresholdsResourceModelSource).toContain('export const normalizeStorageStatus'); expect(thresholdsTableStateHookSource).toContain('export function useThresholdsTableState'); expect(thresholdsTableStateHookSource).toContain('useCollapsedSections()'); expect(thresholdsTableStateHookSource).toContain('useThresholdsData(props, editingId, searchTerm)');