feat(dashboard): show Longhorn and CloudNativePG operators with replica counts

This commit is contained in:
Stephen Schuresko
2026-09-24 10:29:00 -04:00
parent 1118b8e39e
commit 0de82c19c4
3 changed files with 306 additions and 9 deletions

View File

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

View File

@@ -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 @@
</div>
</div>
<!-- Platform Operators Section -->
<div class="section-header">
<div class="section-title">
<span>⚙️ Core Platform Operators</span>
<span class="badge-count" id="count-operators">2 Active</span>
</div>
<div style="font-size:12px; color:var(--text-muted);">High-Availability Data &amp; Distributed Storage Controllers</div>
</div>
<div class="operators-grid" id="operators-container">
<div style="color:var(--text-muted); font-size:13px;">Querying platform operators...</div>
</div>
<!-- Nodes Section -->
<div class="section-header">
<div class="section-title">
@@ -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 = `
<div class="operator-top">
<div>
<div class="operator-title"><span>🐘</span> CloudNativePG Operator</div>
<div class="operator-sub">Enterprise PostgreSQL HA Controller &bull; ns: ${pg.namespace || 'cnpg-system'}</div>
</div>
<span class="status-badge ${pgReadyCls}">${pg.status || 'Active'}</span>
</div>
<div class="operator-meta">
<div class="operator-stat-box">
<span class="operator-stat-label">Operator Controller</span>
<span class="operator-stat-val" style="color: var(--green);">${pg.operator_ready} / ${pg.operator_replicas} Replicas Ready</span>
</div>
<div class="operator-stat-box">
<span class="operator-stat-label">HA Database Cluster</span>
<span class="operator-stat-val">${pg.ready_instances} / ${pg.instances} Instances Ready</span>
</div>
<div class="operator-stat-box">
<span class="operator-stat-label">Primary Instance</span>
<span class="operator-stat-val" style="color: var(--blue);">🟢 ${pg.primary || 'ha-postgres-1'}</span>
</div>
<div class="operator-stat-box">
<span class="operator-stat-label">Cluster Target</span>
<span class="operator-stat-val">${pg.cluster_name || 'ha-postgres'}</span>
</div>
</div>
`;
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 = `
<div class="operator-top">
<div>
<div class="operator-title"><span>🐮</span> Longhorn Storage Operator</div>
<div class="operator-sub">Cloud-Native Distributed Block Storage &bull; ns: ${lh.namespace || 'longhorn-system'}</div>
</div>
<span class="status-badge ${lhReadyCls}">${lh.status || 'Healthy'}</span>
</div>
<div class="operator-meta">
<div class="operator-stat-box">
<span class="operator-stat-label">Storage Manager (DS)</span>
<span class="operator-stat-val" style="color: var(--green);">${lh.manager_ready} / ${lh.manager_desired} Nodes Ready</span>
</div>
<div class="operator-stat-box">
<span class="operator-stat-label">Management UI</span>
<span class="operator-stat-val" style="color: var(--green);">${lh.ui_ready} / ${lh.ui_desired} Replicas Ready</span>
</div>
<div class="operator-stat-box">
<span class="operator-stat-label">Distributed Volumes</span>
<span class="operator-stat-val" style="color: var(--blue);">${lh.total_volumes} Active PVs</span>
</div>
<div class="operator-stat-box">
<span class="operator-stat-label">Storage Class</span>
<span class="operator-stat-val">${lh.storage_class || 'longhorn-2-replicas'}</span>
</div>
</div>
`;
opsCont.appendChild(lhCard);
}
}
// Render Nodes
const nodesCont = document.getElementById('nodes-container');
nodesCont.innerHTML = '';

View File

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