mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Fix platform identity source contracts
This commit is contained in:
parent
6d080a4810
commit
cb772737e7
20 changed files with 667 additions and 31 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({})} />,
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -582,6 +582,7 @@ export interface Resource {
|
|||
platformId: string;
|
||||
platformType: PlatformType;
|
||||
sourceType: SourceType;
|
||||
sources?: string[];
|
||||
|
||||
// Hierarchy
|
||||
parentId?: string; // Parent resource (e.g., VM -> Node)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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[])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue