From 44249ed048d112ccd5cf1cceb021e779efad0f91 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 23 Mar 2026 00:21:11 +0000 Subject: [PATCH] Split resource detail drawer runtime owners --- .../v6/internal/subsystems/registry.json | 4 + .../internal/subsystems/unified-resources.md | 32 +- .../ResourceDetailDrawer.history.test.tsx | 11 + .../useResourceDetailDrawerDerivedState.ts | 591 +++++++++++++ .../useResourceDetailDrawerHistoryState.ts | 143 +++ .../useResourceDetailDrawerState.ts | 831 +----------------- .../frontendResourceTypeBoundaries.test.ts | 12 +- 7 files changed, 796 insertions(+), 828 deletions(-) create mode 100644 frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts create mode 100644 frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b9d73e1c4..7407c3694 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -3166,6 +3166,8 @@ "frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx", "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", "frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", "frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx", @@ -3248,6 +3250,8 @@ "frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx", "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", "frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts", "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts", "frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 8f243550d..540c2c3de 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -53,12 +53,14 @@ cross-source deduplication. 31. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` 32. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` 33. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` -34. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts` -35. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` -36. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx` -37. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts` -38. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx` -39. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts` +34. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts` +35. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts` +36. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts` +37. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` +38. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx` +39. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts` +40. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx` +41. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts` ## Shared Boundaries @@ -146,9 +148,12 @@ Those same relationship changes now summarize the actual edge(s) in `from` and needing the drawer to reconstruct an edge summary from raw endpoints. The infrastructure resource drawer now follows the explicit shell/state/render split used elsewhere in v6: `ResourceDetailDrawer.tsx` owns composition, -`useResourceDetailDrawerState.ts` owns runtime state and fetch orchestration, -and the overview/debug render-heavy surfaces live in dedicated drawer-local -owners instead of staying inline in the shell. +`useResourceDetailDrawerState.ts` owns composition of drawer-local state, +`useResourceDetailDrawerHistoryState.ts` owns facet/intelligence/timeline +runtime orchestration, `useResourceDetailDrawerDerivedState.ts` owns the +canonical drawer derivation layer, and the overview/debug render-heavy +surfaces live in dedicated drawer-local owners instead of staying inline in +the shell. The backend AI and Patrol context renderers now derive their canonical change kind, source type, source adapter, actor, reason, and related-resource fragments from `internal/unifiedresources/change_presentation.go`, so the @@ -286,9 +291,12 @@ that shared routing contract instead of reintroducing dashboard-local path builders. That drawer shell now routes its canonical timeline filter, facet-bundle, and resource-intelligence state through -`frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`, -so unified-resource history and investigation orchestration has one explicit -frontend owner instead of accumulating inline beside the JSX surface. +`frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts`, +while canonical identity, source, service, and debug derivations route through +`frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts` +and `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts` +stays the composition owner, so unified-resource history and investigation +orchestration no longer accumulate inline beside the drawer-local model layer. The shared `ResourceFacetSummary` consumer now omits capability and relationship badges from the default table/detail surface entirely, while the backend contract keeps capability and relationship data on the owned resource diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 88e4a0f95..15062c3b1 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -5,6 +5,9 @@ import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw'; import discoveryTabStateSource from '@/components/Discovery/useDiscoveryTabState.ts?raw'; import resourceDetailDrawerShellSource from '@/components/Infrastructure/ResourceDetailDrawer.tsx?raw'; import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw'; +import resourceDetailDrawerHistoryStateSource from '@/components/Infrastructure/useResourceDetailDrawerHistoryState.ts?raw'; +import resourceDetailDrawerDerivedStateSource from '@/components/Infrastructure/useResourceDetailDrawerDerivedState.ts?raw'; +import resourceDetailDrawerStateSource from '@/components/Infrastructure/useResourceDetailDrawerState.ts?raw'; import type { Resource } from '@/types/resource'; import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer'; @@ -119,6 +122,14 @@ describe('ResourceDetailDrawer change history section', () => { ); expect(resourceDetailDrawerShellSource).toContain("from './ResourceDetailDrawerDebugTab'"); expect(resourceDetailDrawerShellSource).not.toContain('Change history'); + expect(resourceDetailDrawerStateSource).toContain("from './useResourceDetailDrawerHistoryState'"); + expect(resourceDetailDrawerStateSource).toContain("from './useResourceDetailDrawerDerivedState'"); + expect(resourceDetailDrawerStateSource).not.toContain('createResource('); + expect(resourceDetailDrawerHistoryStateSource).toContain('createResource('); + expect(resourceDetailDrawerHistoryStateSource).toContain('ResourceAPI.getFacetBundle'); + expect(resourceDetailDrawerHistoryStateSource).toContain('AIAPI.getResourceIntelligence'); + expect(resourceDetailDrawerDerivedStateSource).toContain('buildWorkloadsHref'); + expect(resourceDetailDrawerDerivedStateSource).toContain('toDiscoveryConfig'); }); it('keeps compact timeline summary chips in overview while showing one embedded change history section', async () => { diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts new file mode 100644 index 000000000..33143df11 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -0,0 +1,591 @@ +import { createMemo, type Accessor } from 'solid-js'; +import type { Resource } from '@/types/resource'; +import { requiresGovernedResourceDisplay } from '@/types/resource'; +import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; +import { getAgentStatusIndicator } from '@/utils/status'; +import { + getPlatformBadge, + getSourceBadge, + getTypeBadge, + getUnifiedSourceBadges, +} from '@/utils/resourceBadgePresentation'; +import { buildWorkloadsHref } from '@/components/Infrastructure/workloadsLink'; +import { buildServiceDetailLinks } from '@/components/Infrastructure/serviceDetailLinks'; +import { + getPrimaryResourceIdentity, + getPrimaryResourceIdentityRows, + getResourceIdentityAliases, + getPreferredResourceClusterName, + getPreferredResourceDisplayName, +} from '@/utils/resourceIdentity'; +import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings'; +import { + getResourcePolicyBadges, + getResourcePolicyDisplayLabel, + getResourcePolicyRedactionLabels, + getResourceRoutingScopeLabel, +} from '@/utils/resourcePolicyPresentation'; +import type { ResourceIntelligence } from '@/types/aiIntelligence'; +import { + ALIAS_COLLAPSE_THRESHOLD, + buildTemperatureRows, + formatInteger, + toAgentFromResource, + toDiscoveryConfig, + toNodeFromProxmox, + type AgentPlatformData, + type DockerPlatformData, + type KubernetesPlatformData, + type PlatformData, +} from '@/components/Infrastructure/resourceDetailMappers'; +import { formatIdentifierLabel } from '@/utils/textPresentation'; + +type DrawerTab = 'overview' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug'; + +interface UseResourceDetailDrawerDerivedStateOptions { + resource: Resource; + resolveResourceLabel?: (resourceId: string) => string | null | undefined; + debugEnabled: Accessor; + resourceIntelligence: Accessor; +} + +export const useResourceDetailDrawerDerivedState = ( + options: UseResourceDetailDrawerDerivedStateOptions, +) => { + const { resource, resolveResourceLabel: resolveResourceLabelInput, debugEnabled, resourceIntelligence } = + options; + + const displayName = createMemo(() => getPreferredResourceDisplayName(resource)); + const kubernetesClusterName = createMemo(() => getPreferredResourceClusterName(resource) ?? ''); + const resolveResourceLabel = (resourceId: string): string => + resolveResourceLabelInput?.(resourceId)?.trim() || resourceId; + const statusIndicator = createMemo(() => getAgentStatusIndicator({ status: resource.status })); + const lastSeen = createMemo(() => formatRelativeTime(resource.lastSeen)); + const lastSeenAbsolute = createMemo(() => formatAbsoluteTime(resource.lastSeen)); + + const platformBadge = createMemo(() => getPlatformBadge(resource.platformType)); + const sourceBadge = createMemo(() => getSourceBadge(resource.sourceType)); + const typeBadge = createMemo(() => getTypeBadge(resource.type)); + const platformData = createMemo(() => resource.platformData as PlatformData | undefined); + const unifiedSourceBadges = createMemo(() => + getUnifiedSourceBadges(platformData()?.sources ?? []), + ); + const hasUnifiedSources = createMemo(() => unifiedSourceBadges().length > 0); + const policyBadges = createMemo(() => getResourcePolicyBadges(resource.policy)); + const policyRedactions = createMemo(() => getResourcePolicyRedactionLabels(resource.policy)); + const governanceSummary = createMemo(() => + requiresGovernedResourceDisplay(resource.policy) + ? getResourcePolicyDisplayLabel(resource) + : (resource.aiSafeSummary?.trim() ?? ''), + ); + const hasGovernanceData = createMemo( + () => policyBadges().length > 0 || Boolean(governanceSummary()), + ); + + const agentMeta = createMemo( + () => resource.agent ?? (platformData()?.agent as AgentPlatformData | undefined), + ); + const kubernetesMeta = createMemo( + () => resource.kubernetes ?? (platformData()?.kubernetes as KubernetesPlatformData | undefined), + ); + const kubernetesCapabilityBadges = createMemo(() => { + const capabilities = kubernetesMeta()?.metricCapabilities; + if (!capabilities) return [] as Array<{ label: string; classes: string; title: string }>; + + const supportedBadge = + 'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-400'; + const unsupportedBadge = + 'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-surface-alt text-muted'; + const badges: { label: string; classes: string; title: string }[] = []; + + if (capabilities.nodeCpuMemory) { + badges.push({ + label: 'K8s Node CPU/Memory', + classes: supportedBadge, + title: 'Node CPU and memory metrics are available.', + }); + } + if (capabilities.nodeTelemetry) { + badges.push({ + label: 'Node Telemetry (Agent)', + classes: supportedBadge, + title: 'Linked Pulse agent provides node uptime, temperature, disk, network, and disk I/O.', + }); + } + if (capabilities.podCpuMemory) { + badges.push({ + label: 'Pod CPU/Memory', + classes: supportedBadge, + title: 'Pod CPU and memory metrics are available.', + }); + } + if (capabilities.podNetwork) { + badges.push({ + label: 'Pod Network', + classes: supportedBadge, + title: 'Pod network throughput is available.', + }); + } + if (capabilities.podEphemeralDisk) { + badges.push({ + label: 'Pod Ephemeral Disk', + classes: supportedBadge, + title: 'Pod ephemeral storage usage is available.', + }); + } + if (!capabilities.podDiskIo) { + badges.push({ + label: 'Pod Disk I/O Unsupported', + classes: unsupportedBadge, + title: + 'Pod disk read/write throughput is not collected by the Kubernetes integration path today.', + }); + } + + return badges; + }); + + const proxmoxNode = createMemo(() => toNodeFromProxmox(resource)); + const agentInfo = createMemo(() => toAgentFromResource(resource, agentMeta())); + const temperatureRows = createMemo(() => buildTemperatureRows(agentInfo()?.sensors)); + + const dockerHostData = createMemo(() => platformData()?.docker as DockerPlatformData | undefined); + const dockerHostSourceId = createMemo( + () => (dockerHostData()?.hostSourceId || '').trim() || null, + ); + const dockerUpdatesAvailable = createMemo(() => dockerHostData()?.updatesAvailableCount ?? 0); + const dockerContainerCount = createMemo(() => dockerHostData()?.containerCount ?? 0); + const dockerUpdatesCheckedRelative = createMemo(() => { + const raw = dockerHostData()?.updatesLastCheckedAt; + if (!raw) return ''; + const parsed = Date.parse(raw); + if (!Number.isFinite(parsed)) return ''; + return formatRelativeTime(parsed); + }); + const dockerHostCommand = createMemo(() => dockerHostData()?.command); + const dockerHostCommandActive = createMemo(() => { + const status = (dockerHostCommand()?.status || '').trim().toLowerCase(); + return ['queued', 'dispatched', 'acknowledged', 'in_progress'].includes(status); + }); + const dockerUpdateActionsDisabled = createMemo(() => shouldHideDockerUpdateActions()); + const dockerUpdateActionsLoading = createMemo(() => !areSystemSettingsLoaded()); + const dockerSwarmInfo = createMemo(() => dockerHostData()?.swarm); + const dockerSwarmClusterKey = createMemo(() => { + const swarm = dockerSwarmInfo(); + return (swarm?.clusterName || swarm?.clusterId || '').trim(); + }); + + const resourceDependencies = createMemo(() => resourceIntelligence()?.dependencies ?? []); + const resourceDependents = createMemo(() => resourceIntelligence()?.dependents ?? []); + const resourceCorrelations = createMemo(() => resourceIntelligence()?.correlations ?? []); + const hasCorrelationContext = createMemo( + () => + resourceDependencies().length > 0 || + resourceDependents().length > 0 || + resourceCorrelations().length > 0, + ); + const hasInvestigationContext = createMemo( + () => Boolean(resourceIntelligence()) || hasGovernanceData(), + ); + const investigationContextSummary = createMemo(() => { + const intel = resourceIntelligence(); + const summary: string[] = []; + + if (intel) { + summary.push(`AI health ${intel.health.grade} · ${Math.round(intel.health.score)}/100`); + } + if (resourceCorrelations().length > 0) { + summary.push( + `${resourceCorrelations().length} correlation${resourceCorrelations().length === 1 ? '' : 's'}`, + ); + } + if (resource.policy?.routing.scope) { + summary.push(`Routing ${getResourceRoutingScopeLabel(resource.policy.routing.scope)}`); + } + + return summary.join(' · '); + }); + + const pbsData = createMemo(() => platformData()?.pbs); + const pmgData = createMemo(() => platformData()?.pmg); + const pbsJobTotal = createMemo(() => { + const pbs = pbsData(); + if (!pbs) return 0; + return ( + (pbs.backupJobCount || 0) + + (pbs.syncJobCount || 0) + + (pbs.verifyJobCount || 0) + + (pbs.pruneJobCount || 0) + + (pbs.garbageJobCount || 0) + ); + }); + const pmgQueueBacklog = createMemo(() => { + const pmg = pmgData(); + if (!pmg) return 0; + return (pmg.queueDeferred || 0) + (pmg.queueHold || 0); + }); + const pmgUpdatedRelative = createMemo(() => { + const raw = pmgData()?.lastUpdated; + if (!raw) return ''; + const parsed = Date.parse(raw); + if (!Number.isFinite(parsed)) return ''; + return formatRelativeTime(parsed); + }); + const pbsJobBreakdown = createMemo(() => { + const pbs = pbsData(); + if (!pbs) return [] as Array<{ label: string; value: number }>; + return [ + { label: 'Backup', value: pbs.backupJobCount || 0 }, + { label: 'Sync', value: pbs.syncJobCount || 0 }, + { label: 'Verify', value: pbs.verifyJobCount || 0 }, + { label: 'Prune', value: pbs.pruneJobCount || 0 }, + { label: 'Garbage', value: pbs.garbageJobCount || 0 }, + ]; + }); + const pbsVisibleJobBreakdown = createMemo(() => { + const all = pbsJobBreakdown(); + const nonZero = all.filter((entry) => entry.value > 0); + return nonZero.length > 0 ? nonZero : all; + }); + const pmgQueueBreakdown = createMemo(() => { + const pmg = pmgData(); + if (!pmg) return [] as Array<{ label: string; value: number; warn?: boolean }>; + return [ + { label: 'Active', value: pmg.queueActive || 0 }, + { label: 'Deferred', value: pmg.queueDeferred || 0, warn: (pmg.queueDeferred || 0) > 0 }, + { label: 'Hold', value: pmg.queueHold || 0, warn: (pmg.queueHold || 0) > 0 }, + { label: 'Incoming', value: pmg.queueIncoming || 0 }, + ]; + }); + const pmgVisibleQueueBreakdown = createMemo(() => { + const all = pmgQueueBreakdown(); + const nonZero = all.filter((entry) => entry.value > 0); + return nonZero.length > 0 ? nonZero : all; + }); + const pmgMailBreakdown = createMemo(() => { + const pmg = pmgData(); + if (!pmg) return [] as Array<{ label: string; value: number }>; + return [ + { label: 'Mail', value: pmg.mailCountTotal || 0 }, + { label: 'Spam', value: pmg.spamIn || 0 }, + { label: 'Virus', value: pmg.virusIn || 0 }, + ]; + }); + const pmgVisibleMailBreakdown = createMemo(() => { + const all = pmgMailBreakdown(); + const nonZero = all.filter((entry) => entry.value > 0); + return nonZero.length > 0 ? nonZero : all; + }); + + const mergedSources = createMemo(() => platformData()?.sources ?? []); + const sourceStatus = createMemo>( + () => platformData()?.sourceStatus ?? {}, + ); + const sourceHealthSummary = createMemo(() => { + const entries = Object.entries(sourceStatus()); + if (entries.length === 0) return null; + + let healthy = 0; + let warning = 0; + let unhealthy = 0; + const parts: string[] = []; + + for (const [source, status] of entries) { + const normalized = (status?.status || '').trim().toLowerCase(); + parts.push(`${source}:${normalized || 'unknown'}`); + if (['online', 'running', 'healthy', 'connected', 'ok'].includes(normalized)) { + healthy += 1; + } else if (['degraded', 'warning', 'stale'].includes(normalized)) { + warning += 1; + } else { + unhealthy += 1; + } + } + + const total = entries.length; + if (unhealthy > 0) { + return { + label: `${unhealthy}/${total} unhealthy`, + className: 'text-red-600 dark:text-red-400', + title: parts.join(' • '), + }; + } + if (warning > 0) { + return { + label: `${warning}/${total} degraded`, + className: 'text-amber-600 dark:text-amber-400', + title: parts.join(' • '), + }; + } + return { + label: `${healthy}/${total} healthy`, + className: 'text-emerald-600 dark:text-emerald-400', + title: parts.join(' • '), + }; + }); + const sourceSummary = createMemo(() => { + const health = sourceHealthSummary(); + if (health) return health; + const sources = mergedSources(); + if (sources.length === 0) return null; + return { + label: sources.length === 1 ? sources[0].toUpperCase() : `${sources.length} sources`, + className: 'text-base-content', + title: sources.join(' • '), + }; + }); + + const identityAliasValues = createMemo(() => getResourceIdentityAliases(resource)); + const primaryIdentityRows = createMemo(() => getPrimaryResourceIdentityRows(resource)); + const identityCardHasRichData = createMemo( + () => + primaryIdentityRows().length > 0 || + (resource.identity?.ips?.length || 0) > 0 || + (resource.tags?.length || 0) > 0 || + identityAliasValues().length > 0, + ); + const aliasPreviewValues = createMemo(() => + identityAliasValues().slice(0, ALIAS_COLLAPSE_THRESHOLD), + ); + const hasAliasOverflow = createMemo( + () => identityAliasValues().length > ALIAS_COLLAPSE_THRESHOLD, + ); + const hasIdentitySupportContext = createMemo( + () => + (resource.identity?.ips?.length ?? 0) > 0 || + (resource.tags?.length ?? 0) > 0 || + identityAliasValues().length > 0, + ); + const hasMergedSources = createMemo(() => mergedSources().length > 1); + const discoveryConfig = createMemo(() => toDiscoveryConfig(resource)); + const discoveryContextSummary = createMemo(() => { + const config = discoveryConfig(); + if (!config) return null; + + const discoveryMode = + config.resourceType === 'agent' + ? 'Host discovery' + : `${formatIdentifierLabel(config.resourceType)} discovery`; + + return config.hostname ? `${discoveryMode} via ${config.hostname}` : discoveryMode; + }); + + const hostDetailCards = createMemo(() => { + const cards: string[] = []; + + if (proxmoxNode()) { + cards.push('system', 'hardware', 'storage'); + } + + const agent = agentInfo(); + if (agent) { + cards.push('system', 'hardware'); + if ((agent.networkInterfaces?.length ?? 0) > 0) cards.push('network'); + if ((agent.disks?.length ?? 0) > 0) cards.push('disks'); + if ((agentMeta()?.raid?.length ?? 0) > 0) cards.push('raid'); + if (temperatureRows().length > 0) cards.push('temperatures'); + } + + return cards; + }); + const hasHostDetails = createMemo(() => hostDetailCards().length > 0); + const hostDetailSummary = createMemo(() => { + const labels = Array.from(new Set(hostDetailCards())); + if (labels.length === 0) return null; + + const categories = + labels.length === 1 + ? labels[0] + : labels.length === 2 + ? `${labels[0]} and ${labels[1]}` + : `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`; + + return `${hostDetailCards().length} detail card${hostDetailCards().length === 1 ? '' : 's'} covering ${categories}.`; + }); + const hasServiceDetails = createMemo( + () => resource.type === 'docker-host' || Boolean(pbsData()) || Boolean(pmgData()), + ); + const serviceDetailsSummary = createMemo(() => { + if (resource.type === 'docker-host') { + return `${formatInteger(dockerContainerCount())} containers · ${formatInteger(dockerUpdatesAvailable())} updates`; + } + + const pbs = pbsData(); + if (pbs) { + return `${formatInteger(pbs.datastoreCount)} datastores · ${formatInteger(pbsJobTotal())} jobs`; + } + + const pmg = pmgData(); + if (pmg) { + return `${formatInteger(pmg.queueTotal)} queue total · ${formatInteger(pmgQueueBacklog())} backlog`; + } + + return null; + }); + + const workloadsHref = createMemo(() => buildWorkloadsHref(resource)); + const headerIdentity = createMemo(() => getPrimaryResourceIdentity(resource)); + const relatedLinks = createMemo(() => { + const links: Array<{ href: string; label: string; compactLabel: string; ariaLabel: string }> = + []; + const workloads = workloadsHref(); + if (workloads) { + links.push({ + href: workloads, + label: 'Open in Workloads', + compactLabel: 'Workloads', + ariaLabel: `Open related workloads for ${displayName()}`, + }); + } + links.push(...buildServiceDetailLinks(resource)); + const seen = new Set(); + return links.filter((link) => { + if (seen.has(link.href)) return false; + seen.add(link.href); + return true; + }); + }); + const hasRuntimeOperationalContext = createMemo( + () => kubernetesCapabilityBadges().length > 0 || relatedLinks().length > 0, + ); + + const sourceSections = createMemo(() => { + const data = platformData(); + if (!data) { + return [] as Array<{ id: string; label: string; payload: unknown }>; + } + const sections = [ + { id: 'proxmox', label: 'Proxmox', payload: data.proxmox }, + { id: 'agent', label: 'Agent', payload: data.agent }, + { id: 'docker', label: 'Containers', payload: data.docker }, + { id: 'pbs', label: 'PBS', payload: data.pbs }, + { id: 'pmg', label: 'PMG', payload: data.pmg }, + { id: 'kubernetes', label: 'Kubernetes', payload: data.kubernetes }, + { id: 'metrics', label: 'Metrics', payload: data.metrics }, + ]; + return sections.filter((section) => section.payload !== undefined); + }); + const identityMatchInfo = createMemo(() => { + const data = platformData(); + return ( + data?.identityMatch ?? + data?.matchResults ?? + data?.matchCandidates ?? + data?.matches ?? + undefined + ); + }); + const debugBundle = createMemo(() => ({ + resource, + identity: { + resourceIdentity: resource.identity, + matchInfo: identityMatchInfo(), + }, + sources: { + sourceStatus: sourceStatus(), + proxmox: platformData()?.proxmox, + agent: platformData()?.agent, + docker: platformData()?.docker, + pbs: platformData()?.pbs, + pmg: platformData()?.pmg, + kubernetes: platformData()?.kubernetes, + metrics: platformData()?.metrics, + }, + })); + const debugJson = createMemo(() => JSON.stringify(debugBundle(), null, 2)); + + const tabs = createMemo(() => { + const base = [ + { id: 'overview' as DrawerTab, label: 'Overview' }, + ...(resource.type === 'pmg' ? [{ id: 'mail' as DrawerTab, label: 'Mail' }] : []), + ...(resource.type === 'k8s-cluster' + ? [{ id: 'namespaces' as DrawerTab, label: 'Namespaces' }] + : []), + ...(resource.type === 'k8s-cluster' + ? [{ id: 'deployments' as DrawerTab, label: 'Deployments' }] + : []), + ...(resource.type === 'docker-host' && dockerSwarmClusterKey() + ? [{ id: 'swarm' as DrawerTab, label: 'Swarm' }] + : []), + ]; + if (debugEnabled()) { + base.push({ id: 'debug' as DrawerTab, label: 'Debug' }); + } + return base; + }); + + return { + displayName, + kubernetesClusterName, + resolveResourceLabel, + statusIndicator, + lastSeen, + lastSeenAbsolute, + platformBadge, + sourceBadge, + typeBadge, + platformData, + unifiedSourceBadges, + hasUnifiedSources, + policyBadges, + policyRedactions, + governanceSummary, + hasGovernanceData, + agentMeta, + kubernetesMeta, + kubernetesCapabilityBadges, + proxmoxNode, + agentInfo, + temperatureRows, + dockerHostData, + dockerHostSourceId, + dockerUpdatesAvailable, + dockerContainerCount, + dockerUpdatesCheckedRelative, + dockerHostCommand, + dockerHostCommandActive, + dockerUpdateActionsDisabled, + dockerUpdateActionsLoading, + dockerSwarmInfo, + dockerSwarmClusterKey, + resourceDependencies, + resourceDependents, + resourceCorrelations, + hasCorrelationContext, + hasInvestigationContext, + investigationContextSummary, + pbsData, + pmgData, + pbsJobTotal, + pmgQueueBacklog, + pmgUpdatedRelative, + pbsVisibleJobBreakdown, + pmgVisibleQueueBreakdown, + pmgVisibleMailBreakdown, + mergedSources, + sourceStatus, + sourceSummary, + identityAliasValues, + primaryIdentityRows, + identityCardHasRichData, + aliasPreviewValues, + hasAliasOverflow, + hasIdentitySupportContext, + hasMergedSources, + discoveryConfig, + discoveryContextSummary, + hasHostDetails, + hostDetailSummary, + hasServiceDetails, + serviceDetailsSummary, + headerIdentity, + relatedLinks, + hasRuntimeOperationalContext, + sourceSections, + identityMatchInfo, + debugJson, + tabs, + }; +}; + +export type ResourceDetailDrawerDerivedState = ReturnType; diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts new file mode 100644 index 000000000..a8c28ae35 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts @@ -0,0 +1,143 @@ +import { createMemo, createResource, createSignal } from 'solid-js'; +import type { + Resource, + ResourceChangeKind, + ResourceChangeSourceAdapter, + ResourceChangeSourceType, +} from '@/types/resource'; +import type { ResourceIntelligence } from '@/types/aiIntelligence'; +import { AIAPI } from '@/api/ai'; +import { ResourceAPI } from '@/api/resources'; + +interface UseResourceDetailDrawerHistoryStateOptions { + resource: Resource; +} + +export const useResourceDetailDrawerHistoryState = ( + options: UseResourceDetailDrawerHistoryStateOptions, +) => { + const { resource } = options; + + const resourceFacetId = createMemo(() => resource.id.trim()); + const [timelineKindFilter, setTimelineKindFilter] = createSignal(''); + const [timelineSourceTypeFilter, setTimelineSourceTypeFilter] = createSignal< + ResourceChangeSourceType | '' + >(''); + const [timelineSourceAdapterFilter, setTimelineSourceAdapterFilter] = createSignal< + ResourceChangeSourceAdapter | '' + >(''); + + const resourceFacetRequest = createMemo(() => { + const id = resourceFacetId(); + return id ? { id } : null; + }); + + const [resourceFacets, { refetch: refetchResourceFacets }] = createResource( + resourceFacetRequest, + async (request) => { + if (!request?.id) return null; + return ResourceAPI.getFacetBundle(request.id, { limit: 25 }); + }, + { initialValue: null }, + ); + + const [resourceIntelligence] = createResource( + resourceFacetRequest, + async (request) => { + if (!request?.id) return null; + return AIAPI.getResourceIntelligence(request.id); + }, + { initialValue: null as ResourceIntelligence | null }, + ); + + const timelineFacetRequest = createMemo(() => { + const id = resourceFacetId(); + if (!id) return null; + const kind = timelineKindFilter(); + const sourceType = timelineSourceTypeFilter(); + const sourceAdapter = timelineSourceAdapterFilter(); + if (!kind && !sourceType && !sourceAdapter) return null; + return { id, kind, sourceType, sourceAdapter }; + }); + + const [timelineFacets, { refetch: refetchTimelineFacets }] = createResource( + timelineFacetRequest, + async (request) => { + if (!request) return null; + return ResourceAPI.getFacetBundle(request.id, { + limit: 25, + kind: request.kind || undefined, + sourceType: request.sourceType || undefined, + sourceAdapter: request.sourceAdapter || undefined, + }); + }, + { initialValue: null }, + ); + + const resourceTimeline = createMemo( + () => resourceFacets()?.recentChanges ?? resource.recentChanges ?? [], + ); + const resourceFacetCounts = createMemo(() => resourceFacets()?.counts ?? resource.facetCounts ?? null); + const historyFacetBundle = createMemo(() => + timelineFacetRequest() ? (timelineFacets() ?? resourceFacets()) : resourceFacets(), + ); + const historyFacetCounts = createMemo( + () => historyFacetBundle()?.counts ?? resourceFacetCounts() ?? null, + ); + const historyRecentChanges = createMemo(() => historyFacetBundle()?.recentChanges ?? resourceTimeline()); + const historyTimeline = createMemo(() => historyRecentChanges()); + const hasTimelineFilters = createMemo(() => + Boolean(timelineKindFilter() || timelineSourceTypeFilter() || timelineSourceAdapterFilter()), + ); + const historyLoadingLabel = createMemo(() => { + if (timelineFacetRequest()) { + return timelineFacets.loading ? 'Refreshing filtered changes...' : 'Filtered changes loaded'; + } + return resourceFacets.loading ? 'Refreshing changes...' : 'Changes loaded'; + }); + const resourceTimelineCount = createMemo( + () => historyFacetCounts()?.recentChanges ?? historyRecentChanges().length, + ); + const sortedResourceTimeline = createMemo(() => + [...historyTimeline()].sort((left, right) => { + const leftTime = Date.parse(left.observedAt || ''); + const rightTime = Date.parse(right.observedAt || ''); + return ( + (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0) + ); + }), + ); + const facetBundleError = createMemo(() => { + const error = timelineFacetRequest() ? timelineFacets.error : resourceFacets.error; + if (!error) return ''; + return (error as Error)?.message || 'Failed to load resource history'; + }); + + const refetchHistoryFacets = () => { + if (timelineFacetRequest()) { + return refetchTimelineFacets(); + } + return refetchResourceFacets(); + }; + + return { + timelineKindFilter, + setTimelineKindFilter, + timelineSourceTypeFilter, + setTimelineSourceTypeFilter, + timelineSourceAdapterFilter, + setTimelineSourceAdapterFilter, + resourceIntelligence, + resourceTimeline, + historyFacetCounts, + historyRecentChanges, + hasTimelineFilters, + historyLoadingLabel, + resourceTimelineCount, + sortedResourceTimeline, + facetBundleError, + refetchHistoryFacets, + }; +}; + +export type ResourceDetailDrawerHistoryState = ReturnType; diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts index c72d9174a..898f81b95 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts @@ -1,59 +1,11 @@ import { createEffect, - createMemo, - createResource, createSignal, - type Accessor, - type Setter, } from 'solid-js'; -import type { - Resource, - ResourceChangeKind, - ResourceChangeSourceAdapter, - ResourceChangeSourceType, -} from '@/types/resource'; -import { requiresGovernedResourceDisplay } from '@/types/resource'; -import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; -import { getAgentStatusIndicator } from '@/utils/status'; -import { - getPlatformBadge, - getSourceBadge, - getTypeBadge, - getUnifiedSourceBadges, -} from '@/utils/resourceBadgePresentation'; -import { buildWorkloadsHref } from '@/components/Infrastructure/workloadsLink'; -import { buildServiceDetailLinks } from '@/components/Infrastructure/serviceDetailLinks'; -import { - getPrimaryResourceIdentity, - getPrimaryResourceIdentityRows, - getResourceIdentityAliases, - getPreferredResourceClusterName, - getPreferredResourceDisplayName, -} from '@/utils/resourceIdentity'; +import type { Resource } from '@/types/resource'; import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage'; -import { AIAPI } from '@/api/ai'; -import { ResourceAPI } from '@/api/resources'; -import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings'; -import { - getResourcePolicyBadges, - getResourcePolicyDisplayLabel, - getResourcePolicyRedactionLabels, - getResourceRoutingScopeLabel, -} from '@/utils/resourcePolicyPresentation'; -import type { ResourceIntelligence } from '@/types/aiIntelligence'; -import { - ALIAS_COLLAPSE_THRESHOLD, - buildTemperatureRows, - formatInteger, - toAgentFromResource, - toDiscoveryConfig, - toNodeFromProxmox, - type AgentPlatformData, - type DockerPlatformData, - type KubernetesPlatformData, - type PlatformData, -} from '@/components/Infrastructure/resourceDetailMappers'; -import { formatIdentifierLabel } from '@/utils/textPresentation'; +import { useResourceDetailDrawerHistoryState } from './useResourceDetailDrawerHistoryState'; +import { useResourceDetailDrawerDerivedState } from './useResourceDetailDrawerDerivedState'; type DrawerTab = 'overview' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug'; @@ -62,135 +14,7 @@ export interface UseResourceDetailDrawerStateOptions { resolveResourceLabel?: (resourceId: string) => string | null | undefined; } -export interface UseResourceDetailDrawerStateResult { - activeTab: Accessor; - setActiveTab: Setter; - debugEnabled: Accessor; - copied: Accessor; - showReportModal: Accessor; - setShowReportModal: Setter; - showInvestigationContext: Accessor; - setShowInvestigationContext: Setter; - showCorrelationContext: Accessor; - setShowCorrelationContext: Setter; - showDiscoveryContext: Accessor; - setShowDiscoveryContext: Setter; - showHostDetails: Accessor; - setShowHostDetails: Setter; - showServiceDetails: Accessor; - setShowServiceDetails: Setter; - showDockerUpdateControls: Accessor; - setShowDockerUpdateControls: Setter; - showPbsJobDetail: Accessor; - setShowPbsJobDetail: Setter; - showPmgMailFlowDetail: Accessor; - setShowPmgMailFlowDetail: Setter; - displayName: Accessor; - kubernetesClusterName: Accessor; - resolveResourceLabel: (resourceId: string) => string; - statusIndicator: Accessor>; - lastSeen: Accessor; - lastSeenAbsolute: Accessor; - platformBadge: Accessor>; - sourceBadge: Accessor>; - typeBadge: Accessor>; - unifiedSourceBadges: Accessor>; - hasUnifiedSources: Accessor; - policyBadges: Accessor>; - policyRedactions: Accessor; - governanceSummary: Accessor; - hasGovernanceData: Accessor; - platformData: Accessor; - agentMeta: Accessor; - kubernetesMeta: Accessor; - kubernetesCapabilityBadges: Accessor>; - proxmoxNode: Accessor>; - agentInfo: Accessor>; - temperatureRows: Accessor>; - dockerHostData: Accessor; - dockerHostSourceId: Accessor; - dockerUpdatesAvailable: Accessor; - dockerContainerCount: Accessor; - dockerUpdatesCheckedRelative: Accessor; - dockerHostCommand: Accessor; - dockerHostCommandActive: Accessor; - dockerUpdateActionsDisabled: Accessor; - dockerUpdateActionsLoading: Accessor; - dockerActionError: Accessor; - setDockerActionError: Setter; - dockerActionNote: Accessor; - setDockerActionNote: Setter; - confirmUpdateAll: Accessor; - setConfirmUpdateAll: Setter; - dockerActionBusy: Accessor; - setDockerActionBusy: Setter; - dockerSwarmInfo: Accessor; - dockerSwarmClusterKey: Accessor; - k8sDeploymentsPrefillNamespace: Accessor; - setK8sDeploymentsPrefillNamespace: Setter; - timelineKindFilter: Accessor; - setTimelineKindFilter: Setter; - timelineSourceTypeFilter: Accessor; - setTimelineSourceTypeFilter: Setter; - timelineSourceAdapterFilter: Accessor; - setTimelineSourceAdapterFilter: Setter; - resourceIntelligence: Accessor; - resourceDependencies: Accessor; - resourceDependents: Accessor; - resourceCorrelations: Accessor; - hasCorrelationContext: Accessor; - hasInvestigationContext: Accessor; - investigationContextSummary: Accessor; - pbsData: Accessor; - pmgData: Accessor; - pbsJobTotal: Accessor; - pmgQueueBacklog: Accessor; - pmgUpdatedRelative: Accessor; - pbsVisibleJobBreakdown: Accessor>; - pmgVisibleQueueBreakdown: Accessor>; - pmgVisibleMailBreakdown: Accessor>; - resourceTimeline: Accessor; - historyFacetCounts: Accessor< - Awaited>['counts'] | null - >; - historyRecentChanges: Accessor; - hasTimelineFilters: Accessor; - historyLoadingLabel: Accessor; - resourceTimelineCount: Accessor; - sortedResourceTimeline: Accessor; - facetBundleError: Accessor; - refetchHistoryFacets: () => unknown; - mergedSources: Accessor; - sourceSummary: Accessor<{ label: string; className: string; title: string } | null>; - identityAliasValues: Accessor; - primaryIdentityRows: Accessor>; - identityCardHasRichData: Accessor; - aliasPreviewValues: Accessor; - hasAliasOverflow: Accessor; - hasIdentitySupportContext: Accessor; - hasMergedSources: Accessor; - discoveryConfig: Accessor>; - discoveryContextSummary: Accessor; - hasHostDetails: Accessor; - hostDetailSummary: Accessor; - hasServiceDetails: Accessor; - serviceDetailsSummary: Accessor; - headerIdentity: Accessor; - relatedLinks: Accessor< - Array<{ href: string; label: string; compactLabel: string; ariaLabel: string }> - >; - hasRuntimeOperationalContext: Accessor; - sourceSections: Accessor>; - sourceStatus: Accessor>; - identityMatchInfo: Accessor; - debugJson: Accessor; - tabs: Accessor>; - handleCopyJson: () => Promise; -} - -export const useResourceDetailDrawerState = ( - options: UseResourceDetailDrawerStateOptions, -): UseResourceDetailDrawerStateResult => { +export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerStateOptions) => { const { resource, resolveResourceLabel: resolveResourceLabelInput } = options; const [activeTab, setActiveTab] = createSignal('overview'); const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false); @@ -204,540 +28,19 @@ export const useResourceDetailDrawerState = ( const [showDockerUpdateControls, setShowDockerUpdateControls] = createSignal(false); const [showPbsJobDetail, setShowPbsJobDetail] = createSignal(false); const [showPmgMailFlowDetail, setShowPmgMailFlowDetail] = createSignal(false); - - const displayName = createMemo(() => getPreferredResourceDisplayName(resource)); - const kubernetesClusterName = createMemo(() => getPreferredResourceClusterName(resource) ?? ''); - const resolveResourceLabel = (resourceId: string): string => - resolveResourceLabelInput?.(resourceId)?.trim() || resourceId; - const statusIndicator = createMemo(() => getAgentStatusIndicator({ status: resource.status })); - const lastSeen = createMemo(() => formatRelativeTime(resource.lastSeen)); - const lastSeenAbsolute = createMemo(() => formatAbsoluteTime(resource.lastSeen)); - - const platformBadge = createMemo(() => getPlatformBadge(resource.platformType)); - const sourceBadge = createMemo(() => getSourceBadge(resource.sourceType)); - const typeBadge = createMemo(() => getTypeBadge(resource.type)); - const platformData = createMemo(() => resource.platformData as PlatformData | undefined); - const unifiedSourceBadges = createMemo(() => - getUnifiedSourceBadges(platformData()?.sources ?? []), - ); - const hasUnifiedSources = createMemo(() => unifiedSourceBadges().length > 0); - const policyBadges = createMemo(() => getResourcePolicyBadges(resource.policy)); - const policyRedactions = createMemo(() => getResourcePolicyRedactionLabels(resource.policy)); - const governanceSummary = createMemo(() => - requiresGovernedResourceDisplay(resource.policy) - ? getResourcePolicyDisplayLabel(resource) - : (resource.aiSafeSummary?.trim() ?? ''), - ); - const hasGovernanceData = createMemo( - () => policyBadges().length > 0 || Boolean(governanceSummary()), - ); - - const agentMeta = createMemo( - () => resource.agent ?? (platformData()?.agent as AgentPlatformData | undefined), - ); - const kubernetesMeta = createMemo( - () => resource.kubernetes ?? (platformData()?.kubernetes as KubernetesPlatformData | undefined), - ); - const kubernetesCapabilityBadges = createMemo(() => { - const capabilities = kubernetesMeta()?.metricCapabilities; - if (!capabilities) return []; - - const supportedBadge = - 'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-400'; - const unsupportedBadge = - 'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-surface-alt text-muted'; - const badges: { label: string; classes: string; title: string }[] = []; - - if (capabilities.nodeCpuMemory) { - badges.push({ - label: 'K8s Node CPU/Memory', - classes: supportedBadge, - title: 'Node CPU and memory metrics are available.', - }); - } - if (capabilities.nodeTelemetry) { - badges.push({ - label: 'Node Telemetry (Agent)', - classes: supportedBadge, - title: 'Linked Pulse agent provides node uptime, temperature, disk, network, and disk I/O.', - }); - } - if (capabilities.podCpuMemory) { - badges.push({ - label: 'Pod CPU/Memory', - classes: supportedBadge, - title: 'Pod CPU and memory metrics are available.', - }); - } - if (capabilities.podNetwork) { - badges.push({ - label: 'Pod Network', - classes: supportedBadge, - title: 'Pod network throughput is available.', - }); - } - if (capabilities.podEphemeralDisk) { - badges.push({ - label: 'Pod Ephemeral Disk', - classes: supportedBadge, - title: 'Pod ephemeral storage usage is available.', - }); - } - if (!capabilities.podDiskIo) { - badges.push({ - label: 'Pod Disk I/O Unsupported', - classes: unsupportedBadge, - title: - 'Pod disk read/write throughput is not collected by the Kubernetes integration path today.', - }); - } - - return badges; - }); - - const proxmoxNode = createMemo(() => toNodeFromProxmox(resource)); - const agentInfo = createMemo(() => toAgentFromResource(resource, agentMeta())); - const temperatureRows = createMemo(() => buildTemperatureRows(agentInfo()?.sensors)); - - const dockerHostData = createMemo(() => platformData()?.docker as DockerPlatformData | undefined); - const dockerHostSourceId = createMemo( - () => (dockerHostData()?.hostSourceId || '').trim() || null, - ); - const dockerUpdatesAvailable = createMemo(() => dockerHostData()?.updatesAvailableCount ?? 0); - const dockerContainerCount = createMemo(() => dockerHostData()?.containerCount ?? 0); - const dockerUpdatesCheckedRelative = createMemo(() => { - const raw = dockerHostData()?.updatesLastCheckedAt; - if (!raw) return ''; - const parsed = Date.parse(raw); - if (!Number.isFinite(parsed)) return ''; - return formatRelativeTime(parsed); - }); - const dockerHostCommand = createMemo(() => dockerHostData()?.command); - const dockerHostCommandActive = createMemo(() => { - const status = (dockerHostCommand()?.status || '').trim().toLowerCase(); - return ['queued', 'dispatched', 'acknowledged', 'in_progress'].includes(status); - }); - const dockerUpdateActionsDisabled = createMemo(() => shouldHideDockerUpdateActions()); - const dockerUpdateActionsLoading = createMemo(() => !areSystemSettingsLoaded()); - const [dockerActionError, setDockerActionError] = createSignal(''); const [dockerActionNote, setDockerActionNote] = createSignal(''); const [confirmUpdateAll, setConfirmUpdateAll] = createSignal(false); const [dockerActionBusy, setDockerActionBusy] = createSignal(false); - const dockerSwarmInfo = createMemo(() => dockerHostData()?.swarm); - const dockerSwarmClusterKey = createMemo(() => { - const swarm = dockerSwarmInfo(); - return (swarm?.clusterName || swarm?.clusterId || '').trim(); - }); - const [k8sDeploymentsPrefillNamespace, setK8sDeploymentsPrefillNamespace] = createSignal(''); - const resourceFacetId = createMemo(() => resource.id.trim()); - const [timelineKindFilter, setTimelineKindFilter] = createSignal(''); - const [timelineSourceTypeFilter, setTimelineSourceTypeFilter] = createSignal< - ResourceChangeSourceType | '' - >(''); - const [timelineSourceAdapterFilter, setTimelineSourceAdapterFilter] = createSignal< - ResourceChangeSourceAdapter | '' - >(''); - const resourceFacetRequest = createMemo(() => { - const id = resourceFacetId(); - return id ? { id } : null; - }); - const [resourceFacets, { refetch: refetchResourceFacets }] = createResource( - resourceFacetRequest, - async (request) => { - if (!request?.id) return null; - return ResourceAPI.getFacetBundle(request.id, { limit: 25 }); - }, - { initialValue: null }, - ); - const [resourceIntelligence] = createResource( - resourceFacetRequest, - async (request) => { - if (!request?.id) return null; - return AIAPI.getResourceIntelligence(request.id); - }, - { initialValue: null as ResourceIntelligence | null }, - ); - const resourceDependencies = createMemo(() => resourceIntelligence()?.dependencies ?? []); - const resourceDependents = createMemo(() => resourceIntelligence()?.dependents ?? []); - const resourceCorrelations = createMemo(() => resourceIntelligence()?.correlations ?? []); - const hasCorrelationContext = createMemo( - () => - resourceDependencies().length > 0 || - resourceDependents().length > 0 || - resourceCorrelations().length > 0, - ); - const hasInvestigationContext = createMemo( - () => Boolean(resourceIntelligence()) || hasGovernanceData(), - ); - const investigationContextSummary = createMemo(() => { - const intel = resourceIntelligence(); - const summary: string[] = []; - if (intel) { - summary.push(`AI health ${intel.health.grade} · ${Math.round(intel.health.score)}/100`); - } - if (resourceCorrelations().length > 0) { - summary.push( - `${resourceCorrelations().length} correlation${resourceCorrelations().length === 1 ? '' : 's'}`, - ); - } - if (resource.policy?.routing.scope) { - summary.push(`Routing ${getResourceRoutingScopeLabel(resource.policy.routing.scope)}`); - } - - return summary.join(' · '); - }); - const timelineFacetRequest = createMemo(() => { - const id = resourceFacetId(); - if (!id) return null; - const kind = timelineKindFilter(); - const sourceType = timelineSourceTypeFilter(); - const sourceAdapter = timelineSourceAdapterFilter(); - if (!kind && !sourceType && !sourceAdapter) return null; - return { id, kind, sourceType, sourceAdapter }; - }); - const [timelineFacets, { refetch: refetchTimelineFacets }] = createResource( - timelineFacetRequest, - async (request) => { - if (!request) return null; - return ResourceAPI.getFacetBundle(request.id, { - limit: 25, - kind: request.kind || undefined, - sourceType: request.sourceType || undefined, - sourceAdapter: request.sourceAdapter || undefined, - }); - }, - { initialValue: null }, - ); - - const pbsData = createMemo(() => platformData()?.pbs); - const pmgData = createMemo(() => platformData()?.pmg); - const pbsJobTotal = createMemo(() => { - const pbs = pbsData(); - if (!pbs) return 0; - return ( - (pbs.backupJobCount || 0) + - (pbs.syncJobCount || 0) + - (pbs.verifyJobCount || 0) + - (pbs.pruneJobCount || 0) + - (pbs.garbageJobCount || 0) - ); - }); - const pmgQueueBacklog = createMemo(() => { - const pmg = pmgData(); - if (!pmg) return 0; - return (pmg.queueDeferred || 0) + (pmg.queueHold || 0); - }); - const pmgUpdatedRelative = createMemo(() => { - const raw = pmgData()?.lastUpdated; - if (!raw) return ''; - const parsed = Date.parse(raw); - if (!Number.isFinite(parsed)) return ''; - return formatRelativeTime(parsed); - }); - const pbsJobBreakdown = createMemo(() => { - const pbs = pbsData(); - if (!pbs) return [] as Array<{ label: string; value: number }>; - return [ - { label: 'Backup', value: pbs.backupJobCount || 0 }, - { label: 'Sync', value: pbs.syncJobCount || 0 }, - { label: 'Verify', value: pbs.verifyJobCount || 0 }, - { label: 'Prune', value: pbs.pruneJobCount || 0 }, - { label: 'Garbage', value: pbs.garbageJobCount || 0 }, - ]; - }); - const pbsVisibleJobBreakdown = createMemo(() => { - const all = pbsJobBreakdown(); - const nonZero = all.filter((entry) => entry.value > 0); - return nonZero.length > 0 ? nonZero : all; - }); - const pmgQueueBreakdown = createMemo(() => { - const pmg = pmgData(); - if (!pmg) return [] as Array<{ label: string; value: number; warn?: boolean }>; - return [ - { label: 'Active', value: pmg.queueActive || 0 }, - { label: 'Deferred', value: pmg.queueDeferred || 0, warn: (pmg.queueDeferred || 0) > 0 }, - { label: 'Hold', value: pmg.queueHold || 0, warn: (pmg.queueHold || 0) > 0 }, - { label: 'Incoming', value: pmg.queueIncoming || 0 }, - ]; - }); - const pmgVisibleQueueBreakdown = createMemo(() => { - const all = pmgQueueBreakdown(); - const nonZero = all.filter((entry) => entry.value > 0); - return nonZero.length > 0 ? nonZero : all; - }); - const pmgMailBreakdown = createMemo(() => { - const pmg = pmgData(); - if (!pmg) return [] as Array<{ label: string; value: number }>; - return [ - { label: 'Mail', value: pmg.mailCountTotal || 0 }, - { label: 'Spam', value: pmg.spamIn || 0 }, - { label: 'Virus', value: pmg.virusIn || 0 }, - ]; - }); - const pmgVisibleMailBreakdown = createMemo(() => { - const all = pmgMailBreakdown(); - const nonZero = all.filter((entry) => entry.value > 0); - return nonZero.length > 0 ? nonZero : all; - }); - const resourceTimeline = createMemo( - () => resourceFacets()?.recentChanges ?? resource.recentChanges ?? [], - ); - const resourceFacetCounts = createMemo( - () => resourceFacets()?.counts ?? resource.facetCounts ?? null, - ); - const historyFacetBundle = createMemo(() => - timelineFacetRequest() ? (timelineFacets() ?? resourceFacets()) : resourceFacets(), - ); - const historyFacetCounts = createMemo( - () => historyFacetBundle()?.counts ?? resourceFacetCounts() ?? null, - ); - const historyRecentChanges = createMemo( - () => historyFacetBundle()?.recentChanges ?? resourceTimeline(), - ); - const historyTimeline = createMemo(() => historyRecentChanges()); - const hasTimelineFilters = createMemo(() => - Boolean(timelineKindFilter() || timelineSourceTypeFilter() || timelineSourceAdapterFilter()), - ); - const historyLoadingLabel = createMemo(() => { - if (timelineFacetRequest()) { - return timelineFacets.loading ? 'Refreshing filtered changes...' : 'Filtered changes loaded'; - } - return resourceFacets.loading ? 'Refreshing changes...' : 'Changes loaded'; - }); - const resourceTimelineCount = createMemo( - () => historyFacetCounts()?.recentChanges ?? historyRecentChanges().length, - ); - const sortedResourceTimeline = createMemo(() => - [...historyTimeline()].sort((left, right) => { - const leftTime = Date.parse(left.observedAt || ''); - const rightTime = Date.parse(right.observedAt || ''); - return ( - (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0) - ); - }), - ); - const facetBundleError = createMemo(() => { - const error = timelineFacetRequest() ? timelineFacets.error : resourceFacets.error; - if (!error) return ''; - return (error as Error)?.message || 'Failed to load resource history'; - }); - const refetchHistoryFacets = () => { - if (timelineFacetRequest()) { - return refetchTimelineFacets(); - } - return refetchResourceFacets(); - }; - const mergedSources = createMemo(() => platformData()?.sources ?? []); - const sourceStatus = createMemo(() => platformData()?.sourceStatus ?? {}); - const sourceHealthSummary = createMemo(() => { - const entries = Object.entries(sourceStatus()); - if (entries.length === 0) return null; - - let healthy = 0; - let warning = 0; - let unhealthy = 0; - const parts: string[] = []; - - for (const [source, status] of entries) { - const normalized = (status?.status || '').trim().toLowerCase(); - parts.push(`${source}:${normalized || 'unknown'}`); - if (['online', 'running', 'healthy', 'connected', 'ok'].includes(normalized)) { - healthy += 1; - } else if (['degraded', 'warning', 'stale'].includes(normalized)) { - warning += 1; - } else { - unhealthy += 1; - } - } - - const total = entries.length; - if (unhealthy > 0) { - return { - label: `${unhealthy}/${total} unhealthy`, - className: 'text-red-600 dark:text-red-400', - title: parts.join(' • '), - }; - } - if (warning > 0) { - return { - label: `${warning}/${total} degraded`, - className: 'text-amber-600 dark:text-amber-400', - title: parts.join(' • '), - }; - } - return { - label: `${healthy}/${total} healthy`, - className: 'text-emerald-600 dark:text-emerald-400', - title: parts.join(' • '), - }; - }); - const sourceSummary = createMemo(() => { - const health = sourceHealthSummary(); - if (health) return health; - const sources = mergedSources(); - if (sources.length === 0) return null; - return { - label: sources.length === 1 ? sources[0].toUpperCase() : `${sources.length} sources`, - className: 'text-base-content', - title: sources.join(' • '), - }; - }); - const identityAliasValues = createMemo(() => getResourceIdentityAliases(resource)); - const primaryIdentityRows = createMemo(() => getPrimaryResourceIdentityRows(resource)); - const identityCardHasRichData = createMemo( - () => - primaryIdentityRows().length > 0 || - (resource.identity?.ips?.length || 0) > 0 || - (resource.tags?.length || 0) > 0 || - identityAliasValues().length > 0, - ); - const aliasPreviewValues = createMemo(() => - identityAliasValues().slice(0, ALIAS_COLLAPSE_THRESHOLD), - ); - const hasAliasOverflow = createMemo( - () => identityAliasValues().length > ALIAS_COLLAPSE_THRESHOLD, - ); - const hasIdentitySupportContext = createMemo( - () => - (resource.identity?.ips?.length ?? 0) > 0 || - (resource.tags?.length ?? 0) > 0 || - identityAliasValues().length > 0, - ); - const hasMergedSources = createMemo(() => mergedSources().length > 1); - const discoveryConfig = createMemo(() => toDiscoveryConfig(resource)); - const discoveryContextSummary = createMemo(() => { - const config = discoveryConfig(); - if (!config) return null; - - const discoveryMode = - config.resourceType === 'agent' - ? 'Host discovery' - : `${formatIdentifierLabel(config.resourceType)} discovery`; - - return config.hostname ? `${discoveryMode} via ${config.hostname}` : discoveryMode; - }); - const hostDetailCards = createMemo(() => { - const cards: string[] = []; - - if (proxmoxNode()) { - cards.push('system', 'hardware', 'storage'); - } - - const agent = agentInfo(); - if (agent) { - cards.push('system', 'hardware'); - if ((agent.networkInterfaces?.length ?? 0) > 0) cards.push('network'); - if ((agent.disks?.length ?? 0) > 0) cards.push('disks'); - if ((agentMeta()?.raid?.length ?? 0) > 0) cards.push('raid'); - if (temperatureRows().length > 0) cards.push('temperatures'); - } - - return cards; - }); - const hasHostDetails = createMemo(() => hostDetailCards().length > 0); - const hostDetailSummary = createMemo(() => { - const labels = Array.from(new Set(hostDetailCards())); - if (labels.length === 0) return null; - - const categories = - labels.length === 1 - ? labels[0] - : labels.length === 2 - ? `${labels[0]} and ${labels[1]}` - : `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`; - - return `${hostDetailCards().length} detail card${hostDetailCards().length === 1 ? '' : 's'} covering ${categories}.`; - }); - const hasServiceDetails = createMemo( - () => resource.type === 'docker-host' || Boolean(pbsData()) || Boolean(pmgData()), - ); - const serviceDetailsSummary = createMemo(() => { - if (resource.type === 'docker-host') { - return `${formatInteger(dockerContainerCount())} containers · ${formatInteger(dockerUpdatesAvailable())} updates`; - } - - const pbs = pbsData(); - if (pbs) { - return `${formatInteger(pbs.datastoreCount)} datastores · ${formatInteger(pbsJobTotal())} jobs`; - } - - const pmg = pmgData(); - if (pmg) { - return `${formatInteger(pmg.queueTotal)} queue total · ${formatInteger(pmgQueueBacklog())} backlog`; - } - - return null; - }); - const workloadsHref = createMemo(() => buildWorkloadsHref(resource)); - const headerIdentity = createMemo(() => getPrimaryResourceIdentity(resource)); - const relatedLinks = createMemo(() => { - const links: Array<{ href: string; label: string; compactLabel: string; ariaLabel: string }> = - []; - const workloads = workloadsHref(); - if (workloads) { - links.push({ - href: workloads, - label: 'Open in Workloads', - compactLabel: 'Workloads', - ariaLabel: `Open related workloads for ${displayName()}`, - }); - } - links.push(...buildServiceDetailLinks(resource)); - const seen = new Set(); - return links.filter((link) => { - if (seen.has(link.href)) return false; - seen.add(link.href); - return true; - }); - }); - const hasRuntimeOperationalContext = createMemo( - () => kubernetesCapabilityBadges().length > 0 || relatedLinks().length > 0, - ); - const sourceSections = createMemo(() => { - const data = platformData(); - if (!data) return []; - const sections = [ - { id: 'proxmox', label: 'Proxmox', payload: data.proxmox }, - { id: 'agent', label: 'Agent', payload: data.agent }, - { id: 'docker', label: 'Containers', payload: data.docker }, - { id: 'pbs', label: 'PBS', payload: data.pbs }, - { id: 'pmg', label: 'PMG', payload: data.pmg }, - { id: 'kubernetes', label: 'Kubernetes', payload: data.kubernetes }, - { id: 'metrics', label: 'Metrics', payload: data.metrics }, - ]; - return sections.filter((section) => section.payload !== undefined); - }); - const identityMatchInfo = createMemo(() => { - const data = platformData(); - return ( - data?.identityMatch ?? - data?.matchResults ?? - data?.matchCandidates ?? - data?.matches ?? - undefined - ); - }); - const debugBundle = createMemo(() => ({ + const history = useResourceDetailDrawerHistoryState({ resource }); + const derived = useResourceDetailDrawerDerivedState({ resource, - identity: { - resourceIdentity: resource.identity, - matchInfo: identityMatchInfo(), - }, - sources: { - sourceStatus: sourceStatus(), - proxmox: platformData()?.proxmox, - agent: platformData()?.agent, - docker: platformData()?.docker, - pbs: platformData()?.pbs, - pmg: platformData()?.pmg, - kubernetes: platformData()?.kubernetes, - metrics: platformData()?.metrics, - }, - })); - const debugJson = createMemo(() => JSON.stringify(debugBundle(), null, 2)); + resolveResourceLabel: resolveResourceLabelInput, + debugEnabled, + resourceIntelligence: history.resourceIntelligence, + }); createEffect(() => { if (!debugEnabled() && activeTab() === 'debug') { @@ -745,36 +48,16 @@ export const useResourceDetailDrawerState = ( } }); - const tabs = createMemo(() => { - const base = [ - { id: 'overview' as DrawerTab, label: 'Overview' }, - ...(resource.type === 'pmg' ? [{ id: 'mail' as DrawerTab, label: 'Mail' }] : []), - ...(resource.type === 'k8s-cluster' - ? [{ id: 'namespaces' as DrawerTab, label: 'Namespaces' }] - : []), - ...(resource.type === 'k8s-cluster' - ? [{ id: 'deployments' as DrawerTab, label: 'Deployments' }] - : []), - ...(resource.type === 'docker-host' && dockerSwarmClusterKey() - ? [{ id: 'swarm' as DrawerTab, label: 'Swarm' }] - : []), - ]; - if (debugEnabled()) { - base.push({ id: 'debug' as DrawerTab, label: 'Debug' }); - } - return base; - }); - createEffect(() => { const current = activeTab(); - const available = new Set(tabs().map((tab) => tab.id)); + const available = new Set(derived.tabs().map((tab) => tab.id)); if (!available.has(current)) { setActiveTab('overview'); } }); const handleCopyJson = async () => { - const payload = debugJson(); + const payload = derived.debugJson(); try { if (navigator?.clipboard?.writeText) { await navigator.clipboard.writeText(payload); @@ -819,37 +102,6 @@ export const useResourceDetailDrawerState = ( setShowPbsJobDetail, showPmgMailFlowDetail, setShowPmgMailFlowDetail, - displayName, - kubernetesClusterName, - resolveResourceLabel, - statusIndicator, - lastSeen, - lastSeenAbsolute, - platformBadge, - sourceBadge, - typeBadge, - unifiedSourceBadges, - hasUnifiedSources, - policyBadges, - policyRedactions, - governanceSummary, - hasGovernanceData, - platformData, - agentMeta, - kubernetesMeta, - kubernetesCapabilityBadges, - proxmoxNode, - agentInfo, - temperatureRows, - dockerHostData, - dockerHostSourceId, - dockerUpdatesAvailable, - dockerContainerCount, - dockerUpdatesCheckedRelative, - dockerHostCommand, - dockerHostCommandActive, - dockerUpdateActionsDisabled, - dockerUpdateActionsLoading, dockerActionError, setDockerActionError, dockerActionNote, @@ -858,63 +110,12 @@ export const useResourceDetailDrawerState = ( setConfirmUpdateAll, dockerActionBusy, setDockerActionBusy, - dockerSwarmInfo, - dockerSwarmClusterKey, k8sDeploymentsPrefillNamespace, setK8sDeploymentsPrefillNamespace, - timelineKindFilter, - setTimelineKindFilter, - timelineSourceTypeFilter, - setTimelineSourceTypeFilter, - timelineSourceAdapterFilter, - setTimelineSourceAdapterFilter, - resourceIntelligence, - resourceDependencies, - resourceDependents, - resourceCorrelations, - hasCorrelationContext, - hasInvestigationContext, - investigationContextSummary, - pbsData, - pmgData, - pbsJobTotal, - pmgQueueBacklog, - pmgUpdatedRelative, - pbsVisibleJobBreakdown, - pmgVisibleQueueBreakdown, - pmgVisibleMailBreakdown, - resourceTimeline, - historyFacetCounts, - historyRecentChanges, - hasTimelineFilters, - historyLoadingLabel, - resourceTimelineCount, - sortedResourceTimeline, - facetBundleError, - refetchHistoryFacets, - mergedSources, - sourceSummary, - identityAliasValues, - primaryIdentityRows, - identityCardHasRichData, - aliasPreviewValues, - hasAliasOverflow, - hasIdentitySupportContext, - hasMergedSources, - discoveryConfig, - discoveryContextSummary, - hasHostDetails, - hostDetailSummary, - hasServiceDetails, - serviceDetailsSummary, - headerIdentity, - relatedLinks, - hasRuntimeOperationalContext, - sourceSections, - sourceStatus, - identityMatchInfo, - debugJson, - tabs, + ...history, + ...derived, handleCopyJson, }; }; + +export type UseResourceDetailDrawerStateResult = ReturnType; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 452d423ea..10a7727e4 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -247,6 +247,8 @@ import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/Reso import resourceDetailDrawerDebugSource from '@/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx?raw'; import infrastructureSummarySource from '@/components/Infrastructure/InfrastructureSummary.tsx?raw'; import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw'; +import resourceDetailDrawerHistoryStateSource from '@/components/Infrastructure/useResourceDetailDrawerHistoryState.ts?raw'; +import resourceDetailDrawerDerivedStateSource from '@/components/Infrastructure/useResourceDetailDrawerDerivedState.ts?raw'; import resourceDetailDrawerStateSource from '@/components/Infrastructure/useResourceDetailDrawerState.ts?raw'; import unifiedResourceTableSource from '@/components/Infrastructure/UnifiedResourceTable.tsx?raw'; import unifiedResourceTableStateSource from '@/components/Infrastructure/useUnifiedResourceTableState.ts?raw'; @@ -523,6 +525,8 @@ const resourceDetailDrawerSource = [ resourceDetailDrawerShellSource, resourceDetailDrawerOverviewSource, resourceDetailDrawerDebugSource, + resourceDetailDrawerHistoryStateSource, + resourceDetailDrawerDerivedStateSource, resourceDetailDrawerStateSource, ].join('\n'); @@ -874,6 +878,11 @@ describe('frontend resource type boundaries', () => { "from './ResourceDetailDrawerOverviewTab'", ); expect(resourceDetailDrawerShellSource).toContain("from './ResourceDetailDrawerDebugTab'"); + expect(resourceDetailDrawerStateSource).toContain("from './useResourceDetailDrawerHistoryState'"); + expect(resourceDetailDrawerStateSource).toContain("from './useResourceDetailDrawerDerivedState'"); + expect(resourceDetailDrawerStateSource).not.toContain('createResource('); + expect(resourceDetailDrawerHistoryStateSource).toContain('createResource('); + expect(resourceDetailDrawerDerivedStateSource).toContain('buildWorkloadsHref'); expect(guestDrawerSource).toContain('useGuestDrawerState'); expect(guestDrawerSource).toContain('GuestDrawerOverview'); expect(guestDrawerStateSource).toContain('getCanonicalWorkloadId'); @@ -2480,7 +2489,7 @@ describe('frontend resource type boundaries', () => { 'export const ENVIRONMENT_LOCK_BUTTON_TITLE', ); expect(resourceDetailDrawerSource).toContain('getServiceHealthPresentation'); - expect(resourceDetailDrawerSource).toContain('ResourceAPI.getFacetBundle'); + expect(resourceDetailDrawerHistoryStateSource).toContain('ResourceAPI.getFacetBundle'); expect(resourceDetailDrawerSource).toContain('History'); expect(resourceDetailDrawerSource).toContain('RESOURCE_CHANGE_KIND_ORDER'); expect(resourceDetailDrawerSource).toContain('RESOURCE_CHANGE_SOURCE_TYPE_ORDER'); @@ -2506,6 +2515,7 @@ describe('frontend resource type boundaries', () => { expect(approvalPresentationSource).toContain('getResourceApprovalLevelLabel'); expect(throughputPresentationSource).toContain('formatThroughputRate'); expect(resourceDetailDrawerSource).toContain('formatIdentifierLabel'); + expect(resourceDetailDrawerDerivedStateSource).toContain('formatIdentifierLabel'); expect(resourceChangePresentationSource).toContain('humanizeToken'); expect(textPresentationSource).toContain('humanizeArrowDelimitedLabel'); expect(resourceCorrelationPresentationSource).not.toContain(