feat: measure and display zero-scale RAM separately per node alongside top 24/7 memory consumers
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user