Align mock state resources with canonical contract

This commit is contained in:
rcourtman 2026-04-11 13:40:01 +01:00
parent 3a992a4fae
commit b64782c083
12 changed files with 385 additions and 92 deletions

View file

@ -154,6 +154,10 @@ an add-only capacity posture.
order instead of inheriting map order or page-local re-sorts, so install
and runtime hydration do not present one resource ordering at first load and
a different ordering after the first live refresh.
When lifecycle surfaces also hydrate from `/api/state`, that first-session
snapshot must carry the same canonical resource types and display names as
`/api/resources` instead of briefly showing legacy host aliases before the
first websocket-backed refresh lands.
Persisted legacy hosted quickstart model IDs are therefore not lifecycle
truth either: when shared settings helpers load or save historical
quickstart values, they must normalize them back to

View file

@ -155,6 +155,7 @@ when the disabled candidate no longer counts toward monitored-system capacity.
5. Route unified-resource action, lifecycle, and export audit reads through `internal/api/activity_audit_handlers.go`, `internal/api/router_routes_licensing.go`, and `internal/api/contract_test.go` together so the control-plane execution trail stays on a governed API contract instead of a store-only shape
6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection
7. Route unified-resource list ordering through `internal/api/resources.go`, `internal/api/contract_test.go`, and the owned unified-resource registry helpers together; list payloads must stay deterministic for equal-name resources by carrying one canonical `name -> type -> id` tie-break across cold seed, REST pagination, and websocket-backed refreshes instead of inheriting map order or page-local re-sorts
That same shared API contract also owns the external resource `type`, canonical display name, and cluster identity published through `/api/resources` and `/api/state`; the websocket/state hydrate path must not emit legacy aliases or raw store labels once the unified resource contract has normalized them.
8. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts`, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, the Patrol-owned section files under `frontend-modern/src/features/patrol/`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the summary card, store normalization owner, runtime hook, feature shell, section owners, route shell, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice
while keeping the learning counters backend-only coverage, so the summary page keeps Patrol health and findings primary and renders timeline, correlation, and policy-posture data as secondary investigation context rather than as a separate headline product metric
and the Patrol findings empty-state behavior, so `0 active findings` only renders as a healthy frontend conclusion when the same governed AI summary contract still reports healthy overall health; degraded or not-fully-verified health predictions must flow through to the Patrol findings surface instead of being replaced by page-local "looks healthy" copy

View file

@ -158,6 +158,13 @@ or restart discovery until the canonical mock runtime has accepted the
requested mode change; rejected release-demo fixture enables must fail before
any monitoring reset so the live preview does not blank itself on an
unauthorized toggle.
That same monitoring owner also owns canonical unified-resource publication on
`/api/state` and the websocket `state.resources` hydrate path. Monitoring must
publish those resources from the same canonical unified snapshot that
`/api/resources` seeds in mock and live mode, rather than projecting a second
raw store-only inventory for broadcast. Otherwise cold hydrate and later
registry-backed refreshes can swap the operator-visible infrastructure set
under one running session.
That same mock-runtime boundary also owns freshness while demos are running.
The mock update loop must keep provider-backed TrueNAS and VMware records plus
legacy PBS and PMG summaries on current `LastSeen` and health state each tick,

View file

@ -83,6 +83,10 @@ querying, and the operator-facing storage health presentation layer.
preserve the canonical unified-resource `name -> type -> id` order so
duplicate-name storage and recovery resources do not reshuffle between cold
hydrate, paginated reads, and later live runtime updates.
If those same surfaces cold-hydrate from websocket `state.resources`, the
state payload must publish the same canonical resource types and display
labels as `/api/resources` so storage and recovery do not momentarily switch
between legacy and canonical infrastructure identities within one session.
That same adjacent API boundary now also owns SSO outbound discovery and metadata fetch trust: storage- and recovery-adjacent surfaces may share `internal/api/sso_outbound.go`, `internal/api/saml_service.go`, and `internal/api/oidc_service.go`, but they must not fork separate metadata/discovery HTTP clients, redirect policies, or credential-file read rules when they depend on shared backend auth helpers.
5. Route canonical storage/recovery resource selection through `frontend-modern/src/hooks/useUnifiedResources.ts` and the owning `unified-resources` contract
That shared hook now also projects resource `clusterId` through the shared cluster-name helper, so storage and recovery links keep the same cluster-context label as other unified-resource consumers instead of rebuilding a local fallback chain.

View file

@ -148,6 +148,11 @@ assembly branch.
`name -> type -> id` order across registry reads, REST pagination, and
websocket-backed refreshes so equal-name resources do not silently reshuffle
between cold hydrate and later runtime updates
That same unified-resource owner also defines the canonical transport
projection for operator-facing resources: `/api/resources` and websocket
`state.resources` must share `ContractResourceType`, canonical display
names, and canonical cluster labels instead of publishing separate REST and
broadcast aliases for the same machine.
`internal/unifiedresources/top_level_systems.go`
Explicit linked-host correlation is canonical here: when Kubernetes node
ingest has a resolved backing host agent, the registry must merge that node

View file

@ -44,6 +44,12 @@ import (
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
)
type resourceContractSnapshot struct {
ID string
Name string
Type string
}
type contractCapturingStreamingProvider struct {
lastRequest providers.ChatRequest
}
@ -8974,6 +8980,120 @@ func TestContract_ResourceListUsesDeterministicNameTieBreakers(t *testing.T) {
}
}
func TestContract_StateAndResourceListShareCanonicalMockResourceContract(t *testing.T) {
t.Setenv("PULSE_MOCK_MODE", "true")
mock.SetEnabled(true)
t.Cleanup(func() { mock.SetEnabled(false) })
dataPath := t.TempDir()
hashedPassword, err := authpkg.HashPassword("password")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := &config.Config{
DataPath: dataPath,
MultiTenantEnabled: true,
AuthUser: "admin",
AuthPass: hashedPassword,
}
InitSessionStore(dataPath)
InitCSRFStore(dataPath)
monitor, err := monitoring.New(cfg)
if err != nil {
t.Fatalf("new monitor: %v", err)
}
t.Cleanup(func() { monitor.Stop() })
router := &Router{
config: cfg,
monitor: monitor,
persistence: config.NewConfigPersistence(dataPath),
}
handlers := NewResourceHandlers(cfg)
handlers.SetStateProvider(monitor)
stateReq := httptest.NewRequest(http.MethodGet, "/api/state", nil)
stateReq.SetBasicAuth("admin", "password")
stateRec := httptest.NewRecorder()
router.handleState(stateRec, stateReq)
if stateRec.Code != http.StatusOK {
t.Fatalf("/api/state status = %d, body=%s", stateRec.Code, stateRec.Body.String())
}
var state models.StateFrontend
if err := json.NewDecoder(stateRec.Body).Decode(&state); err != nil {
t.Fatalf("decode /api/state: %v", err)
}
listReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host,pbs,pmg,k8s-cluster,k8s-node&page=1&limit=100", nil)
listRec := httptest.NewRecorder()
handlers.HandleListResources(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("/api/resources status = %d, body=%s", listRec.Code, listRec.Body.String())
}
var listResp ResourcesResponse
if err := json.NewDecoder(listRec.Body).Decode(&listResp); err != nil {
t.Fatalf("decode /api/resources: %v", err)
}
allowedTypes := map[string]bool{
"agent": true,
"docker-host": true,
"k8s-cluster": true,
"k8s-node": true,
"pbs": true,
"pmg": true,
}
stateContracts := make(map[string]resourceContractSnapshot)
for _, resource := range state.Resources {
if !allowedTypes[resource.Type] {
continue
}
if resource.Type == "node" {
t.Fatalf("/api/state published legacy resource type %#v", resource)
}
if resource.Name == "pve1" || resource.Name == "edge-apps-01" {
t.Fatalf("/api/state published legacy display name %#v", resource)
}
stateContracts[resource.ID] = resourceContractSnapshot{
ID: resource.ID,
Name: resource.Name,
Type: resource.Type,
}
}
listContracts := make(map[string]resourceContractSnapshot)
for _, resource := range listResp.Data {
if !allowedTypes[string(resource.Type)] {
continue
}
listContracts[resource.ID] = resourceContractSnapshot{
ID: resource.ID,
Name: resource.Name,
Type: string(resource.Type),
}
}
if !reflect.DeepEqual(stateContracts, listContracts) {
t.Fatalf("/api/state and /api/resources resource contracts diverged: state=%#v resources=%#v", stateContracts, listContracts)
}
foundCanonicalProxmoxName := false
for _, resource := range stateContracts {
if resource.Name == "West Production A" && resource.Type == "agent" {
foundCanonicalProxmoxName = true
break
}
}
if !foundCanonicalProxmoxName {
t.Fatalf("expected canonical proxmox resource contract in /api/state, got %#v", stateContracts)
}
}
func TestContract_ResourceListCarriesTimelineAndCapabilityContracts(t *testing.T) {
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
occurredAt := now.Add(-2 * time.Minute)

View file

@ -2258,24 +2258,7 @@ func attachMetricsTarget(resource *unified.Resource, registry *unified.ResourceR
// resourceContractType maps internal unified resource shapes onto the canonical
// REST resource-type contract without exposing legacy aliases.
func resourceContractType(r unified.Resource) unified.ResourceType {
canonicalType := unified.CanonicalResourceType(r.Type)
switch canonicalType {
case unified.ResourceTypeAgent:
if r.Proxmox != nil || r.Agent != nil {
return "agent"
}
if r.Docker != nil {
return "docker-host"
}
return "agent"
case unified.ResourceTypeSystemContainer:
return "system-container"
case unified.ResourceTypeAppContainer:
return "app-container"
default:
// Other resource types already match their canonical API names.
return canonicalType
}
return unified.ContractResourceType(r)
}
// applyResourceContractTypes rewrites Type fields on the response slice so the

View file

@ -3712,11 +3712,11 @@ func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateS
frontendState.ActiveAlerts = liveAlerts
}
}
unifiedResources := m.getUnifiedResourcesForBroadcast()
frontendState.Resources = convertResourcesForBroadcast(unifiedResources)
frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(unifiedResources, snapshot)
if freshness := m.currentUnifiedResourceFreshness(); !freshness.IsZero() {
frontendState.LastUpdate = freshness.UnixMilli()
unifiedView := m.currentUnifiedStateView()
frontendState.Resources = convertResourcesForBroadcast(unifiedView.resources)
frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(unifiedView.resources, snapshot)
if !unifiedView.freshness.IsZero() {
frontendState.LastUpdate = unifiedView.freshness.UnixMilli()
}
return frontendState
}
@ -4854,6 +4854,7 @@ func convertResourcesForBroadcast(allResources []unifiedresources.Resource) []mo
}
func monitorResourceToConvertInput(resource unifiedresources.Resource) models.ResourceConvertInput {
unifiedresources.RefreshCanonicalMetadata(&resource)
resourceType := monitorFrontendResourceType(resource)
name, displayName := monitorFrontendNames(resource, resourceType)
platformID := monitorPlatformID(resource, resourceType)
@ -4890,73 +4891,29 @@ func monitorResourceToConvertInput(resource unifiedresources.Resource) models.Re
}
func monitorFrontendResourceType(resource unifiedresources.Resource) string {
switch resource.Type {
case unifiedresources.ResourceTypeVM:
return "vm"
case unifiedresources.ResourceTypeSystemContainer:
return "system-container"
case unifiedresources.ResourceTypeAppContainer:
return "app-container"
case unifiedresources.ResourceTypeK8sCluster:
return "k8s-cluster"
case unifiedresources.ResourceTypeK8sNode:
return "k8s-node"
case unifiedresources.ResourceTypePod:
return "pod"
case unifiedresources.ResourceTypeK8sDeployment:
return "k8s-deployment"
case unifiedresources.ResourceTypePBS:
return "pbs"
case unifiedresources.ResourceTypePMG:
return "pmg"
case unifiedresources.ResourceTypeStorage:
return "storage"
case unifiedresources.ResourceTypeCeph:
return "pool"
case unifiedresources.ResourceTypeAgent:
if resource.Proxmox != nil {
return "node"
}
if resource.Docker != nil {
return "docker-host"
}
return "agent"
default:
return string(resource.Type)
}
return string(unifiedresources.ContractResourceType(resource))
}
func monitorFrontendNames(resource unifiedresources.Resource, resourceType string) (string, string) {
name := strings.TrimSpace(resource.Name)
displayName := ""
switch resourceType {
case "node":
if resource.Proxmox != nil && resource.Proxmox.NodeName != "" && !strings.EqualFold(resource.Proxmox.NodeName, name) {
displayName = name
name = resource.Proxmox.NodeName
}
case "agent":
if resource.Agent != nil && resource.Agent.Hostname != "" && !strings.EqualFold(resource.Agent.Hostname, name) {
displayName = name
name = resource.Agent.Hostname
}
case "docker-host":
if resource.Docker != nil && resource.Docker.Hostname != "" && !strings.EqualFold(resource.Docker.Hostname, name) {
displayName = name
name = resource.Docker.Hostname
}
}
name := strings.TrimSpace(unifiedresources.ResourceDisplayName(resource))
if name == "" {
name = resource.ID
}
return name, strings.TrimSpace(displayName)
return name, name
}
func monitorPlatformType(resource unifiedresources.Resource, resourceType string) string {
if resource.Proxmox != nil {
return "proxmox-pve"
}
if resource.VMware != nil {
return "vmware-vsphere"
}
if resource.TrueNAS != nil {
return "truenas"
}
switch resourceType {
case "node", "vm", "system-container", "storage", "pool":
case "vm", "system-container", "storage", "pool":
return "proxmox-pve"
case "docker-host", "app-container":
return "docker"
@ -5086,6 +5043,10 @@ func monitorIsWorkloadType(resourceType string) bool {
}
func monitorClusterID(resource unifiedresources.Resource) string {
if clusterID := strings.TrimSpace(unifiedresources.ResourceClusterName(resource)); clusterID != "" {
return clusterID
}
if resource.Docker != nil && resource.Docker.Swarm != nil {
if name := strings.TrimSpace(resource.Docker.Swarm.ClusterName); name != "" {
return name
@ -5095,17 +5056,7 @@ func monitorClusterID(resource unifiedresources.Resource) string {
}
}
clusterID := strings.TrimSpace(resource.Identity.ClusterName)
if clusterID == "" && resource.Proxmox != nil {
clusterID = strings.TrimSpace(resource.Proxmox.ClusterName)
}
if clusterID == "" && resource.Kubernetes != nil {
clusterID = strings.TrimSpace(resource.Kubernetes.ClusterID)
}
if clusterID == "" && resource.Kubernetes != nil {
clusterID = strings.TrimSpace(resource.Kubernetes.ClusterName)
}
return clusterID
return ""
}
func monitorMetricInput(metric *unifiedresources.MetricValue) *models.ResourceMetricInput {
@ -5284,7 +5235,20 @@ func monitorPlatformData(resource unifiedresources.Resource, resourceType string
case "system-container", "oci-container":
payload = buildProxmoxVMPayload(resource)
case "agent":
if resource.Agent != nil {
if resource.Proxmox != nil {
payload = map[string]interface{}{
"instance": resource.Proxmox.Instance,
"host": "",
"guestURL": "",
"pveVersion": resource.Proxmox.PVEVersion,
"kernelVersion": resource.Proxmox.KernelVersion,
"cpuInfo": resource.Proxmox.CPUInfo,
"loadAverage": []float64{},
"isClusterMember": resource.Proxmox.ClusterName != "",
"clusterName": resource.Proxmox.ClusterName,
"connectionHealth": monitorSourceStatus(resource.SourceStatus, unifiedresources.SourceProxmox),
}
} else if resource.Agent != nil {
payload = map[string]interface{}{
"platform": resource.Agent.Platform,
"osName": resource.Agent.OSName,

View file

@ -81,6 +81,15 @@ func hasUnifiedResourceName(resources []unifiedresources.Resource, name string)
return false
}
func hasFrontendResourceName(resources []models.ResourceFrontend, name string) bool {
for _, resource := range resources {
if resource.Name == name {
return true
}
}
return false
}
func TestMonitorGetUnifiedReadStateOrSnapshotUsesStoreResourcesWithoutSnapshotFallback(t *testing.T) {
now := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC)
store := &resourceOnlyStore{
@ -231,6 +240,7 @@ func TestMonitorGetUnifiedReadStateOrSnapshotUsesCanonicalMockUnifiedResources(t
}
m := &Monitor{
state: models.NewState(),
resourceStore: store,
alertManager: alerts.NewManager(),
}
@ -257,6 +267,85 @@ func TestMonitorGetUnifiedReadStateOrSnapshotUsesCanonicalMockUnifiedResources(t
}
}
func TestMonitorBuildBroadcastFrontendStateUsesCanonicalMockUnifiedResources(t *testing.T) {
mock.SetEnabled(true)
t.Cleanup(func() { mock.SetEnabled(false) })
graph := mock.CurrentFixtureGraph()
legacyName := ""
if len(graph.State.VMs) > 0 {
legacyName = graph.State.VMs[0].Name
} else if len(graph.State.Containers) > 0 {
legacyName = graph.State.Containers[0].Name
}
if legacyName == "" {
t.Fatal("expected canonical mock graph to include at least one legacy resource")
}
store := &resourceOnlyStore{
resources: []unifiedresources.Resource{
{
ID: "store-only-resource",
Type: unifiedresources.ResourceTypeAgent,
Name: "store-only-resource",
Status: unifiedresources.StatusOnline,
LastSeen: time.Date(2026, 3, 6, 14, 0, 0, 0, time.UTC),
Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent},
Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"store-only-resource"}},
},
},
}
m := &Monitor{
state: models.NewState(),
resourceStore: store,
alertManager: alerts.NewManager(),
}
defer m.alertManager.Stop()
expectedResources, freshness := m.UnifiedResourceSnapshot()
frontend := m.BuildBroadcastFrontendState()
if hasFrontendResourceName(frontend.Resources, "store-only-resource") {
t.Fatal("expected mock-mode broadcast state to ignore live resource store data")
}
if !hasFrontendResourceName(frontend.Resources, "truenas-main") {
t.Fatalf("expected mock-mode broadcast state to include TrueNAS mock resources, got %#v", frontend.Resources)
}
if hasFrontendResourceName(frontend.Resources, "pve1") {
t.Fatalf("expected mock-mode broadcast state to stop publishing legacy proxmox node labels, got %#v", frontend.Resources)
}
if hasFrontendResourceName(frontend.Resources, "edge-apps-01") {
t.Fatalf("expected mock-mode broadcast state to stop publishing legacy docker host labels, got %#v", frontend.Resources)
}
if !hasFrontendResourceName(frontend.Resources, "esxi-01.lab.local") {
t.Fatalf("expected mock-mode broadcast state to include VMware mock resources, got %#v", frontend.Resources)
}
if !hasFrontendResourceName(frontend.Resources, "West Production A") {
t.Fatalf("expected mock-mode broadcast state to include canonical proxmox host label, got %#v", frontend.Resources)
}
if !hasFrontendResourceName(frontend.Resources, "Edge Apps 01") {
t.Fatalf("expected mock-mode broadcast state to include canonical docker host label, got %#v", frontend.Resources)
}
if !hasFrontendResourceName(frontend.Resources, legacyName) {
t.Fatalf("expected mock-mode broadcast state to include legacy mock resource %q, got %#v", legacyName, frontend.Resources)
}
if len(frontend.Resources) != len(expectedResources) {
t.Fatalf("expected mock-mode broadcast state to mirror canonical unified resource count %d, got %d", len(expectedResources), len(frontend.Resources))
}
if freshness.IsZero() {
t.Fatal("expected canonical mock unified snapshot freshness")
}
if frontend.LastUpdate != freshness.UnixMilli() {
t.Fatalf("expected broadcast lastUpdate %d from canonical mock freshness, got %d", freshness.UnixMilli(), frontend.LastUpdate)
}
for _, resource := range frontend.Resources {
if resource.Type == "node" {
t.Fatalf("expected canonical broadcast resource types only, got legacy node resource %#v", resource)
}
}
}
func TestMonitorMonitoredSystemUsageWaitsForSupplementalReadinessAndStoreFreshness(t *testing.T) {
monitor := &Monitor{}
monitor.SetResourceStore(unifiedresources.NewMonitorAdapter(nil))

View file

@ -16,6 +16,62 @@ func TestCanonicalResourceTypeDoesNotAliasHost(t *testing.T) {
}
}
func TestContractResourceType(t *testing.T) {
tests := []struct {
name string
resource Resource
want ResourceType
}{
{
name: "proxmox agent uses canonical agent contract type",
resource: Resource{
Type: ResourceTypeAgent,
Proxmox: &ProxmoxData{},
},
want: ResourceTypeAgent,
},
{
name: "docker host uses docker-host contract type",
resource: Resource{
Type: ResourceTypeAgent,
Docker: &DockerData{},
},
want: ResourceType("docker-host"),
},
{
name: "vmware host stays agent",
resource: Resource{
Type: ResourceTypeAgent,
VMware: &VMwareData{},
},
want: ResourceTypeAgent,
},
{
name: "truenas host stays agent",
resource: Resource{
Type: ResourceTypeAgent,
TrueNAS: &TrueNASData{},
},
want: ResourceTypeAgent,
},
{
name: "workload passthrough remains canonical",
resource: Resource{
Type: ResourceTypeVM,
},
want: ResourceTypeVM,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ContractResourceType(tt.resource); got != tt.want {
t.Fatalf("ContractResourceType(%+v) = %q, want %q", tt.resource, got, tt.want)
}
})
}
}
func TestCanonicalResourceIDDoesNotAliasLegacyHostPrefixes(t *testing.T) {
cases := []struct {
name string

View file

@ -1767,3 +1767,42 @@ func TestCanonicalResourceOrderingContractsStayShared(t *testing.T) {
t.Fatalf("%s: cached unified resource views must share the canonical deterministic name ordering helper", filepath.ToSlash(registryPath))
}
}
func TestBroadcastStateUsesSharedCanonicalResourceContract(t *testing.T) {
repoRoot := filepath.Join("..", "..")
typesPath := filepath.Join(repoRoot, "internal", "unifiedresources", "types.go")
resourcesPath := filepath.Join(repoRoot, "internal", "api", "resources.go")
monitorPath := filepath.Join(repoRoot, "internal", "monitoring", "monitor.go")
typesSource, err := os.ReadFile(typesPath)
if err != nil {
t.Fatalf("failed to read %s: %v", typesPath, err)
}
resourcesSource, err := os.ReadFile(resourcesPath)
if err != nil {
t.Fatalf("failed to read %s: %v", resourcesPath, err)
}
monitorSource, err := os.ReadFile(monitorPath)
if err != nil {
t.Fatalf("failed to read %s: %v", monitorPath, err)
}
if !strings.Contains(string(typesSource), "func ContractResourceType(resource Resource) ResourceType {") {
t.Fatalf("%s: unified resource contract type helper must remain canonical", filepath.ToSlash(typesPath))
}
if !strings.Contains(string(resourcesSource), "return unified.ContractResourceType(r)") {
t.Fatalf("%s: /api/resources must derive external resource types from unified.ContractResourceType", filepath.ToSlash(resourcesPath))
}
requiredMonitorSnippets := []string{
"unifiedView := m.currentUnifiedStateView()",
"return string(unifiedresources.ContractResourceType(resource))",
"unifiedresources.ResourceDisplayName(resource)",
"unifiedresources.ResourceClusterName(resource)",
}
for _, snippet := range requiredMonitorSnippets {
if !strings.Contains(string(monitorSource), snippet) {
t.Fatalf("%s: websocket/state broadcast must contain %q", filepath.ToSlash(monitorPath), snippet)
}
}
}

View file

@ -131,6 +131,27 @@ func CanonicalResourceType(rt ResourceType) ResourceType {
return normalized
}
// ContractResourceType maps an internal unified resource onto the canonical
// external transport type used by REST and websocket/state payloads.
func ContractResourceType(resource Resource) ResourceType {
switch CanonicalResourceType(resource.Type) {
case ResourceTypeAgent:
if resource.Proxmox != nil || resource.Agent != nil || resource.TrueNAS != nil || resource.VMware != nil {
return ResourceTypeAgent
}
if resource.Docker != nil {
return ResourceType("docker-host")
}
return ResourceTypeAgent
case ResourceTypeSystemContainer:
return ResourceTypeSystemContainer
case ResourceTypeAppContainer:
return ResourceTypeAppContainer
default:
return CanonicalResourceType(resource.Type)
}
}
// ResourceStatus represents the high-level status of a resource.
type ResourceStatus string