From 0de82c19c47601004b3ae1c7d7f51e47caa6531a Mon Sep 17 00:00:00 2001 From: Stephen Schuresko Date: Thu, 24 Sep 2026 10:29:00 -0400 Subject: [PATCH] feat(dashboard): show Longhorn and CloudNativePG operators with replica counts --- cmd/zero-scale-dashboard/collector.go | 149 +++++++++++++++++++++++- cmd/zero-scale-dashboard/dashboard.html | 120 +++++++++++++++++++ cmd/zero-scale-dashboard/types.go | 46 ++++++-- 3 files changed, 306 insertions(+), 9 deletions(-) diff --git a/cmd/zero-scale-dashboard/collector.go b/cmd/zero-scale-dashboard/collector.go index ca48aa8..95f06ff 100644 --- a/cmd/zero-scale-dashboard/collector.go +++ b/cmd/zero-scale-dashboard/collector.go @@ -102,8 +102,10 @@ func (c *Collector) Collect() (*ClusterStatus, error) { var wg sync.WaitGroup var nodesErr, metricsErr, ingressErr error var rawNodes, rawMetrics, rawIngresses []byte + var rawCnpgDeploy, rawCnpgClusters, rawLonghornDs, rawLonghornDeploy, rawLonghornVols []byte + var cnpgDeployErr, cnpgClustersErr, longhornDsErr, longhornDeployErr, longhornVolsErr error - wg.Add(3) + wg.Add(8) go func() { defer wg.Done() rawNodes, nodesErr = c.k8sGet("/api/v1/nodes") @@ -116,6 +118,26 @@ func (c *Collector) Collect() (*ClusterStatus, error) { defer wg.Done() rawIngresses, ingressErr = c.k8sGet("/apis/networking.k8s.io/v1/ingresses") }() + go func() { + defer wg.Done() + rawCnpgDeploy, cnpgDeployErr = c.k8sGet("/apis/apps/v1/namespaces/cnpg-system/deployments") + }() + go func() { + defer wg.Done() + rawCnpgClusters, cnpgClustersErr = c.k8sGet("/apis/postgresql.cnpg.io/v1/namespaces/default/clusters") + }() + go func() { + defer wg.Done() + rawLonghornDs, longhornDsErr = c.k8sGet("/apis/apps/v1/namespaces/longhorn-system/daemonsets") + }() + go func() { + defer wg.Done() + rawLonghornDeploy, longhornDeployErr = c.k8sGet("/apis/apps/v1/namespaces/longhorn-system/deployments") + }() + go func() { + defer wg.Done() + rawLonghornVols, longhornVolsErr = c.k8sGet("/apis/longhorn.io/v1beta2/namespaces/longhorn-system/volumes") + }() wg.Wait() if nodesErr != nil { @@ -301,6 +323,131 @@ func (c *Collector) Collect() (*ClusterStatus, error) { status.Summary.TotalIngresses = totalIng } + // Populate CloudNativePG Operator & Cluster info + status.Operators.Postgres = PostgresOperatorInfo{ + Name: "CloudNativePG", + Namespace: "cnpg-system", + Status: "Healthy", + } + if cnpgDeployErr == nil && len(rawCnpgDeploy) > 0 { + var deployList struct { + Items []struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Spec struct { + Replicas int `json:"replicas"` + } `json:"spec"` + Status struct { + ReadyReplicas int `json:"readyReplicas"` + Replicas int `json:"replicas"` + } `json:"status"` + } `json:"items"` + } + if err := json.Unmarshal(rawCnpgDeploy, &deployList); err == nil { + for _, item := range deployList.Items { + if strings.Contains(item.Metadata.Name, "cnpg") || strings.Contains(item.Metadata.Name, "cloudnative-pg") { + status.Operators.Postgres.OperatorReplicas = item.Spec.Replicas + status.Operators.Postgres.OperatorReady = item.Status.ReadyReplicas + break + } + } + } + } + if cnpgClustersErr == nil && len(rawCnpgClusters) > 0 { + var clusterList struct { + Items []struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Spec struct { + Instances int `json:"instances"` + } `json:"spec"` + Status struct { + Instances int `json:"instances"` + ReadyInstances int `json:"readyInstances"` + Phase string `json:"phase"` + TargetPrimary string `json:"targetPrimary"` + } `json:"status"` + } `json:"items"` + } + if err := json.Unmarshal(rawCnpgClusters, &clusterList); err == nil && len(clusterList.Items) > 0 { + cItem := clusterList.Items[0] + status.Operators.Postgres.ClusterName = cItem.Metadata.Name + status.Operators.Postgres.Instances = cItem.Spec.Instances + status.Operators.Postgres.ReadyInstances = cItem.Status.ReadyInstances + status.Operators.Postgres.Primary = cItem.Status.TargetPrimary + if cItem.Status.Phase != "" { + status.Operators.Postgres.Status = cItem.Status.Phase + } + } + } + + // Populate Longhorn Storage Operator info + status.Operators.Longhorn = LonghornOperatorInfo{ + Name: "Longhorn", + Namespace: "longhorn-system", + StorageClass: "longhorn-2-replicas", + Status: "Healthy", + } + if longhornDsErr == nil && len(rawLonghornDs) > 0 { + var dsList struct { + Items []struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Status struct { + DesiredNumberScheduled int `json:"desiredNumberScheduled"` + NumberReady int `json:"numberReady"` + } `json:"status"` + } `json:"items"` + } + if err := json.Unmarshal(rawLonghornDs, &dsList); err == nil { + for _, item := range dsList.Items { + if item.Metadata.Name == "longhorn-manager" { + status.Operators.Longhorn.ManagerDesired = item.Status.DesiredNumberScheduled + status.Operators.Longhorn.ManagerReady = item.Status.NumberReady + break + } + } + } + } + if longhornDeployErr == nil && len(rawLonghornDeploy) > 0 { + var deployList struct { + Items []struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Spec struct { + Replicas int `json:"replicas"` + } `json:"spec"` + Status struct { + ReadyReplicas int `json:"readyReplicas"` + } `json:"status"` + } `json:"items"` + } + if err := json.Unmarshal(rawLonghornDeploy, &deployList); err == nil { + for _, item := range deployList.Items { + if item.Metadata.Name == "longhorn-ui" { + status.Operators.Longhorn.UIDesired = item.Spec.Replicas + status.Operators.Longhorn.UIReady = item.Status.ReadyReplicas + break + } + } + } + } + if longhornVolsErr == nil && len(rawLonghornVols) > 0 { + var volList struct { + Items []json.RawMessage `json:"items"` + } + if err := json.Unmarshal(rawLonghornVols, &volList); err == nil { + status.Operators.Longhorn.TotalVolumes = len(volList.Items) + } + } + if status.Operators.Longhorn.ManagerReady < status.Operators.Longhorn.ManagerDesired && status.Operators.Longhorn.ManagerDesired > 0 { + status.Operators.Longhorn.Status = "Degraded" + } + return status, nil } diff --git a/cmd/zero-scale-dashboard/dashboard.html b/cmd/zero-scale-dashboard/dashboard.html index 346375f..3336b00 100644 --- a/cmd/zero-scale-dashboard/dashboard.html +++ b/cmd/zero-scale-dashboard/dashboard.html @@ -198,6 +198,38 @@ .node-meta { font-size: 12px; color: var(--text-muted); display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin: 12px 0 16px 0; background: rgba(0,0,0,0.2); padding: 10px; border-radius: 8px; } .node-metric { margin-top: 10px; } .metric-label-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; } + /* Operators Grid */ + .operators-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: 16px; + margin-bottom: 28px; + } + .operator-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + transition: transform 0.2s ease, border-color 0.2s ease; + } + .operator-card:hover { transform: translateY(-2px); border-color: rgba(56, 189, 248, 0.4); } + .operator-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; } + .operator-title { font-size: 16px; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 8px; } + .operator-sub { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; } + .operator-meta { + font-size: 12px; + color: var(--text-muted); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 12px 0 0 0; + background: rgba(0,0,0,0.2); + padding: 12px; + border-radius: 8px; + } + .operator-stat-box { display: flex; flex-direction: column; gap: 3px; } + .operator-stat-label { font-size: 10.5px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; } + .operator-stat-val { font-size: 13.5px; font-weight: 600; color: #fff; word-break: break-all; } /* Zero-Scale Grid */ .zero-grid { @@ -414,6 +446,18 @@ + +
+
+ ⚙️ Core Platform Operators + 2 Active +
+
High-Availability Data & Distributed Storage Controllers
+
+
+
Querying platform operators...
+
+
@@ -614,6 +658,82 @@ function renderDashboard(data) { document.getElementById('count-zero').innerText = sum.total_zero_apps; document.getElementById('sub-zero-active').innerText = `${sum.active_zero_apps} Active Now`; + // Render Platform Operators + const opsCont = document.getElementById('operators-container'); + if (opsCont && data.operators) { + opsCont.innerHTML = ''; + const pg = data.operators.postgres; + const lh = data.operators.longhorn; + + if (pg) { + const pgCard = document.createElement('div'); + pgCard.className = 'operator-card'; + const pgReadyCls = pg.operator_ready > 0 ? 'ready' : 'notready'; + pgCard.innerHTML = ` +
+
+
🐘 CloudNativePG Operator
+
Enterprise PostgreSQL HA Controller • ns: ${pg.namespace || 'cnpg-system'}
+
+ ${pg.status || 'Active'} +
+
+
+ Operator Controller + ${pg.operator_ready} / ${pg.operator_replicas} Replicas Ready +
+
+ HA Database Cluster + ${pg.ready_instances} / ${pg.instances} Instances Ready +
+
+ Primary Instance + 🟢 ${pg.primary || 'ha-postgres-1'} +
+
+ Cluster Target + ${pg.cluster_name || 'ha-postgres'} +
+
+ `; + opsCont.appendChild(pgCard); + } + + if (lh) { + const lhCard = document.createElement('div'); + lhCard.className = 'operator-card'; + const lhReadyCls = lh.manager_ready > 0 && lh.manager_ready === lh.manager_desired ? 'ready' : 'notready'; + lhCard.innerHTML = ` +
+
+
🐮 Longhorn Storage Operator
+
Cloud-Native Distributed Block Storage • ns: ${lh.namespace || 'longhorn-system'}
+
+ ${lh.status || 'Healthy'} +
+
+
+ Storage Manager (DS) + ${lh.manager_ready} / ${lh.manager_desired} Nodes Ready +
+
+ Management UI + ${lh.ui_ready} / ${lh.ui_desired} Replicas Ready +
+
+ Distributed Volumes + ${lh.total_volumes} Active PVs +
+
+ Storage Class + ${lh.storage_class || 'longhorn-2-replicas'} +
+
+ `; + opsCont.appendChild(lhCard); + } + } + // Render Nodes const nodesCont = document.getElementById('nodes-container'); nodesCont.innerHTML = ''; diff --git a/cmd/zero-scale-dashboard/types.go b/cmd/zero-scale-dashboard/types.go index e96cc3e..21f87af 100644 --- a/cmd/zero-scale-dashboard/types.go +++ b/cmd/zero-scale-dashboard/types.go @@ -3,14 +3,44 @@ package main import "time" type ClusterStatus struct { - Timestamp time.Time `json:"timestamp"` - ClusterVersion string `json:"cluster_version"` - Authenticated bool `json:"authenticated"` - Summary ClusterSummary `json:"summary"` - Nodes []NodeStatus `json:"nodes"` - ZeroScaleApps []ZeroScaleApp `json:"zero_scale_apps"` - OtherSites []SiteGroup `json:"other_sites"` - GitURL string `json:"git_url"` + Timestamp time.Time `json:"timestamp"` + ClusterVersion string `json:"cluster_version"` + Authenticated bool `json:"authenticated"` + Summary ClusterSummary `json:"summary"` + Operators PlatformOperators `json:"operators"` + Nodes []NodeStatus `json:"nodes"` + ZeroScaleApps []ZeroScaleApp `json:"zero_scale_apps"` + OtherSites []SiteGroup `json:"other_sites"` + GitURL string `json:"git_url"` +} + +type PlatformOperators struct { + Postgres PostgresOperatorInfo `json:"postgres"` + Longhorn LonghornOperatorInfo `json:"longhorn"` +} + +type PostgresOperatorInfo struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Status string `json:"status"` + OperatorReplicas int `json:"operator_replicas"` + OperatorReady int `json:"operator_ready"` + ClusterName string `json:"cluster_name"` + Instances int `json:"instances"` + ReadyInstances int `json:"ready_instances"` + Primary string `json:"primary"` +} + +type LonghornOperatorInfo struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Status string `json:"status"` + ManagerDesired int `json:"manager_desired"` + ManagerReady int `json:"manager_ready"` + UIDesired int `json:"ui_desired"` + UIReady int `json:"ui_ready"` + StorageClass string `json:"storage_class"` + TotalVolumes int `json:"total_volumes"` } type ClusterSummary struct {