Fix platform identity source contracts

This commit is contained in:
rcourtman 2026-05-08 14:54:08 +01:00
parent 6d080a4810
commit cb772737e7
20 changed files with 667 additions and 31 deletions

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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(() => <ResourceDetailDrawer resource={resource} />);
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(
() => <ResourceDetailDrawer resource={baseResource({})} />,

View file

@ -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([]);
});
});

View file

@ -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 ?? [];
};

View file

@ -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));

View file

@ -582,6 +582,7 @@ export interface Resource {
platformId: string;
platformType: PlatformType;
sourceType: SourceType;
sources?: string[];
// Hierarchy
parentId?: string; // Parent resource (e.g., VM -> Node)

View file

@ -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(

View file

@ -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<string, unknown>).sources).toEqual(['agent']);
expect((resource.platformData as Record<string, unknown>).storage).toMatchObject({
platform: 'unraid',
type: 'unraid-array',
topology: 'array',
});
expect((resource.platformData as Record<string, unknown>).proxmox).toBeUndefined();
});
it('replaces stale platform source facets with the current snapshot sources', () => {
const existing = {
id: 'agent-tower',

View file

@ -365,14 +365,51 @@ const getAvailabilitySystemIdentityBadge = (
};
};
const getStoragePlatformSource = (
resource: Resource,
platformData: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> & { 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 &&

View file

@ -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[])

View file

@ -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,

View file

@ -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"`

View file

@ -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 {

View file

@ -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))

View file

@ -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"

View file

@ -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)
}

View file

@ -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

View file

@ -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 {