mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Align mock state resources with canonical contract
This commit is contained in:
parent
3a992a4fae
commit
b64782c083
12 changed files with 385 additions and 92 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue