feat: measure and display zero-scale RAM separately per node alongside top 24/7 memory consumers

This commit is contained in:
Stephen Schuresko
2026-09-24 11:23:02 -04:00
parent b6601f5ac9
commit c26aac5e0b
3 changed files with 339 additions and 6 deletions

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
@@ -104,8 +105,10 @@ func (c *Collector) Collect() (*ClusterStatus, error) {
var rawNodes, rawMetrics, rawIngresses []byte
var rawCnpgDeploy, rawCnpgClusters, rawLonghornDs, rawLonghornDeploy, rawLonghornVols []byte
var cnpgDeployErr, cnpgClustersErr, longhornDsErr, longhornDeployErr, longhornVolsErr error
var rawPods, rawPodMetrics []byte
var podsErr, podMetricsErr error
wg.Add(8)
wg.Add(10)
go func() {
defer wg.Done()
rawNodes, nodesErr = c.k8sGet("/api/v1/nodes")
@@ -114,6 +117,14 @@ func (c *Collector) Collect() (*ClusterStatus, error) {
defer wg.Done()
rawMetrics, metricsErr = c.k8sGet("/apis/metrics.k8s.io/v1beta1/nodes")
}()
go func() {
defer wg.Done()
rawPods, podsErr = c.k8sGet("/api/v1/pods")
}()
go func() {
defer wg.Done()
rawPodMetrics, podMetricsErr = c.k8sGet("/apis/metrics.k8s.io/v1beta1/pods")
}()
go func() {
defer wg.Done()
rawIngresses, ingressErr = c.k8sGet("/apis/networking.k8s.io/v1/ingresses")
@@ -173,6 +184,75 @@ func (c *Collector) Collect() (*ClusterStatus, error) {
}
}
// Parse pod metrics map (namespace/podName -> total memory bytes)
podMetricsMap := make(map[string]int64)
if podMetricsErr == nil && len(rawPodMetrics) > 0 {
var podMetricList struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
} `json:"metadata"`
Containers []struct {
Usage struct {
Memory string `json:"memory"`
} `json:"usage"`
} `json:"containers"`
} `json:"items"`
}
if err := json.Unmarshal(rawPodMetrics, &podMetricList); err == nil {
for _, item := range podMetricList.Items {
var podMem int64
for _, c := range item.Containers {
podMem += parseQuantityBytes(c.Usage.Memory)
}
key := item.Metadata.Namespace + "/" + item.Metadata.Name
podMetricsMap[key] = podMem
}
}
}
type podItem struct {
name string
namespace string
memBytes int64
}
nodePods := make(map[string][]podItem)
nodeZeroScaleRAM := make(map[string]int64)
if podsErr == nil && len(rawPods) > 0 {
var podList struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
} `json:"metadata"`
Spec struct {
NodeName string `json:"nodeName"`
} `json:"spec"`
} `json:"items"`
}
if err := json.Unmarshal(rawPods, &podList); err == nil {
for _, p := range podList.Items {
node := p.Spec.NodeName
if node == "" {
continue
}
key := p.Metadata.Namespace + "/" + p.Metadata.Name
mem := podMetricsMap[key]
if isZeroScalePod(p.Metadata.Namespace, p.Metadata.Name) {
nodeZeroScaleRAM[node] += mem
} else {
nodePods[node] = append(nodePods[node], podItem{
name: p.Metadata.Name,
namespace: p.Metadata.Namespace,
memBytes: mem,
})
}
}
}
}
// Parse nodes
var k8sNodeList struct {
Items []struct {
@@ -288,6 +368,40 @@ func (c *Collector) Collect() (*ClusterStatus, error) {
}
}
// Zero-Scale RAM vs Other Workload RAM on this node
zeroRAM := nodeZeroScaleRAM[n.Metadata.Name]
nodeStat.ZeroScaleRAMBytes = zeroRAM
if nodeStat.RAMTotalBytes > 0 {
nodeStat.ZeroScaleRAMPercent = (float64(zeroRAM) / float64(nodeStat.RAMTotalBytes)) * 100.0
}
if nodeStat.RAMUsedBytes > zeroRAM {
nodeStat.OtherWorkloadRAMBytes = nodeStat.RAMUsedBytes - zeroRAM
} else {
var sumOther int64
for _, p := range nodePods[n.Metadata.Name] {
sumOther += p.memBytes
}
nodeStat.OtherWorkloadRAMBytes = sumOther
}
// Top memory consumers (non-zero-scale 24/7 workloads)
podsOnNode := nodePods[n.Metadata.Name]
sort.Slice(podsOnNode, func(i, j int) bool {
return podsOnNode[i].memBytes > podsOnNode[j].memBytes
})
var topHogs []MemoryHog
for i := 0; i < len(podsOnNode) && i < 3; i++ {
if podsOnNode[i].memBytes > 0 {
topHogs = append(topHogs, MemoryHog{
Name: podsOnNode[i].name,
Namespace: podsOnNode[i].namespace,
RAMBytes: podsOnNode[i].memBytes,
Display: formatPodHogName(podsOnNode[i].namespace, podsOnNode[i].name),
})
}
}
nodeStat.TopMemoryConsumers = topHogs
status.Nodes = append(status.Nodes, nodeStat)
}
@@ -780,3 +894,104 @@ func formatDuration(d time.Duration) string {
mins := int(d.Minutes()) % 60
return fmt.Sprintf("%dh %dm", hours, mins)
}
func parseQuantityBytes(str string) int64 {
str = strings.TrimSpace(str)
if str == "" {
return 0
}
if strings.HasSuffix(str, "Ki") {
v, _ := strconv.ParseInt(strings.TrimSuffix(str, "Ki"), 10, 64)
return v * 1024
}
if strings.HasSuffix(str, "Mi") {
v, _ := strconv.ParseInt(strings.TrimSuffix(str, "Mi"), 10, 64)
return v * 1024 * 1024
}
if strings.HasSuffix(str, "Gi") {
v, _ := strconv.ParseInt(strings.TrimSuffix(str, "Gi"), 10, 64)
return v * 1024 * 1024 * 1024
}
if strings.HasSuffix(str, "k") {
v, _ := strconv.ParseInt(strings.TrimSuffix(str, "k"), 10, 64)
return v * 1000
}
if strings.HasSuffix(str, "M") {
v, _ := strconv.ParseInt(strings.TrimSuffix(str, "M"), 10, 64)
return v * 1000 * 1000
}
if strings.HasSuffix(str, "G") {
v, _ := strconv.ParseInt(strings.TrimSuffix(str, "G"), 10, 64)
return v * 1000 * 1000 * 1000
}
v, _ := strconv.ParseInt(str, 10, 64)
return v
}
func formatPodHogName(namespace, podName string) string {
lower := strings.ToLower(podName)
if strings.Contains(lower, "yacy") {
return "YaCy Search"
}
if strings.Contains(lower, "mastodon-web") {
return "Mastodon Web"
}
if strings.Contains(lower, "mastodon-sidekiq") {
return "Mastodon Sidekiq"
}
if strings.Contains(lower, "mastodon-streaming") {
return "Mastodon Streaming"
}
if strings.Contains(lower, "prometheus-server") {
return "Prometheus Server"
}
if strings.Contains(lower, "odoo") {
return "Odoo ERP"
}
if strings.Contains(lower, "nextcloud") {
return "Nextcloud Hub"
}
if strings.Contains(lower, "ha-postgres") {
return "PostgreSQL (" + podName + ")"
}
if strings.Contains(lower, "instance-manager") {
return "Longhorn Engine"
}
if strings.Contains(lower, "longhorn-manager") {
return "Longhorn Manager"
}
if strings.Contains(lower, "nginx-ingress") {
return "NGINX Ingress"
}
if strings.Contains(lower, "microk8s-microk8s") || strings.Contains(lower, "minio") {
return "MinIO Storage"
}
if strings.Contains(lower, "hoppscotch") {
return "Hoppscotch"
}
if strings.Contains(lower, "builds-dash") {
return "Builds Dash"
}
if strings.Contains(lower, "papi-prod") {
return "PAPI Prod"
}
cleanName := podName
parts := strings.Split(cleanName, "-")
if len(parts) > 2 && len(parts[len(parts)-1]) == 5 {
parts = parts[:len(parts)-1]
if len(parts) > 1 && (len(parts[len(parts)-1]) >= 8 && len(parts[len(parts)-1]) <= 10) {
parts = parts[:len(parts)-1]
}
cleanName = strings.Join(parts, "-")
}
if namespace != "" && namespace != "default" {
return fmt.Sprintf("%s (%s)", cleanName, namespace)
}
return cleanName
}
func isZeroScalePod(namespace, podName string) bool {
lower := strings.ToLower(podName)
return strings.HasPrefix(lower, "zero-scale-") || strings.Contains(lower, "zero-scale-gateway")
}

View File

@@ -199,6 +199,88 @@
.node-metric { margin-top: 10px; }
.metric-label-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
/* Node RAM Breakdown & Top Consumers */
.node-ram-breakdown {
margin-top: 12px;
padding: 10px 12px;
background: rgba(15, 23, 42, 0.65);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
gap: 6px;
}
.ram-breakdown-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11.5px;
}
.ram-sub-title {
color: var(--text-muted);
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.ram-sub-val {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
color: #e2e8f0;
display: flex;
align-items: center;
gap: 6px;
}
.ram-sub-val.highlight-green {
color: var(--green);
}
.ram-pct-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 10px;
background: rgba(34, 197, 94, 0.15);
color: var(--green);
border: 1px solid rgba(34, 197, 94, 0.25);
}
.node-hogs-section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
}
.node-hogs-header {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: 6px;
}
.node-hogs-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hog-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
background: rgba(30, 41, 59, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 3px 8px;
border-radius: 6px;
color: #cbd5e1;
transition: border-color 0.15s ease;
}
.hog-pill:hover {
border-color: rgba(245, 158, 11, 0.4);
}
.hog-ram {
color: #f59e0b;
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
font-weight: 600;
}
/* Efficiency Grid */
.efficiency-grid {
display: grid;
@@ -916,6 +998,31 @@ function renderDashboard(data) {
<div class="progress-fill" style="width:${Math.min(ramPct, 100)}%; --fill-color: var(--purple);"></div>
</div>
</div>
<div class="node-ram-breakdown">
<div class="ram-breakdown-row">
<div class="ram-sub-title"><span>⚡ Zero-Scale Footprint:</span></div>
<div class="ram-sub-val highlight-green">
${formatBytes(n.zero_scale_ram_bytes || 0)}
<span class="ram-pct-badge">${(n.zero_scale_ram_percent || 0).toFixed(1)}% of node</span>
</div>
</div>
<div class="ram-breakdown-row">
<div class="ram-sub-title"><span>🐘 24/7 Workloads:</span></div>
<div class="ram-sub-val">${formatBytes(n.other_workload_ram_bytes || 0)}</div>
</div>
${n.top_memory_consumers && n.top_memory_consumers.length > 0 ? `
<div class="node-hogs-section">
<div class="node-hogs-header">Top 24/7 Monoliths on Node:</div>
<div class="node-hogs-list">
${n.top_memory_consumers.map(h => `
<span class="hog-pill" title="${h.namespace}/${h.name}">
${h.display} <span class="hog-ram">${formatBytes(h.ram_bytes)}</span>
</span>
`).join('')}
</div>
</div>
` : ''}
</div>
`;
nodesCont.appendChild(card);
});

View File

@@ -85,11 +85,22 @@ type NodeStatus struct {
CPUCores int `json:"cpu_cores"`
CPUUsedCores float64 `json:"cpu_used_cores"`
CPUUsagePercent float64 `json:"cpu_usage_percent"`
RAMTotalBytes int64 `json:"ram_total_bytes"`
RAMUsedBytes int64 `json:"ram_used_bytes"`
RAMUsagePercent float64 `json:"ram_usage_percent"`
EphemeralBytes int64 `json:"ephemeral_bytes"`
Conditions map[string]string `json:"conditions"`
RAMTotalBytes int64 `json:"ram_total_bytes"`
RAMUsedBytes int64 `json:"ram_used_bytes"`
RAMUsagePercent float64 `json:"ram_usage_percent"`
ZeroScaleRAMBytes int64 `json:"zero_scale_ram_bytes"`
ZeroScaleRAMPercent float64 `json:"zero_scale_ram_percent"`
OtherWorkloadRAMBytes int64 `json:"other_workload_ram_bytes"`
TopMemoryConsumers []MemoryHog `json:"top_memory_consumers"`
EphemeralBytes int64 `json:"ephemeral_bytes"`
Conditions map[string]string `json:"conditions"`
}
type MemoryHog struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
RAMBytes int64 `json:"ram_bytes"`
Display string `json:"display"`
}
type ZeroScaleApp struct {