diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index c2d85c582..cc691f494 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -178,7 +178,14 @@ state remains a separate `unraid_sync_active` reason. Realtime resource broadcasts must preserve canonical identity, discovery target, metrics target, incident rollups, and raw `agent`/`storage` facet payloads so frontend infrastructure surfaces can explain degraded/warning rows without falling back -to generic status labels. +to generic status labels. That realtime broadcast contract also owns source and +platform identity for storage resources: `internal/monitoring/monitor.go` must +carry the canonical `Resource.Sources` array onto `ResourceFrontend` and +`platformData.sources`, and must derive storage `platformType` from the owning +source/facet instead of treating the `storage` resource type as Proxmox by +default. Appliance presentation details such as Unraid array identity may remain +inside storage metadata, but agent-backed storage must stay canonical +`platformType=agent`. VMware vSphere now also has a locked phase-1 ingestion boundary under this lane. The admitted direction is vCenter-only in phase 1, and monitoring must stay API-first through the diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 0ad24ad7d..04b17cb38 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -189,7 +189,11 @@ regression protection. from the already-materialized resource incident and storage-risk fields, but it must not introduce per-row API reads, broad resource scans, storage topology recomputation, or layout-measuring work just to explain warning or - degraded rows. + degraded rows. Infrastructure table source derivation follows the same + bounded-work rule: `unifiedResourceTableStateModel.ts` must read the + already-materialized top-level `resource.sources` array before legacy + `platformData.sources` hints, and must not reconstruct platform identity by + scanning sibling resources or making row-time API calls. 5. Extend workload hot-path filter, sort, grouping, and stats math through `frontend-modern/src/components/Workloads/workloadSelectors.ts`, and extend workload identity, discovery routing, and node-topology helpers through `frontend-modern/src/components/Workloads/workloadTopology.ts`, rather than duplicating selector or topology logic in `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx` The retired dashboard overview route must not return as a hot-path orientation shortcut. First-viewport system count, health, source coverage, diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 933795e43..3f699ca60 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -644,13 +644,22 @@ presentation and host/appliance support-floor copy. Raw appliance identity aliases such as `unraid-os` may be accepted only through the generated host-profile token projection and must resolve to a governed profile id before they reach platform filters, source IDs, or top-level resource identity. +Agent-backed storage resources follow the same distinction: `StorageMeta.platform` +may carry appliance presentation context such as `unraid` so the operator can see +what system owns the array, but realtime `platformType` and source filters must +remain agent-backed unless the storage resource is actually owned by a governed +API platform such as Proxmox, PBS, TrueNAS, or VMware. Storage resources must not +fall back to Proxmox solely because their canonical resource type is `storage`. That same shared source boundary also applies when unified seeds and supplemental providers coexist. If a canonical unified-resource seed omits an owned supplemental source such as TrueNAS or VMware, the shared resource API must still ingest that provider-owned source instead of letting the seed -silence the platform entirely. Operator-facing source filters may accept the -`vmware-vsphere` alias for the VMware platform, but the emitted shared source -family remains canonical `vmware`. +silence the platform entirely. When a supplemental provider identity-matches an +existing resource, adding the source tag alone is not enough; the matching +provider facet must be merged onto the shared resource so source identity, +health, routing, and detail payloads cannot disagree. Operator-facing source +filters may accept the `vmware-vsphere` alias for the VMware platform, but the +emitted shared source family remains canonical `vmware`. That same canonical identity boundary now also applies to infrastructure summary emphasis. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx` may vary card presentation, but the active sparkline or density-map series must diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index 12db1e5a1..538b99653 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -154,6 +154,28 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { expect(getByText('Unraid array is running check')).toBeInTheDocument(); }); + it('uses canonical system identity in the drawer header for Unraid agent hosts', () => { + const resource = baseResource({ + platformType: 'agent', + sourceType: 'hybrid', + sources: ['agent', 'docker'], + platformData: { + sources: ['kubernetes'], + agent: { + platform: 'linux', + osName: 'Unraid OS 7.2.2', + osVersion: '7.2.2', + }, + }, + }); + + const { getByTestId, queryByText } = render(() => ); + const headerBadges = getByTestId('resource-header-badges'); + + expect(within(headerBadges).getByText('Unraid')).toBeInTheDocument(); + expect(queryByText('K8s')).toBeNull(); + }); + it('keeps discovery as secondary overview context instead of a peer tab', async () => { const { getByRole, getByText, getByTestId, queryByRole, queryByTestId, queryByText } = render( () => , diff --git a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts index 65ccccb09..a145150c5 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts @@ -210,6 +210,14 @@ describe('unifiedResourceTableStateModel', () => { expect( getUnifiedSources(makeResource('a', { platformData: { sources: ['proxmox', 'agent'] } })), ).toEqual(['proxmox', 'agent']); - expect(getUnifiedSources(makeResource('b', { platformData: {} }))).toEqual([]); + expect( + getUnifiedSources( + makeResource('b', { + sources: ['agent', 'docker'], + platformData: { sources: ['kubernetes'] }, + }), + ), + ).toEqual(['agent', 'docker']); + expect(getUnifiedSources(makeResource('c', { platformData: {} }))).toEqual([]); }); }); diff --git a/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts b/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts index 8ec1918b8..ee43f406a 100644 --- a/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts +++ b/frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts @@ -227,6 +227,9 @@ export const shouldShowUnifiedResourceHostTable = ( ): boolean => primaryResourceCount > 0 || serviceResourceCount === 0; export const getUnifiedSources = (resource: Resource): string[] => { + if (Array.isArray(resource.sources) && resource.sources.length > 0) { + return resource.sources; + } const platformData = resource.platformData as { sources?: string[] } | undefined; return platformData?.sources ?? []; }; diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index 847160954..5ce4ba222 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -6,6 +6,7 @@ import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; import { getAgentStatusIndicator } from '@/utils/status'; import { dedupeResourceBadges, + getInfrastructureSystemIdentityBadges, getPlatformBadge, getSourceBadge, getTypeBadge, @@ -100,13 +101,22 @@ export const useResourceDetailDrawerDerivedState = ( const typeBadge = createMemo(() => getTypeBadge(resource.type)); const platformData = createMemo(() => resource.platformData as PlatformData | undefined); const unifiedSourceBadges = createMemo(() => - getUnifiedSourceBadges(platformData()?.sources ?? []), + getUnifiedSourceBadges( + Array.isArray(resource.sources) && resource.sources.length > 0 + ? resource.sources + : (platformData()?.sources ?? []), + ), ); + const systemIdentityBadges = createMemo(() => getInfrastructureSystemIdentityBadges(resource)); const hasUnifiedSources = createMemo(() => unifiedSourceBadges().length > 0); const headerBadges = createMemo(() => dedupeResourceBadges([ typeBadge(), - ...(hasUnifiedSources() ? unifiedSourceBadges() : [platformBadge(), sourceBadge()]), + ...(systemIdentityBadges().length > 0 + ? systemIdentityBadges() + : hasUnifiedSources() + ? unifiedSourceBadges() + : [platformBadge(), sourceBadge()]), ]), ); const policyBadges = createMemo(() => getResourcePolicyBadges(resource.policy)); diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index a7b95f72d..fa531cfe6 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -582,6 +582,7 @@ export interface Resource { platformId: string; platformType: PlatformType; sourceType: SourceType; + sources?: string[]; // Hierarchy parentId?: string; // Parent resource (e.g., VM -> Node) diff --git a/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts index ecb457ba2..7c1819ad5 100644 --- a/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts @@ -116,6 +116,49 @@ describe('resourceBadgePresentation', () => { ).toEqual(['Unraid']); }); + it('shows storage-owned platform identity without using presentation profiles as PlatformType', () => { + const unraidStorage = makeResource({ + type: 'storage', + platformType: 'agent', + sourceType: 'agent', + sources: ['agent'], + platformData: { + sources: ['agent'], + storage: { + platform: 'unraid', + type: 'unraid-array', + topology: 'array', + }, + }, + storage: { + platform: 'unraid', + type: 'unraid-array', + topology: 'array', + } as Resource['storage'], + }); + + expect(getInfrastructureSystemIdentityBadges(unraidStorage).map((badge) => badge.label)).toEqual([ + 'Unraid', + ]); + expect(getInfrastructureSystemIdentitySortLabel(unraidStorage)).toBe('Unraid'); + + const pbsDatastore = makeResource({ + type: 'storage', + platformType: 'proxmox-pbs', + sourceType: 'api', + sources: ['pbs'], + storage: { + platform: 'pbs', + type: 'pbs-datastore', + topology: 'datastore', + } as Resource['storage'], + }); + + expect(getInfrastructureSystemIdentityBadges(pbsDatastore).map((badge) => badge.label)).toEqual([ + 'PBS', + ]); + }); + it('does not let stale platform sources override explicit agent host profiles', () => { expect( getInfrastructureSystemIdentityBadges( @@ -160,6 +203,27 @@ describe('resourceBadgePresentation', () => { ).toEqual(['Unraid']); }); + it('uses authoritative top-level sources before stale platformData source hints', () => { + expect( + getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'agent', + platformType: 'agent', + sourceType: 'hybrid', + sources: ['agent', 'docker'], + platformData: { + sources: ['kubernetes'], + agent: { + platform: 'linux', + osName: 'Unraid OS 7.2.2', + osVersion: '7.2.2', + }, + }, + }), + ).map((badge) => badge.label), + ).toEqual(['Unraid']); + }); + it('matches governed platform display tokens inside reported host identity text', () => { expect( getInfrastructureSystemIdentityBadges( diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index 3d1d5c507..79f5eaf60 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -348,6 +348,48 @@ describe('resourceStateAdapters nodeFromResource', () => { }); }); + it('uses authoritative top-level sources for realtime storage platform canonicalization', () => { + const [resource] = mergeCanonicalResourceSnapshot( + [ + { + id: 'storage:tower-array', + type: 'storage', + name: 'Tower Array', + displayName: 'Tower Array', + platformId: 'tower-array', + platformType: 'proxmox-pve', + sourceType: 'agent', + sources: ['agent'], + status: 'degraded', + lastSeen: Date.now(), + storage: { + platform: 'unraid', + type: 'unraid-array', + topology: 'array', + postureSummary: 'Unraid array is running without parity protection', + }, + platformData: { + platform: 'unraid', + type: 'unraid-array', + topology: 'array', + postureSummary: 'Unraid array is running without parity protection', + }, + } as Resource, + ], + [], + ); + + expect(resource.platformType).toBe('agent'); + expect(resource.sourceType).toBe('agent'); + expect((resource.platformData as Record).sources).toEqual(['agent']); + expect((resource.platformData as Record).storage).toMatchObject({ + platform: 'unraid', + type: 'unraid-array', + topology: 'array', + }); + expect((resource.platformData as Record).proxmox).toBeUndefined(); + }); + it('replaces stale platform source facets with the current snapshot sources', () => { const existing = { id: 'agent-tower', diff --git a/frontend-modern/src/utils/resourceBadgePresentation.ts b/frontend-modern/src/utils/resourceBadgePresentation.ts index df5d4fa1d..6b6b24586 100644 --- a/frontend-modern/src/utils/resourceBadgePresentation.ts +++ b/frontend-modern/src/utils/resourceBadgePresentation.ts @@ -365,14 +365,51 @@ const getAvailabilitySystemIdentityBadge = ( }; }; +const getStoragePlatformSource = ( + resource: Resource, + platformData: Record | undefined, +): KnownSourcePlatform | null => { + const storage = getFacetRecord(resource, platformData, 'storage'); + const platform = + trimString(storage?.platform) || + (resource.type === 'storage' || resource.type === 'pool' || resource.type === 'datastore' + ? trimString(platformData?.platform) + : ''); + return normalizeSourcePlatformKey(platform); +}; + +const getStorageSystemIdentityBadge = ( + resource: Resource, + platformData: Record | undefined, +): ResourceBadge | null => { + const storagePlatform = getStoragePlatformSource(resource, platformData); + if (!storagePlatform) return null; + + const badge = getSourcePlatformBadge(storagePlatform); + if (!badge) return null; + + const storage = getFacetRecord(resource, platformData, 'storage'); + const topology = trimString(storage?.topology) || trimString(platformData?.topology); + const storageType = trimString(storage?.type) || trimString(platformData?.type); + return { + label: badge.label, + classes: badge.classes, + title: titleFromParts(badge.title, topology || storageType), + }; +}; + export function getInfrastructureSystemIdentityBadges(resource: Resource): ResourceBadge[] { const platformData = getPlatformDataRecord(resource) as | (Record & { sources?: string[] }) | undefined; - const rawSources = [ - ...(Array.isArray(platformData?.sources) ? platformData.sources : []), - ...deriveSourceKeysFromFacets(resource, platformData), - ]; + const explicitSources = Array.isArray(resource.sources) ? resource.sources : []; + const rawSources = + explicitSources.length > 0 + ? explicitSources + : [ + ...(Array.isArray(platformData?.sources) ? platformData.sources : []), + ...deriveSourceKeysFromFacets(resource, platformData), + ]; const sources = normalizeUnifiedSourceKeys(rawSources); const availabilityIdentityBadge = getAvailabilitySystemIdentityBadge( resource, @@ -383,6 +420,11 @@ export function getInfrastructureSystemIdentityBadges(resource: Resource): Resou return [availabilityIdentityBadge]; } + const storageIdentityBadge = getStorageSystemIdentityBadge(resource, platformData); + if (storageIdentityBadge) { + return [storageIdentityBadge]; + } + const agentIdentityBadge = getAgentSystemIdentityBadge(resource); if ( agentIdentityBadge && diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index d7da58780..a4cf67e26 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -164,6 +164,9 @@ const mergePlatformData = ( const getResourceRecord = (resource: Resource): JsonRecord => resource as unknown as JsonRecord; +const getExplicitResourceSources = (resource: Resource): string[] | undefined => + readStringArray(getResourceRecord(resource).sources); + const getFacetRecord = ( resource: Resource, platformData: Resource['platformData'] | undefined, @@ -178,6 +181,11 @@ const deriveLegacySourceList = ( resource: Resource, platformData: Resource['platformData'] | undefined = resource.platformData, ): string[] | undefined => { + const resourceSources = getExplicitResourceSources(resource); + if (resourceSources && resourceSources.length > 0) { + return resourceSources; + } + const sources: string[] = []; if (getFacetRecord(resource, platformData, 'proxmox')) sources.push('proxmox'); if (getFacetRecord(resource, platformData, 'pbs')) sources.push('pbs'); @@ -286,10 +294,12 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD } } + const explicitResourceSources = getExplicitResourceSources(resource); const normalizedSources = - Array.isArray(platformData.sources) && platformData.sources.length > 0 + explicitResourceSources ?? + (Array.isArray(platformData.sources) && platformData.sources.length > 0 ? (platformData.sources as string[]) - : deriveLegacySourceList(resource, platformData); + : deriveLegacySourceList(resource, platformData)); if (normalizedSources && normalizedSources.length > 0) { normalized.sources = normalizedSources; } @@ -445,6 +455,8 @@ const getCanonicalSourceList = ( resource: Resource, platformData?: Resource['platformData'], ): string[] | undefined => { + const resourceSources = getExplicitResourceSources(resource); + if (resourceSources && resourceSources.length > 0) return resourceSources; const platformRecord = asRecord(platformData); return Array.isArray(platformRecord?.sources) && platformRecord.sources.length > 0 ? (platformRecord.sources as string[]) diff --git a/internal/models/converters.go b/internal/models/converters.go index c9f25d081..13f540b6c 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -910,6 +910,7 @@ type ResourceConvertInput struct { PlatformID string PlatformType string SourceType string + Sources []string ParentID string ClusterID string Status string @@ -996,6 +997,7 @@ func ConvertResourceToFrontend(input ResourceConvertInput) ResourceFrontend { PlatformID: input.PlatformID, PlatformType: input.PlatformType, SourceType: input.SourceType, + Sources: append([]string(nil), input.Sources...), ParentID: input.ParentID, ClusterID: input.ClusterID, Status: input.Status, diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index 64bdecce5..12c9ae705 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -878,9 +878,10 @@ type ResourceFrontend struct { DisplayName string `json:"displayName"` // Platform/Source - PlatformID string `json:"platformId"` - PlatformType string `json:"platformType"` - SourceType string `json:"sourceType"` + PlatformID string `json:"platformId"` + PlatformType string `json:"platformType"` + SourceType string `json:"sourceType"` + Sources []string `json:"sources,omitempty"` // Hierarchy ParentID string `json:"parentId,omitempty"` diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 8800955d4..14cef3231 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -763,6 +763,44 @@ func TestMockOwnedUnifiedMetricSyncDefersToCanonicalSamplerInMockMode(t *testing } } +func TestMonitoringBroadcastCarriesCanonicalSourceAndStoragePlatformIdentity(t *testing.T) { + input := monitorResourceToConvertInput(unifiedresources.Resource{ + ID: "storage-tower-array", + Type: unifiedresources.ResourceTypeStorage, + Name: "Tower Array", + Status: unifiedresources.StatusWarning, + Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent}, + Storage: &unifiedresources.StorageMeta{ + Type: "unraid-array", + Platform: "unraid", + Topology: "array", + Enabled: true, + Active: true, + }, + }) + + if input.PlatformType != "agent" { + t.Fatalf("PlatformType = %q, want agent for Unraid agent-backed storage", input.PlatformType) + } + if input.SourceType != "agent" { + t.Fatalf("SourceType = %q, want agent", input.SourceType) + } + if len(input.Sources) != 1 || input.Sources[0] != string(unifiedresources.SourceAgent) { + t.Fatalf("Sources = %#v, want [agent]", input.Sources) + } + + payload := string(input.PlatformData) + for _, snippet := range []string{ + `"platform":"unraid"`, + `"type":"unraid-array"`, + `"sources":["agent"]`, + } { + if !strings.Contains(payload, snippet) { + t.Fatalf("PlatformData must contain %s, got %s", snippet, payload) + } + } +} + func TestMonitorSetMockModeAuthorizesBeforeResettingRuntimeState(t *testing.T) { data, err := os.ReadFile("monitor.go") if err != nil { diff --git a/internal/monitoring/guest_config_conversion_coverage_test.go b/internal/monitoring/guest_config_conversion_coverage_test.go index 5aae714c6..317319149 100644 --- a/internal/monitoring/guest_config_conversion_coverage_test.go +++ b/internal/monitoring/guest_config_conversion_coverage_test.go @@ -229,6 +229,45 @@ func TestMonitorFrontendAndMetricHelpers(t *testing.T) { {name: "pbs explicit", resourceType: "pbs", want: "proxmox-pbs"}, {name: "pmg explicit", resourceType: "pmg", want: "proxmox-pmg"}, {name: "agent explicit", resourceType: "agent", want: "agent"}, + { + name: "storage proxmox facet", + resourceType: "storage", + resource: unifiedresources.Resource{Proxmox: &unifiedresources.ProxmoxData{}}, + want: "proxmox-pve", + }, + { + name: "storage truenas metadata", + resourceType: "storage", + resource: unifiedresources.Resource{Storage: &unifiedresources.StorageMeta{Platform: "truenas"}}, + want: "truenas", + }, + { + name: "storage pbs metadata", + resourceType: "storage", + resource: unifiedresources.Resource{Storage: &unifiedresources.StorageMeta{Platform: "pbs"}}, + want: "proxmox-pbs", + }, + { + name: "storage vmware metadata", + resourceType: "storage", + resource: unifiedresources.Resource{Storage: &unifiedresources.StorageMeta{Platform: "vmware"}}, + want: "vmware-vsphere", + }, + { + name: "unraid storage remains agent platform", + resourceType: "storage", + resource: unifiedresources.Resource{ + Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent}, + Storage: &unifiedresources.StorageMeta{Platform: "unraid"}, + }, + want: "agent", + }, + { + name: "unknown storage no longer defaults proxmox", + resourceType: "storage", + resource: unifiedresources.Resource{}, + want: "generic", + }, { name: "fallback k8s precedence", resourceType: "custom", @@ -585,6 +624,45 @@ func TestMonitorPlatformData(t *testing.T) { } }) + t.Run("storage payload keeps owned platform metadata", func(t *testing.T) { + resource := unifiedresources.Resource{ + Status: unifiedresources.StatusWarning, + Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent}, + Storage: &unifiedresources.StorageMeta{ + Type: "unraid-array", + Platform: "unraid", + Topology: "array", + Content: "files", + ContentTypes: []string{"files"}, + Enabled: true, + Active: true, + Protection: "none", + PostureSummary: "Unraid array is running without parity protection", + RebuildInProgress: true, + SyncAction: "check", + NumDisabled: 2, + }, + } + + payload := decodePlatformDataPayload(t, monitorPlatformData(resource, "storage", "ignored")) + if payload["platform"] != "unraid" { + t.Fatalf("platform = %#v, want unraid", payload["platform"]) + } + if payload["type"] != "unraid-array" || payload["topology"] != "array" { + t.Fatalf("expected unraid storage identity, got %#v", payload) + } + if payload["postureSummary"] != "Unraid array is running without parity protection" { + t.Fatalf("postureSummary = %#v", payload["postureSummary"]) + } + if payload["syncAction"] != "check" || payload["numDisabled"] != float64(2) { + t.Fatalf("expected unraid health detail, got %#v", payload) + } + sources, ok := payload["sources"].([]interface{}) + if !ok || len(sources) != 1 || sources[0] != "agent" { + t.Fatalf("sources = %#v, want [agent]", payload["sources"]) + } + }) + t.Run("nil payload branches", func(t *testing.T) { if got := monitorPlatformData(unifiedresources.Resource{}, "agent", "id"); got != nil { t.Fatalf("expected nil payload for agent without resource agent data, got %s", string(got)) diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index dcb0e8399..91b13e19a 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -4910,6 +4910,7 @@ func monitorResourceToConvertInput(resource unifiedresources.Resource) models.Re PlatformID: platformID, PlatformType: monitorPlatformType(resource, resourceType), SourceType: monitorSourceType(resource.Sources), + Sources: monitorSourceKeys(resource.Sources), ParentID: monitorStringValue(resource.ParentID), ClusterID: monitorClusterID(resource), Status: monitorFrontendStatus(resource, resourceType), @@ -5081,9 +5082,29 @@ func monitorPlatformType(resource unifiedresources.Resource, resourceType string if resource.Availability != nil { return "generic" } + if storagePlatform := monitorStoragePlatformType(resource.Storage, resource.Sources); storagePlatform != "" { + return storagePlatform + } switch resourceType { - case "vm", "system-container", "storage", "pool": + case "vm", "system-container": return "proxmox-pve" + case "storage", "pool": + if monitorHasSource(resource.Sources, unifiedresources.SourceProxmox) { + return "proxmox-pve" + } + if monitorHasSource(resource.Sources, unifiedresources.SourcePBS) { + return "proxmox-pbs" + } + if monitorHasSource(resource.Sources, unifiedresources.SourceTrueNAS) { + return "truenas" + } + if monitorHasSource(resource.Sources, unifiedresources.SourceVMware) { + return "vmware-vsphere" + } + if monitorHasSource(resource.Sources, unifiedresources.SourceAgent) { + return "agent" + } + return "generic" case "docker-host", "app-container": return "docker" case "k8s-cluster", "k8s-node", "pod", "k8s-deployment": @@ -5122,6 +5143,33 @@ func monitorPlatformType(resource unifiedresources.Resource, resourceType string } } +func monitorStoragePlatformType(storage *unifiedresources.StorageMeta, sources []unifiedresources.DataSource) string { + if storage == nil { + return "" + } + switch strings.ToLower(strings.TrimSpace(storage.Platform)) { + case "proxmox", "proxmox-pve", "pve": + return "proxmox-pve" + case "pbs", "proxmox-pbs": + return "proxmox-pbs" + case "truenas": + return "truenas" + case "vmware", "vmware-vsphere", "vsphere": + return "vmware-vsphere" + case "docker", "podman": + return "docker" + case "kubernetes", "k8s": + return "kubernetes" + case "unraid": + if monitorHasSource(sources, unifiedresources.SourceAgent) { + return "agent" + } + return "generic" + default: + return "" + } +} + func monitorPlatformID(resource unifiedresources.Resource, resourceType string) string { switch resourceType { case "node", "vm", "system-container": @@ -5556,19 +5604,7 @@ func monitorPlatformData(resource unifiedresources.Resource, resourceType string } } case "storage", "pool": - nodeLabel := resource.ParentName - if nodeLabel == "" { - nodeLabel = monitorStringValue(resource.ParentID) - } - payload = map[string]interface{}{ - "instance": platformID, - "node": nodeLabel, - "type": "", - "content": "", - "shared": false, - "enabled": true, - "active": resource.Status == unifiedresources.StatusOnline, - } + payload = monitorStoragePlatformData(resource, platformID) case "network-endpoint": if resource.Availability != nil { payload = map[string]interface{}{ @@ -5591,6 +5627,10 @@ func monitorPlatformData(resource unifiedresources.Resource, resourceType string } } + if payload == nil { + return nil + } + payload = monitorAttachSourceKeys(payload, resource.Sources) if payload == nil { return nil } @@ -5602,6 +5642,85 @@ func monitorPlatformData(resource unifiedresources.Resource, resourceType string return encoded } +func monitorAttachSourceKeys(payload interface{}, sources []unifiedresources.DataSource) interface{} { + sourceKeys := monitorSourceKeys(sources) + if len(sourceKeys) == 0 { + return payload + } + if payloadMap, ok := payload.(map[string]interface{}); ok { + if payloadMap == nil { + return nil + } + payloadMap["sources"] = sourceKeys + return payloadMap + } + return payload +} + +func monitorStoragePlatformData(resource unifiedresources.Resource, platformID string) map[string]interface{} { + nodeLabel := resource.ParentName + if nodeLabel == "" { + nodeLabel = monitorStringValue(resource.ParentID) + } + payload := map[string]interface{}{ + "instance": platformID, + "node": nodeLabel, + "active": resource.Status == unifiedresources.StatusOnline, + } + + if resource.Proxmox != nil { + if strings.TrimSpace(resource.Proxmox.Instance) != "" { + payload["instance"] = strings.TrimSpace(resource.Proxmox.Instance) + } + if strings.TrimSpace(resource.Proxmox.NodeName) != "" { + payload["node"] = strings.TrimSpace(resource.Proxmox.NodeName) + } + } + + if resource.Storage == nil { + payload["type"] = "" + payload["content"] = "" + payload["shared"] = false + payload["enabled"] = true + return payload + } + + storage := resource.Storage + payload["type"] = storage.Type + payload["content"] = storage.Content + payload["contentTypes"] = append([]string(nil), storage.ContentTypes...) + payload["shared"] = storage.Shared + payload["enabled"] = storage.Enabled + payload["active"] = storage.Active + payload["isCeph"] = storage.IsCeph + payload["isZfs"] = storage.IsZFS + payload["platform"] = storage.Platform + payload["topology"] = storage.Topology + payload["protection"] = storage.Protection + payload["pool"] = storage.Pool + payload["path"] = storage.Path + payload["nodes"] = append([]string(nil), storage.Nodes...) + payload["risk"] = storage.Risk + payload["riskSummary"] = storage.RiskSummary + payload["postureSummary"] = storage.PostureSummary + payload["protectionReduced"] = storage.ProtectionReduced + payload["protectionSummary"] = storage.ProtectionSummary + payload["rebuildInProgress"] = storage.RebuildInProgress + payload["rebuildSummary"] = storage.RebuildSummary + payload["arrayState"] = storage.ArrayState + payload["syncAction"] = storage.SyncAction + payload["syncProgress"] = storage.SyncProgress + payload["numProtected"] = storage.NumProtected + payload["numDisabled"] = storage.NumDisabled + payload["numInvalid"] = storage.NumInvalid + payload["numMissing"] = storage.NumMissing + payload["zfsPoolState"] = storage.ZFSPoolState + payload["zfsReadErrors"] = storage.ZFSReadErrors + payload["zfsWriteErrors"] = storage.ZFSWriteErrors + payload["zfsChecksumErrors"] = storage.ZFSChecksumErrors + return payload +} + func convertProxmoxDisks(disks []unifiedresources.DiskInfo) []map[string]interface{} { if len(disks) == 0 { return nil @@ -5700,6 +5819,26 @@ func monitorHasSource(sources []unifiedresources.DataSource, source unifiedresou return false } +func monitorSourceKeys(sources []unifiedresources.DataSource) []string { + if len(sources) == 0 { + return nil + } + keys := make([]string, 0, len(sources)) + seen := make(map[string]struct{}, len(sources)) + for _, source := range sources { + key := strings.TrimSpace(string(source)) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + keys = append(keys, key) + } + return keys +} + func monitorSourceType(sources []unifiedresources.DataSource) string { if len(sources) > 1 { return "hybrid" diff --git a/internal/monitoring/monitor_unified_state_test.go b/internal/monitoring/monitor_unified_state_test.go index be014e984..0ef21bc3d 100644 --- a/internal/monitoring/monitor_unified_state_test.go +++ b/internal/monitoring/monitor_unified_state_test.go @@ -403,9 +403,12 @@ func TestConvertResourcesForBroadcastIncludesCanonicalHealthContext(t *testing.T LastSeen: time.Date(2026, 5, 8, 11, 30, 0, 0, time.UTC), Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent}, Storage: &unifiedresources.StorageMeta{ + Type: "unraid-array", Platform: "unraid", + Topology: "array", RiskSummary: "Unraid array is running without parity protection", PostureSummary: "Unraid array is running without parity protection", + SyncAction: "check", Risk: &unifiedresources.StorageRisk{ Level: storagehealth.RiskWarning, Reasons: []unifiedresources.StorageRiskReason{{ @@ -479,6 +482,16 @@ func TestConvertResourcesForBroadcastIncludesCanonicalHealthContext(t *testing.T } var storagePayload struct { + PlatformType string `json:"platformType"` + SourceType string `json:"sourceType"` + Sources []string `json:"sources"` + PlatformData struct { + Platform string `json:"platform"` + Type string `json:"type"` + Topology string `json:"topology"` + PostureSummary string `json:"postureSummary"` + SyncAction string `json:"syncAction"` + } `json:"platformData"` Storage struct { PostureSummary string `json:"postureSummary"` Risk struct { @@ -495,6 +508,18 @@ func TestConvertResourcesForBroadcastIncludesCanonicalHealthContext(t *testing.T if err := json.Unmarshal(encoded, &storagePayload); err != nil { t.Fatalf("unmarshal storage frontend resource: %v", err) } + if storagePayload.PlatformType != "agent" || storagePayload.SourceType != "agent" { + t.Fatalf("expected unraid storage to remain agent-backed, got platform=%q source=%q", storagePayload.PlatformType, storagePayload.SourceType) + } + if len(storagePayload.Sources) != 1 || storagePayload.Sources[0] != "agent" { + t.Fatalf("storage sources = %#v, want [agent]", storagePayload.Sources) + } + if storagePayload.PlatformData.Platform != "unraid" || storagePayload.PlatformData.Type != "unraid-array" || storagePayload.PlatformData.Topology != "array" { + t.Fatalf("expected unraid storage platform data, got %+v", storagePayload.PlatformData) + } + if storagePayload.PlatformData.PostureSummary != "Unraid array is running without parity protection" { + t.Fatalf("platformData posture summary = %q", storagePayload.PlatformData.PostureSummary) + } if storagePayload.Storage.PostureSummary != "Unraid array is running without parity protection" { t.Fatalf("storage posture summary = %q", storagePayload.Storage.PostureSummary) } diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 3446e0756..ed6ece361 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -1273,6 +1273,17 @@ func (rr *ResourceRegistry) mergeInto(existing *Resource, incoming Resource, sou existing.Kubernetes = incoming.Kubernetes case SourcePMG: existing.PMG = incoming.PMG + case SourceTrueNAS: + existing.TrueNAS = mergeTrueNASData(existing.TrueNAS, incoming.TrueNAS) + if incoming.Agent != nil && !hasDataSource(existing.Sources, SourceAgent) { + existing.Agent = incoming.Agent + } + if incoming.Docker != nil && !hasDataSource(existing.Sources, SourceDocker) { + existing.Docker = incoming.Docker + } + if incoming.Storage != nil && existing.Storage == nil { + existing.Storage = incoming.Storage + } case SourceVMware: existing.VMware = mergeVMwareData(existing.VMware, incoming.VMware) case SourceAvailability: @@ -1386,6 +1397,48 @@ func mergeProxmoxData(existing *ProxmoxData, incoming *ProxmoxData) *ProxmoxData return &merged } +func mergeTrueNASData(existing *TrueNASData, incoming *TrueNASData) *TrueNASData { + if existing == nil { + return incoming + } + if incoming == nil { + return existing + } + + merged := *existing + if incoming.Hostname != "" { + merged.Hostname = incoming.Hostname + } + if incoming.Version != "" { + merged.Version = incoming.Version + } + if incoming.UptimeSeconds != 0 { + merged.UptimeSeconds = incoming.UptimeSeconds + } + if incoming.StorageRisk != nil { + merged.StorageRisk = cloneStorageRisk(incoming.StorageRisk) + } + if incoming.StorageRiskSummary != "" { + merged.StorageRiskSummary = incoming.StorageRiskSummary + } + if incoming.StoragePostureSummary != "" { + merged.StoragePostureSummary = incoming.StoragePostureSummary + } + if incoming.ProtectionReduced { + merged.ProtectionReduced = true + } + if incoming.ProtectionSummary != "" { + merged.ProtectionSummary = incoming.ProtectionSummary + } + if incoming.RebuildInProgress { + merged.RebuildInProgress = true + } + if incoming.RebuildSummary != "" { + merged.RebuildSummary = incoming.RebuildSummary + } + return &merged +} + func mergePhysicalDiskData(existing *PhysicalDiskMeta, incoming *PhysicalDiskMeta) *PhysicalDiskMeta { if existing == nil { return incoming diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index d1feeb8a2..eb472c154 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -2057,6 +2057,82 @@ func TestResourceRegistry_MergePhysicalDiskKeepsIncidentBackedRisk(t *testing.T) } } +func TestResourceRegistry_MergeTrueNASPayloadOnIdentityMatch(t *testing.T) { + rr := NewRegistry(nil) + now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC) + + rr.IngestRecords(SourceAgent, []IngestRecord{ + { + SourceID: "agent-archive", + Resource: Resource{ + Type: ResourceTypeAgent, + Name: "archive", + Status: StatusOnline, + LastSeen: now, + Agent: &AgentData{ + Hostname: "archive.local", + OSName: "Debian GNU/Linux", + }, + }, + Identity: ResourceIdentity{ + MachineID: "archive-machine", + Hostnames: []string{"archive.local"}, + }, + }, + }) + + rr.IngestRecords(SourceTrueNAS, []IngestRecord{ + { + SourceID: "truenas-archive", + Resource: Resource{ + Type: ResourceTypeAgent, + Name: "archive.local", + Status: StatusWarning, + LastSeen: now.Add(time.Minute), + TrueNAS: &TrueNASData{ + Hostname: "archive.local", + Version: "24.10", + UptimeSeconds: 86400, + StorageRisk: &StorageRisk{Level: storagehealth.RiskWarning}, + StorageRiskSummary: "Pool tank is DEGRADED", + StoragePostureSummary: "Pool tank is DEGRADED", + ProtectionReduced: true, + ProtectionSummary: "Pool redundancy is reduced", + }, + Agent: &AgentData{ + Hostname: "archive.local", + OSName: "TrueNAS SCALE", + }, + }, + Identity: ResourceIdentity{ + MachineID: "archive-machine", + Hostnames: []string{"archive.local"}, + }, + }, + }) + + resources := rr.ListByType(ResourceTypeAgent) + if len(resources) != 1 { + t.Fatalf("ListByType(ResourceTypeAgent) returned %d resources, want 1", len(resources)) + } + resource := resources[0] + if !hasDataSource(resource.Sources, SourceAgent) || !hasDataSource(resource.Sources, SourceTrueNAS) { + t.Fatalf("sources = %#v, want agent and truenas", resource.Sources) + } + if resource.TrueNAS == nil { + t.Fatal("expected TrueNAS payload to merge onto matched agent resource") + } + if resource.TrueNAS.Version != "24.10" || resource.TrueNAS.UptimeSeconds != 86400 { + t.Fatalf("TrueNAS payload not refreshed: %+v", resource.TrueNAS) + } + if resource.TrueNAS.StorageRisk == nil || resource.TrueNAS.StorageRisk.Level != storagehealth.RiskWarning { + t.Fatalf("TrueNAS storage risk not merged: %+v", resource.TrueNAS.StorageRisk) + } + if resource.Agent == nil || resource.Agent.OSName != "Debian GNU/Linux" { + t.Fatalf("SourceAgent facet should remain authoritative, got %+v", resource.Agent) + } +} + func hasStorageConsumer(consumers []StorageConsumerMeta, name string, resourceType ResourceType, diskCount int) bool { for _, consumer := range consumers { if consumer.Name == name && consumer.ResourceType == resourceType && consumer.DiskCount == diskCount {