From e17dc98cf912dde727579f2fd68cbc0115918024 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 20 Mar 2026 18:35:09 +0000 Subject: [PATCH] Extract resource detail drawer state owner --- .../v6/internal/subsystems/registry.json | 2 + .../internal/subsystems/unified-resources.md | 8 +- .../Infrastructure/ResourceDetailDrawer.tsx | 1666 ++++++----------- .../ResourceDetailDrawer.history.test.tsx | 49 +- .../useResourceDetailDrawerState.ts | 918 +++++++++ .../frontendResourceTypeBoundaries.test.ts | 8 +- 6 files changed, 1525 insertions(+), 1126 deletions(-) create mode 100644 frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 8b619b813..c400c27a9 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2498,6 +2498,7 @@ "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", "frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx", "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", "frontend-modern/src/hooks/useDashboardTrends.ts", "frontend-modern/src/hooks/useUnifiedResources.ts", "frontend-modern/src/pages/Infrastructure.tsx", @@ -2566,6 +2567,7 @@ "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", "frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx", "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", "frontend-modern/src/hooks/useDashboardTrends.ts", "frontend-modern/src/hooks/useUnifiedResources.ts", "frontend-modern/src/pages/Infrastructure.tsx", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ce7a1e783..518e9e7dd 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -45,6 +45,7 @@ cross-source deduplication. 23. `internal/unifiedresources/actions.go` 24. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx` 25. `frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx` +26. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts` ## Shared Boundaries @@ -70,7 +71,7 @@ assembly branch. 4. Add metrics-target normalization or synthetic metrics support through `internal/unifiedresources/metrics_targets.go` and `internal/unifiedresources/metrics.go` 5. Add platform registry, resolution, or host-dedup behavior through `internal/unifiedresources/registry.go`, `internal/unifiedresources/resolve.go`, `internal/unifiedresources/resolved_host_set.go`, `internal/unifiedresources/snapshot_source_filter.go`, `internal/unifiedresources/store.go`, `internal/unifiedresources/kubernetes_capabilities.go`, and `internal/unifiedresources/pbs_rollups.go` 6. Add canonical governed name-resolution or policy-aware resource lookup behavior through `internal/unifiedresources/resolve.go` and `internal/unifiedresources/resolve_context.go` -7. Add or change resource drawer timeline/facet presentation through `frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx`, `frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx`, `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts`, and the governed `internal/api/resources.go` facet/timeline contract together +7. Add or change resource drawer timeline/facet presentation through `frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx`, `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`, `frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx`, `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts`, and the governed `internal/api/resources.go` facet/timeline contract together ## Forbidden Paths @@ -241,6 +242,11 @@ The frontend now also consumes those facet reads through `frontend-modern/src/api/resources.ts` and the dedicated resource detail drawer, which keeps the presentation surface aligned with the governed API contract instead of rebuilding the relationship and timeline inline. +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. 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/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index 1d1ab4bee..5559f293d 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -1,12 +1,4 @@ -import { - Show, - Suspense, - createMemo, - For, - createSignal, - createEffect, - createResource, -} from 'solid-js'; +import { Show, Suspense, For } from 'solid-js'; import type { Component, JSX } from 'solid-js'; import type { Resource, @@ -14,27 +6,10 @@ import type { ResourceChangeSourceAdapter, ResourceChangeSourceType, } from '@/types/resource'; -import { requiresGovernedResourceDisplay } from '@/types/resource'; -import { formatUptime, formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; +import { formatUptime, formatRelativeTime } from '@/utils/format'; import { StatusDot } from '@/components/shared/StatusDot'; import { Card } from '@/components/shared/Card'; import { TagBadges } from '@/components/Dashboard/TagBadges'; -import { getAgentStatusIndicator } from '@/utils/status'; -import { - getPlatformBadge, - getSourceBadge, - getTypeBadge, - getUnifiedSourceBadges, -} from '@/utils/resourceBadgePresentation'; -import { buildWorkloadsHref } from './workloadsLink'; -import { buildServiceDetailLinks } from './serviceDetailLinks'; -import { - getPrimaryResourceIdentity, - getPrimaryResourceIdentityRows, - getResourceIdentityAliases, - getPreferredResourceClusterName, - getPreferredResourceDisplayName, -} from '@/utils/resourceIdentity'; import { SystemInfoCard } from '@/components/shared/cards/SystemInfoCard'; import { HardwareCard } from '@/components/shared/cards/HardwareCard'; import { RootDiskCard } from '@/components/shared/cards/RootDiskCard'; @@ -42,7 +17,6 @@ import { NetworkInterfacesCard } from '@/components/shared/cards/NetworkInterfac import { DisksCard } from '@/components/shared/cards/DisksCard'; import { TemperaturesCard } from '@/components/shared/cards/TemperaturesCard'; import { RaidCard } from '@/components/shared/cards/RaidCard'; -import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage'; import { getDiscoveryLoadingState } from '@/utils/discoveryPresentation'; import { ReportMergeModal } from './ReportMergeModal'; import { DiscoveryTab } from '@/components/Discovery/DiscoveryTab'; @@ -50,16 +24,10 @@ import { PMGInstanceDrawer } from '@/components/PMG/PMGInstanceDrawer'; import { K8sDeploymentsDrawer } from '@/components/Kubernetes/K8sDeploymentsDrawer'; import { K8sNamespacesDrawer } from '@/components/Kubernetes/K8sNamespacesDrawer'; import { MonitoringAPI } from '@/api/monitoring'; -import { AIAPI } from '@/api/ai'; -import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings'; import { SwarmServicesDrawer } from '@/components/Docker/SwarmServicesDrawer'; import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField'; import { getServiceHealthPresentation } from '@/utils/serviceHealthPresentation'; -import { ResourceAPI } from '@/api/resources'; import { - getResourcePolicyBadges, - getResourcePolicyDisplayLabel, - getResourcePolicyRedactionLabels, getResourceRoutingScopeLabel, getResourceSensitivityLabel, } from '@/utils/resourcePolicyPresentation'; @@ -76,8 +44,9 @@ import { } from '@/utils/resourceChangePresentation'; import { formatConfidenceLabel } from '@/utils/confidencePresentation'; import { formatIdentifierLabel } from '@/utils/textPresentation'; -import type { ResourceIntelligence } from '@/types/aiIntelligence'; import { buildInfrastructureResourceHref } from '@/routing/resourceLinks'; +import { formatInteger, formatSourceType } from './resourceDetailMappers'; +import { useResourceDetailDrawerState } from './useResourceDetailDrawerState'; interface ResourceDetailDrawerProps { resource: Resource; @@ -85,20 +54,6 @@ interface ResourceDetailDrawerProps { resolveResourceLabel?: (resourceId: string) => string | null | undefined; } -import { - type AgentPlatformData, - type KubernetesPlatformData, - type PlatformData, - type DockerPlatformData, - toDiscoveryConfig, - toNodeFromProxmox, - toAgentFromResource, - buildTemperatureRows, - formatInteger, - ALIAS_COLLAPSE_THRESHOLD, - formatSourceType, -} from './resourceDetailMappers'; - const hasMetadataEntries = (value?: Record | null): boolean => Boolean(value && Object.keys(value).length > 0); @@ -201,621 +156,125 @@ const timelineSourceAdapterOptions: Array<{ ]; const DrawerContent: Component = (props) => { - type DrawerTab = - | 'overview' - | 'mail' - | 'namespaces' - | 'deployments' - | 'swarm' - | 'debug'; - const [activeTab, setActiveTab] = createSignal('overview'); - const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false); - const [copied, setCopied] = createSignal(false); - const [showReportModal, setShowReportModal] = createSignal(false); - const [showInvestigationContext, setShowInvestigationContext] = createSignal(false); - const [showCorrelationContext, setShowCorrelationContext] = createSignal(false); - const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false); - const [showHostDetails, setShowHostDetails] = createSignal(false); - const [showServiceDetails, setShowServiceDetails] = createSignal(false); - const [showDockerUpdateControls, setShowDockerUpdateControls] = createSignal(false); - const [showPbsJobDetail, setShowPbsJobDetail] = createSignal(false); - const [showPmgMailFlowDetail, setShowPmgMailFlowDetail] = createSignal(false); - - const displayName = createMemo(() => getPreferredResourceDisplayName(props.resource)); - const kubernetesClusterName = createMemo(() => - getPreferredResourceClusterName(props.resource) ?? '', - ); - const resolveResourceLabel = (resourceId: string): string => - props.resolveResourceLabel?.(resourceId)?.trim() || resourceId; - const statusIndicator = createMemo(() => - getAgentStatusIndicator({ status: props.resource.status }), - ); - const lastSeen = createMemo(() => formatRelativeTime(props.resource.lastSeen)); - const lastSeenAbsolute = createMemo(() => formatAbsoluteTime(props.resource.lastSeen)); - - const platformBadge = createMemo(() => getPlatformBadge(props.resource.platformType)); - const sourceBadge = createMemo(() => getSourceBadge(props.resource.sourceType)); - const typeBadge = createMemo(() => getTypeBadge(props.resource.type)); - const unifiedSourceBadges = createMemo(() => { - const platformData = props.resource.platformData as PlatformData | undefined; - return getUnifiedSourceBadges(platformData?.sources ?? []); - }); - const hasUnifiedSources = createMemo(() => unifiedSourceBadges().length > 0); - const policyBadges = createMemo(() => getResourcePolicyBadges(props.resource.policy)); - const policyRedactions = createMemo(() => - getResourcePolicyRedactionLabels(props.resource.policy), - ); - const governanceSummary = createMemo(() => - requiresGovernedResourceDisplay(props.resource.policy) - ? getResourcePolicyDisplayLabel(props.resource) - : props.resource.aiSafeSummary?.trim() ?? '', - ); - const hasGovernanceData = createMemo( - () => policyBadges().length > 0 || Boolean(governanceSummary()), - ); - const platformData = createMemo(() => props.resource.platformData as PlatformData | undefined); - const agentMeta = createMemo( - () => props.resource.agent ?? (platformData()?.agent as AgentPlatformData | undefined), - ); - const kubernetesMeta = createMemo( - () => - props.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(props.resource)); - const agentInfo = createMemo(() => toAgentFromResource(props.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(() => props.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 (props.resource.policy?.routing.scope) { - summary.push(`Routing ${getResourceRoutingScopeLabel(props.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 ?? props.resource.recentChanges ?? [], - ); - const resourceFacetCounts = createMemo( - () => resourceFacets()?.counts ?? props.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 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(props.resource)); - const primaryIdentityRows = createMemo(() => getPrimaryResourceIdentityRows(props.resource)); - const identityCardHasRichData = createMemo( - () => - primaryIdentityRows().length > 0 || - (props.resource.identity?.ips?.length || 0) > 0 || - (props.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( - () => - (props.resource.identity?.ips?.length ?? 0) > 0 || - (props.resource.tags?.length ?? 0) > 0 || - identityAliasValues().length > 0, - ); - const hasMergedSources = createMemo(() => mergedSources().length > 1); - const discoveryConfig = createMemo(() => toDiscoveryConfig(props.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( - () => props.resource.type === 'docker-host' || Boolean(pbsData()) || Boolean(pmgData()), - ); - const serviceDetailsSummary = createMemo(() => { - if (props.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(props.resource)); - const headerIdentity = createMemo(() => getPrimaryResourceIdentity(props.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(props.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 { + activeTab, + setActiveTab, + debugEnabled, + copied, + showReportModal, + setShowReportModal, + showInvestigationContext, + setShowInvestigationContext, + showCorrelationContext, + setShowCorrelationContext, + showDiscoveryContext, + setShowDiscoveryContext, + showHostDetails, + setShowHostDetails, + showServiceDetails, + setShowServiceDetails, + showDockerUpdateControls, + setShowDockerUpdateControls, + showPbsJobDetail, + setShowPbsJobDetail, + showPmgMailFlowDetail, + setShowPmgMailFlowDetail, + displayName, + kubernetesClusterName, + resolveResourceLabel, + statusIndicator, + lastSeen, + lastSeenAbsolute, + platformBadge, + sourceBadge, + typeBadge, + unifiedSourceBadges, + hasUnifiedSources, + policyRedactions, + governanceSummary, + hasGovernanceData, + agentMeta, + kubernetesCapabilityBadges, + proxmoxNode, + agentInfo, + temperatureRows, + dockerHostData, + dockerHostSourceId, + dockerUpdatesAvailable, + dockerContainerCount, + dockerUpdatesCheckedRelative, + dockerHostCommand, + dockerHostCommandActive, + dockerUpdateActionsDisabled, + dockerUpdateActionsLoading, + dockerActionError, + setDockerActionError, + dockerActionNote, + setDockerActionNote, + confirmUpdateAll, + 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, + 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, + identityMatchInfo, + tabs, + handleCopyJson, + } = useResourceDetailDrawerState({ resource: props.resource, - identity: { - resourceIdentity: props.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)); - - createEffect(() => { - if (!debugEnabled() && activeTab() === 'debug') { - setActiveTab('overview'); - } + resolveResourceLabel: props.resolveResourceLabel, }); - const tabs = createMemo(() => { - const base = [ - { id: 'overview' as DrawerTab, label: 'Overview' }, - ...(props.resource.type === 'pmg' ? [{ id: 'mail' as DrawerTab, label: 'Mail' }] : []), - ...(props.resource.type === 'k8s-cluster' - ? [{ id: 'namespaces' as DrawerTab, label: 'Namespaces' }] - : []), - ...(props.resource.type === 'k8s-cluster' - ? [{ id: 'deployments' as DrawerTab, label: 'Deployments' }] - : []), - ...(props.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)); - if (!available.has(current)) { - setActiveTab('overview'); - } - }); - - const handleCopyJson = async () => { - const payload = debugJson(); - try { - if (navigator?.clipboard?.writeText) { - await navigator.clipboard.writeText(payload); - } else { - const textarea = document.createElement('textarea'); - textarea.value = payload; - textarea.setAttribute('readonly', 'true'); - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - } - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - setCopied(false); - } - }; - return (
@@ -1039,7 +498,10 @@ const DrawerContent: Component = (props) => {
0} + when={ + props.resource.identity?.ips && + props.resource.identity.ips.length > 0 + } >
IP Addresses @@ -1112,11 +574,7 @@ const DrawerContent: Component = (props) => {
- +
Identity
@@ -1164,15 +622,7 @@ const DrawerContent: Component = (props) => {
-
- {timelineFacetRequest() - ? timelineFacets.loading - ? 'Refreshing filtered changes...' - : 'Filtered changes loaded' - : resourceFacets.loading - ? 'Refreshing changes...' - : 'Changes loaded'} -
+
{historyLoadingLabel()}
Change filters active
@@ -1343,7 +793,9 @@ const DrawerContent: Component = (props) => { - 0}> + 0} + >
Related: @@ -1388,413 +840,420 @@ const DrawerContent: Component = (props) => { contentClass="mt-3 space-y-3" dataTestId="resource-service-details-section" > - -
-
-
- Docker runtime -
- - - {dockerHostData()?.runtime} - - + +
+
+
+ Docker runtime
- -
-
- Containers - - {formatInteger(dockerContainerCount())} - -
-
- Updates - 0 ? 'text-sky-700 dark:text-sky-300' : 'text-base-content'}`} - > - {formatInteger(dockerUpdatesAvailable())} - -
- -
- Checked - - {dockerUpdatesCheckedRelative()} - -
-
- - -
- -
-
- Action - - {formatIdentifierLabel(dockerHostCommand()?.type, { - fallback: 'command', - })} - -
-
- State - - {formatIdentifierLabel(dockerHostCommand()?.status, { - fallback: 'unknown', - })} - -
- -
- {dockerHostCommand()?.message} -
-
- -
- {dockerHostCommand()?.failureReason} -
-
-
-
- - -
- {dockerActionError()} -
-
- -
- {dockerActionNote()} -
-
- -
- - - -
-
-
- - -
+ {dockerHostData()?.runtime} + +
- - - {(pbs) => { - const connection = getServiceHealthPresentation( - props.resource.status, - pbs().connectionHealth, - ); - return ( -
-
-
- PBS -
- - - {pbs().hostname} - - -
-
-
- State - {connection.label} -
- +
+
+ Containers + + {formatInteger(dockerContainerCount())} + +
+
+ Updates + 0 ? 'text-sky-700 dark:text-sky-300' : 'text-base-content'}`} + > + {formatInteger(dockerUpdatesAvailable())} + +
+ +
+ Checked + + {dockerUpdatesCheckedRelative()} + +
+
+ + +
+ +
- Version - {pbs().version} -
- - -
- Uptime + Action - {formatUptime(pbs().uptimeSeconds ?? props.resource.uptime ?? 0)} + {formatIdentifierLabel(dockerHostCommand()?.type, { + fallback: 'command', + })}
-
- -
-
-
-
Datastores
-
- {formatInteger(pbs().datastoreCount)} -
-
-
-
Jobs
-
- {formatInteger(pbsJobTotal())} -
-
-
-
- - Types - {pbsVisibleJobBreakdown().length} - -
- - {(entry) => ( - - {entry.label}:{' '} - - {formatInteger(entry.value)} - - - )} - -
-
+
+ State + + {formatIdentifierLabel(dockerHostCommand()?.status, { + fallback: 'unknown', + })} +
- + +
+ {dockerHostCommand()?.message} +
+
+ +
+ {dockerHostCommand()?.failureReason} +
+
+
+
+ + +
+ {dockerActionError()} +
+
+ +
+ {dockerActionNote()} +
+
+ +
+ +
- ); - }} -
+ - - {(pmg) => { - const connection = getServiceHealthPresentation( - props.resource.status, - pmg().connectionHealth, - ); - return ( -
-
-
- PMG -
- - - {pmg().hostname} - - + +
+
+
+ + + {(pbs) => { + const connection = getServiceHealthPresentation( + props.resource.status, + pbs().connectionHealth, + ); + return ( +
+
+
+ PBS
-
+ + + {pbs().hostname} + + +
+
+
+ State + {connection.label} +
+
- State - {connection.label} + Version + {pbs().version}
- -
- Version - {pmg().version} -
-
- -
- Uptime - - {formatUptime(pmg().uptimeSeconds ?? props.resource.uptime ?? 0)} - -
-
- -
-
-
-
Queue
-
0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`} - > - {formatInteger(pmg().queueTotal)} -
-
-
-
Backlog
-
0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`} - > - {formatInteger(pmgQueueBacklog())} -
+ + +
+ Uptime + + {formatUptime(pbs().uptimeSeconds ?? props.resource.uptime ?? 0)} + +
+
+ +
+
+
+
Datastores
+
+ {formatInteger(pbs().datastoreCount)}
- +
+
Jobs
+
+ {formatInteger(pbsJobTotal())} +
+
+
+
+ + Types + {pbsVisibleJobBreakdown().length} + +
+ + {(entry) => ( + + {entry.label}:{' '} + + {formatInteger(entry.value)} + + + )} + +
+
+
+
+ +
+
+ ); + }} + + + + {(pmg) => { + const connection = getServiceHealthPresentation( + props.resource.status, + pmg().connectionHealth, + ); + return ( +
+
+
+ PMG +
+ + + {pmg().hostname} + + +
+
+
+ State + {connection.label} +
+ +
+ Version + {pmg().version} +
+
+ +
+ Uptime + + {formatUptime(pmg().uptimeSeconds ?? props.resource.uptime ?? 0)} + +
+
+ +
+
+
+
Queue
0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`} > - -
- Nodes - - {formatInteger(pmg().nodeCount)} - -
-
- -
- Updated - - {pmgUpdatedRelative()} - -
-
+ {formatInteger(pmg().queueTotal)}
- -
- - Queue detail - -
- - {(entry) => ( -
- {entry.label} - - {formatInteger(entry.value)} - -
- )} -
+
+
+
Backlog
+
0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`} + > + {formatInteger(pmgQueueBacklog())}
-
-
- - Mail detail - -
- - {(entry) => ( -
- {entry.label} - - {formatInteger(entry.value)} - -
- )} -
-
-
+
- - -
+ +
+ +
+ Nodes + + {formatInteger(pmg().nodeCount)} + +
+
+ +
+ Updated + + {pmgUpdatedRelative()} + +
+
+
+
+
+ + Queue detail + +
+ + {(entry) => ( +
+ {entry.label} + + {formatInteger(entry.value)} + +
+ )} +
+
+
+
+ + Mail detail + +
+ + {(entry) => ( +
+ {entry.label} + + {formatInteger(entry.value)} + +
+ )} +
+
+
+
+ +
- ); - }} -
+
+ ); + }} +
@@ -1877,10 +1336,7 @@ const DrawerContent: Component = (props) => { compact /> -
+
Correlation context @@ -1968,48 +1424,46 @@ const DrawerContent: Component = (props) => {
- {(config) => ( -
- - - setShowDiscoveryContext((value) => !value)} - showLabel="Show metadata" - hideLabel="Hide metadata" - class="h-full" - dataTestId="resource-discovery-context" - > - -
- - {getDiscoveryLoadingState().text} - -
- } - > - -
-
-
- )} -
- -
+ {(config) => ( +
+ + setShowDiscoveryContext((value) => !value)} + showLabel="Show metadata" + hideLabel="Hide metadata" + class="h-full" + dataTestId="resource-discovery-context" + > + +
+ + {getDiscoveryLoadingState().text} + +
+ } + > + +
+
+
+ )} + +
{/* PMG Mail Tab */} @@ -2018,7 +1472,9 @@ const DrawerContent: Component = (props) => { } + fallback={ + + } > = (props) => { + } > { expect(screen.queryByText('Runtime')).toBeNull(); expect(screen.getByText('Change history')).toBeInTheDocument(); expect(screen.getByTestId('resource-secondary-sections').classList.contains('flex')).toBe(true); - expect( - screen.getByTestId('resource-secondary-sections').classList.contains('flex-wrap'), - ).toBe(true); + expect(screen.getByTestId('resource-secondary-sections').classList.contains('flex-wrap')).toBe( + true, + ); expect( screen.getByTestId('resource-summary-section').querySelectorAll('.bg-surface-hover.px-2.py-2') .length, ).toBe(0); const summarySection = screen.getByTestId('resource-summary-section'); expect(summarySection.querySelector('.mt-3.grid.gap-3')).toBeTruthy(); - expect(summarySection.querySelector('.mt-3.grid.gap-3')?.classList.contains('sm:grid-cols-2')).toBe(true); - expect(screen.getByTestId('resource-current-state-section').classList.contains('rounded-md')).toBe( - true, - ); - expect(screen.getByTestId('resource-current-state-section').classList.contains('bg-surface')).toBe( - true, - ); - expect(screen.getByTestId('resource-current-state-section').classList.contains('shadow-sm')).toBe( - true, - ); + expect( + summarySection.querySelector('.mt-3.grid.gap-3')?.classList.contains('sm:grid-cols-2'), + ).toBe(true); + expect( + screen.getByTestId('resource-current-state-section').classList.contains('rounded-md'), + ).toBe(true); + expect( + screen.getByTestId('resource-current-state-section').classList.contains('bg-surface'), + ).toBe(true); + expect( + screen.getByTestId('resource-current-state-section').classList.contains('shadow-sm'), + ).toBe(true); expect(screen.getByTestId('resource-identity-section').classList.contains('rounded-md')).toBe( true, ); @@ -247,9 +249,9 @@ describe('ResourceDetailDrawer change history section', () => { ).toBeNull(); expect(screen.getByRole('button', { name: 'Show metadata' })).toBeInTheDocument(); expect( - screen.getByTestId('resource-discovery-context').querySelector( - '.mt-3.rounded.border.border-border.bg-surface.p-2\\.5', - ), + screen + .getByTestId('resource-discovery-context') + .querySelector('.mt-3.rounded.border.border-border.bg-surface.p-2\\.5'), ).toBeNull(); expect(screen.queryByText('Details')).toBeNull(); expect(screen.queryByRole('button', { name: 'Show details' })).toBeNull(); @@ -290,7 +292,9 @@ describe('ResourceDetailDrawer change history section', () => { expect(screen.getByText('stable')).toBeInTheDocument(); expect(screen.getByText('Notes')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); - expect(screen.getByTestId('resource-correlation-context').querySelector('.rounded.border')).toBeNull(); + expect( + screen.getByTestId('resource-correlation-context').querySelector('.rounded.border'), + ).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Show correlations' })); expect( screen @@ -474,7 +478,9 @@ describe('ResourceDetailDrawer change history section', () => { expect( screen.getByTestId('resource-service-details-section').querySelector('.mt-3.space-y-3'), ).toBeTruthy(); - expect(screen.getByTestId('resource-service-details-section').querySelector('.mt-3.grid')).toBeNull(); + expect( + screen.getByTestId('resource-service-details-section').querySelector('.mt-3.grid'), + ).toBeNull(); expect(serviceDetails.getByText('PBS')).toBeInTheDocument(); expect(serviceDetails.queryByText('PBS Service')).toBeNull(); expect(serviceDetails.queryByText('Connection')).toBeNull(); @@ -663,6 +669,13 @@ describe('ResourceDetailDrawer change history section', () => { expect(panel.queryByText('Timeline 2')).toBeNull(); expect(await panel.findByText('Routine restart requested')).toBeInTheDocument(); expect(panel.queryByText('CPU spike detected')).toBeNull(); + + fireEvent.click(panel.getByRole('button', { name: 'Clear filters' })); + + expect(await panel.findByText('Changes loaded')).toBeInTheDocument(); + expect(await panel.findByText('Timeline 2')).toBeInTheDocument(); + expect(panel.queryByText('Filtered changes loaded')).toBeNull(); + expect(panel.getByText('CPU spike detected')).toBeInTheDocument(); }); it('filters timeline entries by source adapter', async () => { diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts new file mode 100644 index 000000000..522839e5d --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts @@ -0,0 +1,918 @@ +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 { 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'; + +type DrawerTab = 'overview' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug'; + +export interface UseResourceDetailDrawerStateOptions { + resource: Resource; + 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>; + identityMatchInfo: Accessor; + debugJson: Accessor; + tabs: Accessor>; + handleCopyJson: () => Promise; +} + +export const useResourceDetailDrawerState = ( + options: UseResourceDetailDrawerStateOptions, +): UseResourceDetailDrawerStateResult => { + const { resource, resolveResourceLabel: resolveResourceLabelInput } = options; + const [activeTab, setActiveTab] = createSignal('overview'); + const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false); + const [copied, setCopied] = createSignal(false); + const [showReportModal, setShowReportModal] = createSignal(false); + const [showInvestigationContext, setShowInvestigationContext] = createSignal(false); + const [showCorrelationContext, setShowCorrelationContext] = createSignal(false); + const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false); + const [showHostDetails, setShowHostDetails] = createSignal(false); + const [showServiceDetails, setShowServiceDetails] = createSignal(false); + 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(() => ({ + 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)); + + createEffect(() => { + if (!debugEnabled() && activeTab() === 'debug') { + setActiveTab('overview'); + } + }); + + 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)); + if (!available.has(current)) { + setActiveTab('overview'); + } + }); + + const handleCopyJson = async () => { + const payload = debugJson(); + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(payload); + } else { + const textarea = document.createElement('textarea'); + textarea.value = payload; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } + }; + + return { + activeTab, + setActiveTab, + debugEnabled, + copied, + showReportModal, + setShowReportModal, + showInvestigationContext, + setShowInvestigationContext, + showCorrelationContext, + setShowCorrelationContext, + showDiscoveryContext, + setShowDiscoveryContext, + showHostDetails, + setShowHostDetails, + showServiceDetails, + setShowServiceDetails, + showDockerUpdateControls, + setShowDockerUpdateControls, + showPbsJobDetail, + 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, + setDockerActionNote, + confirmUpdateAll, + 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, + identityMatchInfo, + debugJson, + tabs, + handleCopyJson, + }; +}; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index d877d99d1..20c380e5f 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -165,9 +165,10 @@ import securityWarningSource from '@/components/SecurityWarning.tsx?raw'; import licensePresentationSource from '@/utils/licensePresentation.ts?raw'; import securityScorePresentationSource from '@/utils/securityScorePresentation.ts?raw'; import securityAuthPresentationSource from '@/utils/securityAuthPresentation.ts?raw'; -import resourceDetailDrawerSource from '@/components/Infrastructure/ResourceDetailDrawer.tsx?raw'; +import resourceDetailDrawerShellSource from '@/components/Infrastructure/ResourceDetailDrawer.tsx?raw'; import infrastructureSummarySource from '@/components/Infrastructure/InfrastructureSummary.tsx?raw'; import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw'; +import resourceDetailDrawerStateSource from '@/components/Infrastructure/useResourceDetailDrawerState.ts?raw'; import unifiedResourceTableSource from '@/components/Infrastructure/UnifiedResourceTable.tsx?raw'; import useUnifiedResourcesSource from '@/hooks/useUnifiedResources.ts?raw'; import useWorkloadsSource from '@/hooks/useWorkloads.ts?raw'; @@ -316,6 +317,11 @@ const aiSettingsSource = [ aiSettingsModelSource, ].join('\n'); +const resourceDetailDrawerSource = [ + resourceDetailDrawerShellSource, + resourceDetailDrawerStateSource, +].join('\n'); + describe('frontend resource type boundaries', () => { it('keeps the shared compatibility adapter narrow and explicit', () => { expect(resourceTypeCompatSource).toContain('export const canonicalizeFrontendResourceType');