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 {