diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78e2531..6f71bc1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
+## [0.5.0] - 2026-08-22
+
+### Added
+
+- **Applications** table shows live **CPU** (% of cluster allocatable) and **Memory** (k8s `Mi`/`Gi`) from summed pod metrics when metrics-server is available.
+- **Deployments**, **StatefulSets**, and **DaemonSets** tables include **CPU** and **Memory** columns (millicores / `MiB`, summed from owned pods).
+- Applications metric **tooltips**: CPU in millicores; memory as `MiB` plus rounded `MB`.
+- Applications pod attribution matches instance labels, direct workload owners, and **ReplicaSet → Deployment** owners.
+- Transient Kubernetes API errors (for example HTTP/2 `ResponseEnded`) retry automatically; the client uses HTTP/1.1 for list traffic.
+
+### Changed
+
+- Applications memory uses k8s compact units (`512.0MiB`, `<1Mi`) instead of Task Manager `MB` labels; CPU stays as cluster %.
+- DataGrid row separators, headers, and zebra rows are tuned for the dark theme (less harsh white lines).
+
+### Fixed
+
+- Pod metrics memory was misread when stored as `MiB`-style strings, which made Applications **Memory** show `0 MB` while CPU looked correct.
+- Pod metrics now sum **all** container usages in a pod (not only the last container).
+
## [0.4.2] - 2026-08-21
### Fixed
diff --git a/README.md b/README.md
index 812c781..bd30eda 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# MaksIT.ClusterConsole
-
-
-
+
+
+



diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 97c61f0..579c277 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -3,7 +3,7 @@
latest
enable
enable
- 0.4.2
+ 0.5.0
MaksIT.ClusterConsole
MaksIT.ClusterConsole
diff --git a/src/MaksIT.ClusterConsole.Client/ClusterSession.cs b/src/MaksIT.ClusterConsole.Client/ClusterSession.cs
index 84b9a0c..ed74990 100644
--- a/src/MaksIT.ClusterConsole.Client/ClusterSession.cs
+++ b/src/MaksIT.ClusterConsole.Client/ClusterSession.cs
@@ -500,6 +500,22 @@ public sealed class ClusterSession : IClusterSession {
}
}
+ public async Task> GetClusterCpuAllocatableAsync(CancellationToken cancellationToken = default) {
+ try {
+ var nodes = await _client.CoreV1.ListNodeAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
+ var cpu = 0d;
+ foreach (var node in nodes.Items) {
+ if (node.Status?.Allocatable?.TryGetValue("cpu", out var cpuQty) == true)
+ cpu += KubeQuantity.ToCores(cpuQty.ToString());
+ }
+
+ return Result.Ok(cpu);
+ }
+ catch (Exception ex) {
+ return KubernetesResult.Map(ex);
+ }
+ }
+
public async Task>> GetPodMetricsAsync(
string? @namespace,
CancellationToken cancellationToken = default) {
@@ -803,9 +819,11 @@ public sealed class ClusterSession : IClusterSession {
var listed = new List();
string? continueToken = null;
do {
- var list = await _client.CoreV1.ListNamespaceAsync(
- continueParameter: continueToken,
- cancellationToken: cancellationToken).ConfigureAwait(false);
+ var list = await KubernetesApiRetry.ExecuteAsync(
+ ct => _client.CoreV1.ListNamespaceAsync(
+ continueParameter: continueToken,
+ cancellationToken: ct),
+ cancellationToken).ConfigureAwait(false);
foreach (var ns in list.Items ?? []) {
var name = ns.Metadata?.Name;
if (string.IsNullOrEmpty(name))
@@ -832,9 +850,11 @@ public sealed class ClusterSession : IClusterSession {
var pods = new List<(string Namespace, DateTimeOffset? Created)>();
string? continueToken = null;
do {
- var list = await _client.CoreV1.ListPodForAllNamespacesAsync(
- continueParameter: continueToken,
- cancellationToken: cancellationToken).ConfigureAwait(false);
+ var list = await KubernetesApiRetry.ExecuteAsync(
+ ct => _client.CoreV1.ListPodForAllNamespacesAsync(
+ continueParameter: continueToken,
+ cancellationToken: ct),
+ cancellationToken).ConfigureAwait(false);
foreach (var pod in list.Items ?? []) {
var ns = pod.Metadata?.NamespaceProperty;
if (string.IsNullOrEmpty(ns))
@@ -955,7 +975,9 @@ public sealed class ClusterSession : IClusterSession {
var items = new JsonArray();
string? continueToken = null;
do {
- var raw = await page(continueToken).ConfigureAwait(false);
+ var raw = await KubernetesApiRetry.ExecuteAsync(
+ ct => page(continueToken),
+ cancellationToken).ConfigureAwait(false);
var root = KubernetesResult.ToObject(raw);
foreach (var item in KubernetesResult.Items(raw))
items.Add(item.DeepClone());
@@ -989,18 +1011,29 @@ public sealed class ClusterSession : IClusterSession {
private static (string Cpu, string Memory) SumPodMetrics(JsonObject item) {
var containers = item["containers"] as JsonArray;
- if (containers is null)
+ if (containers is null || containers.Count == 0)
return ("-", "-");
- var cpu = "-";
- var mem = "-";
+ var cpu = 0d;
+ long mem = 0;
+ var hasCpu = false;
+ var hasMem = false;
foreach (var c in containers.OfType()) {
var usage = c["usage"] as JsonObject;
- cpu = usage?["cpu"]?.ToString() ?? cpu;
- mem = usage?["memory"]?.ToString() ?? mem;
+ if (usage?["cpu"] is not null) {
+ cpu += KubeQuantity.ToCores(usage["cpu"]?.ToString());
+ hasCpu = true;
+ }
+
+ if (usage?["memory"] is not null) {
+ mem += KubeQuantity.ToBytes(usage["memory"]?.ToString());
+ hasMem = true;
+ }
}
- return (cpu, mem);
+ return (
+ hasCpu ? KubeQuantity.FormatCores(cpu) : "-",
+ hasMem ? KubeQuantity.FormatMemoryQuantity(mem) : "-");
}
private static HelmReleaseInfo? TryDecodeHelm(V1Secret secret) {
diff --git a/src/MaksIT.ClusterConsole.Client/IClusterSession.cs b/src/MaksIT.ClusterConsole.Client/IClusterSession.cs
index e68b365..ac6e464 100644
--- a/src/MaksIT.ClusterConsole.Client/IClusterSession.cs
+++ b/src/MaksIT.ClusterConsole.Client/IClusterSession.cs
@@ -80,6 +80,8 @@ public interface IClusterSession : IDisposable {
Task> GetClusterUsageAsync(CancellationToken cancellationToken = default);
+ Task> GetClusterCpuAllocatableAsync(CancellationToken cancellationToken = default);
+
Task PatchContainerResourcesAsync(
WorkloadContainerLimit row,
string cpuLimit,
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs b/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs
index 918cf22..68826e3 100644
--- a/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs
@@ -145,6 +145,7 @@ public sealed class KubeConfigService : IKubeConfigService {
return Result.NotFound(null, "kubeconfig not found");
var cfg = KubernetesClientConfiguration.BuildConfigFromConfigFile(path, contextName);
+ cfg.DisableHttp2 = true;
return Result.Ok(cfg);
}
catch (KubeConfigException ex) {
diff --git a/src/MaksIT.ClusterConsole.Client/KubeQuantity.cs b/src/MaksIT.ClusterConsole.Client/KubeQuantity.cs
index b395e47..4f28a29 100644
--- a/src/MaksIT.ClusterConsole.Client/KubeQuantity.cs
+++ b/src/MaksIT.ClusterConsole.Client/KubeQuantity.cs
@@ -26,11 +26,11 @@ public static class KubeQuantity {
return 0;
var scale = suffix switch {
- "Ki" => 1024d,
- "Mi" => 1024d * 1024,
- "Gi" => 1024d * 1024 * 1024,
- "Ti" => 1024d * 1024 * 1024 * 1024,
- "Pi" => 1024d * 1024 * 1024 * 1024 * 1024,
+ "Ki" or "KiB" => 1024d,
+ "Mi" or "MiB" => 1024d * 1024,
+ "Gi" or "GiB" => 1024d * 1024 * 1024,
+ "Ti" or "TiB" => 1024d * 1024 * 1024 * 1024,
+ "Pi" or "PiB" => 1024d * 1024 * 1024 * 1024 * 1024,
"k" or "K" => 1_000d,
"M" => 1_000_000d,
"G" => 1_000_000_000d,
@@ -72,6 +72,36 @@ public static class KubeQuantity {
return $"{bytes}B";
}
+ public static string FormatMegabytes(long bytes) {
+ const double mi = 1024d * 1024;
+ var megabytes = bytes / mi;
+ if (megabytes < 1)
+ return "<1 MB";
+
+ return $"{Math.Round(megabytes):0} MB";
+ }
+
+ public static string FormatMemoryQuantity(long bytes) {
+ if (bytes <= 0)
+ return "0";
+
+ const long ki = 1024;
+ const long mi = 1024 * 1024;
+ const long gi = 1024L * 1024 * 1024;
+ if (bytes % gi == 0)
+ return $"{bytes / gi}Gi";
+ if (bytes % mi == 0)
+ return $"{bytes / mi}Mi";
+ if (bytes % ki == 0)
+ return $"{bytes / ki}Ki";
+ if (bytes >= mi)
+ return $"{(bytes / (double)mi).ToString("0.##", CultureInfo.InvariantCulture)}Mi";
+ if (bytes >= ki)
+ return $"{(bytes / (double)ki).ToString("0.##", CultureInfo.InvariantCulture)}Ki";
+
+ return bytes.ToString(CultureInfo.InvariantCulture);
+ }
+
private static bool TrySplit(string? value, out double number, out string suffix) {
number = 0;
suffix = "";
diff --git a/src/MaksIT.ClusterConsole.Client/KubernetesApiRetry.cs b/src/MaksIT.ClusterConsole.Client/KubernetesApiRetry.cs
new file mode 100644
index 0000000..b7f60bc
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Client/KubernetesApiRetry.cs
@@ -0,0 +1,44 @@
+using System.Net;
+
+
+namespace MaksIT.ClusterConsole.Client;
+
+internal static class KubernetesApiRetry {
+ private const int MaxAttempts = 3;
+
+ public static bool IsTransient(Exception ex) {
+ if (ex is OperationCanceledException)
+ return false;
+
+ for (var current = ex; current is not null; current = current.InnerException) {
+ if (current is HttpIOException or HttpRequestException)
+ return true;
+
+ var message = current.Message;
+ if (message.Contains("ResponseEnded", StringComparison.Ordinal)
+ || message.Contains("prematurely", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("connection reset", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("forcibly closed", StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+
+ return false;
+ }
+
+ public static async Task ExecuteAsync(
+ Func> action,
+ CancellationToken cancellationToken) {
+ Exception? last = null;
+ for (var attempt = 1; attempt <= MaxAttempts; attempt++) {
+ try {
+ return await action(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (attempt < MaxAttempts && IsTransient(ex)) {
+ last = ex;
+ await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt), cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ throw last ?? new InvalidOperationException("Kubernetes API retry failed without an exception.");
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Client/KubernetesResult.cs b/src/MaksIT.ClusterConsole.Client/KubernetesResult.cs
index b0cf7a0..ea3a401 100644
--- a/src/MaksIT.ClusterConsole.Client/KubernetesResult.cs
+++ b/src/MaksIT.ClusterConsole.Client/KubernetesResult.cs
@@ -7,6 +7,10 @@ namespace MaksIT.ClusterConsole.Client;
internal static class KubernetesResult {
public static Result Map(Exception ex) {
+ if (KubernetesApiRetry.IsTransient(ex))
+ return Result.ServiceUnavailable(
+ "Cluster API connection dropped while loading data. Retry refresh; if it persists, check API server, load balancer, or network.");
+
var message = ex.Message;
var text = message + " " + ex.GetType().Name;
diff --git a/src/MaksIT.ClusterConsole.Shared/ApplicationManifest.cs b/src/MaksIT.ClusterConsole.Shared/ApplicationManifest.cs
index e8ebaa5..b1308bd 100644
--- a/src/MaksIT.ClusterConsole.Shared/ApplicationManifest.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ApplicationManifest.cs
@@ -1,9 +1,12 @@
using System.Globalization;
using System.Text.Json.Nodes;
+using MaksIT.ClusterConsole.Client;
namespace MaksIT.ClusterConsole.Shared;
+public readonly record struct ApplicationUsage(double CpuCores, long MemoryBytes);
+
public static class ApplicationManifest {
public const string NameKey = "app.kubernetes.io/name";
public const string InstanceKey = "app.kubernetes.io/instance";
@@ -46,7 +49,11 @@ public static class ApplicationManifest {
return !string.IsNullOrWhiteSpace(name) && name == Read(rightLabels, NameKey);
}
- public static IReadOnlyDictionary Cells(JsonObject item) {
+ public static IReadOnlyDictionary Cells(
+ JsonObject item,
+ ApplicationUsage? usage = null,
+ double clusterCpuAllocatable = 0,
+ bool metricsAvailable = false) {
var labels = Labels(item);
var kind = item["kind"]?.GetValue() ?? "Application";
var instance = Read(labels, InstanceKey)
@@ -58,11 +65,123 @@ public static class ApplicationManifest {
["Managed by"] = Read(labels, ManagedByKey) ?? "",
["Version"] = Read(labels, VersionKey) ?? "",
["Ready"] = Ready(item, kind),
+ ["CPU"] = FormatCpuPercent(usage?.CpuCores ?? 0, clusterCpuAllocatable, metricsAvailable),
+ ["Memory"] = FormatMemoryUsage(usage?.MemoryBytes ?? 0, metricsAvailable),
["Status"] = Status(item, kind),
["Age"] = JsonPath.Read(item, "metadata.creationTimestamp")
};
}
+ public static IReadOnlyDictionary MetricTips(
+ ApplicationUsage? usage,
+ bool metricsAvailable) {
+ if (!metricsAvailable || usage is null)
+ return EmptyTips;
+
+ var tips = new Dictionary(StringComparer.Ordinal);
+ var value = usage.Value;
+ if (value.CpuCores > 0)
+ tips["CPU"] = FormatCpuTip(value.CpuCores);
+ if (value.MemoryBytes > 0)
+ tips["Memory"] = FormatMemoryTip(value.MemoryBytes);
+
+ return tips;
+ }
+
+ public static ApplicationUsage SumUsage(
+ JsonObject application,
+ IEnumerable pods,
+ IReadOnlyDictionary metrics,
+ IReadOnlyDictionary? deploymentByReplicaSet = null) {
+ var cpu = 0d;
+ long memory = 0;
+ foreach (var pod in pods) {
+ if (!BelongsToApplication(application, pod, deploymentByReplicaSet))
+ continue;
+ if (pod["status"]?["phase"]?.GetValue() is "Succeeded" or "Failed")
+ continue;
+
+ var key = $"{JsonPath.Namespace(pod)}/{JsonPath.Name(pod)}";
+ if (!metrics.TryGetValue(key, out var podMetrics))
+ continue;
+
+ cpu += KubeQuantity.ToCores(podMetrics.Cpu);
+ memory += KubeQuantity.ToBytes(podMetrics.Memory);
+ }
+
+ return new ApplicationUsage(cpu, memory);
+ }
+
+ public static bool BelongsToApplication(
+ JsonObject application,
+ JsonObject pod,
+ IReadOnlyDictionary? deploymentByReplicaSet = null) {
+ if (SameInstance(application, pod))
+ return true;
+
+ var appNs = JsonPath.Namespace(application);
+ if (!string.Equals(appNs, JsonPath.Namespace(pod), StringComparison.Ordinal))
+ return false;
+
+ var workloads = WorkloadNames(application);
+ if (workloads.Count == 0)
+ return false;
+
+ var owners = pod["metadata"]?["ownerReferences"] as JsonArray;
+ if (owners is null)
+ return false;
+
+ foreach (var owner in owners.OfType()) {
+ var kind = owner["kind"]?.ToString() ?? "";
+ var name = owner["name"]?.ToString() ?? "";
+ if (string.IsNullOrEmpty(name))
+ continue;
+
+ if (workloads.Contains(name, StringComparer.Ordinal))
+ return true;
+
+ if (kind.Equals("ReplicaSet", StringComparison.Ordinal)
+ && deploymentByReplicaSet is not null
+ && deploymentByReplicaSet.TryGetValue($"{JsonPath.Namespace(pod)}/{name}", out var deployment)
+ && workloads.Contains(deployment, StringComparer.Ordinal))
+ return true;
+ }
+
+ return false;
+ }
+
+ public static string FormatCpuTip(double usedCores) =>
+ KubeQuantity.FormatCores(usedCores);
+
+ public static string FormatMemoryTip(long bytes) =>
+ $"{KubeQuantity.FormatBytes(bytes)} · {KubeQuantity.FormatMegabytes(bytes)}";
+
+ public static string FormatCpuPercent(double usedCores, double clusterAllocatable, bool metricsAvailable) {
+ if (!metricsAvailable)
+ return "-";
+ if (usedCores <= 0 || clusterAllocatable <= 0)
+ return "0%";
+
+ var percent = usedCores / clusterAllocatable * 100;
+ if (percent < 0.05)
+ return "<0.1%";
+
+ return $"{Math.Clamp(percent, 0, 100).ToString("0.#", CultureInfo.InvariantCulture)}%";
+ }
+
+ public static string FormatMemoryUsage(long bytes, bool metricsAvailable) {
+ if (!metricsAvailable)
+ return "-";
+ if (bytes <= 0)
+ return "0";
+
+ const long mi = 1024 * 1024;
+ if (bytes < mi)
+ return "<1Mi";
+
+ return KubeQuantity.FormatBytesCompact(bytes);
+ }
+
public static JsonObject? Labels(JsonObject item) {
var meta = item["metadata"]?["labels"] as JsonObject;
var template = item["spec"]?["template"]?["metadata"]?["labels"] as JsonObject;
@@ -100,6 +219,9 @@ public static class ApplicationManifest {
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToList();
+ private static readonly IReadOnlyDictionary EmptyTips =
+ new Dictionary(StringComparer.Ordinal);
+
private static JsonObject CollapseGroup(IGrouping group) {
var members = group
.OrderBy(m => JsonPath.Name(m), StringComparer.OrdinalIgnoreCase)
diff --git a/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.Metrics.cs b/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.Metrics.cs
new file mode 100644
index 0000000..1b29ea7
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.Metrics.cs
@@ -0,0 +1,65 @@
+using System.Text.Json.Nodes;
+using MaksIT.ClusterConsole.Client;
+using MaksIT.Results;
+
+
+namespace MaksIT.ClusterConsole.Shared;
+
+public sealed partial class ClusterWorkspace {
+ private static readonly TimeSpan ClusterCpuAllocatableCacheLifetime = TimeSpan.FromSeconds(30);
+
+ private double? _cachedClusterCpuAllocatable;
+ private DateTimeOffset _clusterCpuAllocatableCachedAt;
+
+ private void ResetMetricsCache() {
+ _cachedClusterCpuAllocatable = null;
+ _clusterCpuAllocatableCachedAt = default;
+ }
+
+ private async Task GetClusterCpuAllocatableCachedAsync(CancellationToken cancellationToken) {
+ if (_session is null)
+ return 0;
+
+ if (_cachedClusterCpuAllocatable is not null
+ && DateTimeOffset.UtcNow - _clusterCpuAllocatableCachedAt < ClusterCpuAllocatableCacheLifetime)
+ return _cachedClusterCpuAllocatable.Value;
+
+ var result = await _session.GetClusterCpuAllocatableAsync(cancellationToken).ConfigureAwait(false);
+ if (!result.IsSuccess)
+ return _cachedClusterCpuAllocatable ?? 0;
+
+ _cachedClusterCpuAllocatable = result.Value;
+ _clusterCpuAllocatableCachedAt = DateTimeOffset.UtcNow;
+ return result.Value;
+ }
+
+ private async Task>> ListWorkloadsWithPodMetricsAsync(
+ ResourceDescriptor descriptor,
+ IReadOnlyList items,
+ string? @namespace,
+ string? filter,
+ CancellationToken cancellationToken) {
+ var podsDescriptor = ResourceCatalog.Find("pods")!;
+ var podsTask = _session!.ListAsync(podsDescriptor.ToRef(), @namespace, cancellationToken);
+ var metricsTask = _session.GetPodMetricsAsync(@namespace, cancellationToken);
+ await Task.WhenAll(podsTask, metricsTask).ConfigureAwait(false);
+
+ var podsResult = await podsTask.ConfigureAwait(false);
+ var metricsResult = await metricsTask.ConfigureAwait(false);
+ var podMetrics = metricsResult.IsSuccess && metricsResult.Value is not null
+ ? metricsResult.Value
+ : (IReadOnlyDictionary)new Dictionary();
+ var metricsAvailable = podMetrics.Count > 0;
+ var allPods = podsResult.IsSuccess ? podsResult.Value ?? [] : [];
+
+ var rows = items
+ .Select(item => {
+ var usage = PodMetricsAggregate.SumForOwner(item, allPods, podMetrics);
+ return ResourceRow.From(item, descriptor, PodMetricsAggregate.ToDisplayMetrics(usage, metricsAvailable));
+ })
+ .Where(row => Matches(row, filter))
+ .ToList();
+
+ return Result>.Ok(rows);
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs b/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs
index 1cf1db2..66f9afe 100644
--- a/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs
@@ -26,6 +26,7 @@ public sealed partial class ClusterWorkspace {
public async Task ConnectAsync(IClusterSession session, CancellationToken cancellationToken = default) {
_session?.Dispose();
+ ResetMetricsCache();
_session = session;
var builtins = ResourceCatalog.BuiltIns.ToList();
var crds = await session.ListCustomResourceDefinitionsAsync(cancellationToken).ConfigureAwait(false);
@@ -39,6 +40,7 @@ public sealed partial class ClusterWorkspace {
public void Disconnect() {
_session?.Dispose();
_session = null;
+ ResetMetricsCache();
Navigator = BuildNavigator(ResourceCatalog.BuiltIns);
}
@@ -73,6 +75,10 @@ public sealed partial class ClusterWorkspace {
if (!listed.IsSuccess)
return new Result>(null, false, listed.Messages, listed.StatusCode);
+ if (PodMetricsAggregate.WorkloadResourceIds.Contains(descriptor.Id))
+ return await ListWorkloadsWithPodMetricsAsync(descriptor, listed.Value ?? [], @namespace, filter, cancellationToken)
+ .ConfigureAwait(false);
+
IReadOnlyDictionary? metrics = null;
if (descriptor.Id is "pods" or "nodes") {
var metricsResult = descriptor.Id == "pods"
@@ -227,14 +233,35 @@ public sealed partial class ClusterWorkspace {
ResourceCatalog.Find("statefulsets")!,
ResourceCatalog.Find("daemonsets")!
};
- var listed = await Task.WhenAll(kinds.Select(kind =>
- _session!.ListAsync(kind.ToRef(), @namespace, cancellationToken))).ConfigureAwait(false);
+ var podsDescriptor = ResourceCatalog.Find("pods")!;
+ var workloadsTask = Task.WhenAll(kinds.Select(kind =>
+ _session!.ListAsync(kind.ToRef(), @namespace, cancellationToken)));
+ var podsTask = _session!.ListAsync(podsDescriptor.ToRef(), @namespace, cancellationToken);
+ var metricsTask = _session.GetPodMetricsAsync(@namespace, cancellationToken);
+ var allocatableTask = GetClusterCpuAllocatableCachedAsync(cancellationToken);
+ var replicaSetsTask = _session.ListAsync(ResourceCatalog.Find("replicasets")!.ToRef(), @namespace, cancellationToken);
+ await Task.WhenAll(workloadsTask, podsTask, metricsTask, allocatableTask, replicaSetsTask).ConfigureAwait(false);
+ var listed = await workloadsTask.ConfigureAwait(false);
for (var i = 0; i < listed.Length; i++) {
if (!listed[i].IsSuccess)
return new Result>(null, false, listed[i].Messages, listed[i].StatusCode);
}
+ var podsResult = await podsTask.ConfigureAwait(false);
+ var metricsResult = await metricsTask.ConfigureAwait(false);
+ var allocatableResult = await allocatableTask.ConfigureAwait(false);
+ var replicaSetsResult = await replicaSetsTask.ConfigureAwait(false);
+ var podMetrics = metricsResult.IsSuccess && metricsResult.Value is not null
+ ? metricsResult.Value
+ : (IReadOnlyDictionary)new Dictionary();
+ var metricsAvailable = podMetrics.Count > 0;
+ var clusterCpuAllocatable = allocatableResult;
+ var deploymentByReplicaSet = replicaSetsResult.IsSuccess
+ ? PodMetricsAggregate.DeploymentByReplicaSet(replicaSetsResult.Value ?? [])
+ : (IReadOnlyDictionary)new Dictionary();
+ var allPods = podsResult.IsSuccess ? podsResult.Value ?? [] : [];
+
var members = listed
.SelectMany((result, i) => (result.Value ?? []).Select(item => {
EnsureApiIdentity(item, kinds[i]);
@@ -244,12 +271,18 @@ public sealed partial class ClusterWorkspace {
.ToList();
var rows = ApplicationManifest.Collapse(members)
- .Select(doc => new ResourceRow {
- Uid = JsonPath.Uid(doc),
- Name = JsonPath.Name(doc),
- Namespace = JsonPath.Namespace(doc),
- Document = doc,
- Cells = ApplicationManifest.Cells(doc)
+ .Select(doc => {
+ var usage = metricsAvailable
+ ? ApplicationManifest.SumUsage(doc, allPods, podMetrics, deploymentByReplicaSet)
+ : (ApplicationUsage?)null;
+ return new ResourceRow {
+ Uid = JsonPath.Uid(doc),
+ Name = JsonPath.Name(doc),
+ Namespace = JsonPath.Namespace(doc),
+ Document = doc,
+ Cells = ApplicationManifest.Cells(doc, usage, clusterCpuAllocatable, metricsAvailable),
+ CellTips = ApplicationManifest.MetricTips(usage, metricsAvailable)
+ };
})
.Where(row => Matches(row, filter))
.OrderBy(row => row.Namespace, StringComparer.OrdinalIgnoreCase)
diff --git a/src/MaksIT.ClusterConsole.Shared/PodMetricsAggregate.cs b/src/MaksIT.ClusterConsole.Shared/PodMetricsAggregate.cs
new file mode 100644
index 0000000..9989b43
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Shared/PodMetricsAggregate.cs
@@ -0,0 +1,70 @@
+using System.Text.Json.Nodes;
+using MaksIT.ClusterConsole.Client;
+
+
+namespace MaksIT.ClusterConsole.Shared;
+
+public static class PodMetricsAggregate {
+ public static readonly IReadOnlySet WorkloadResourceIds = new HashSet(StringComparer.Ordinal) {
+ "deployments",
+ "statefulsets",
+ "daemonsets"
+ };
+
+ public static ApplicationUsage SumForOwner(
+ JsonObject owner,
+ IEnumerable pods,
+ IReadOnlyDictionary metrics) {
+ var cpu = 0d;
+ long memory = 0;
+ foreach (var pod in pods) {
+ if (!ResourceOwnership.Owns(pod, owner))
+ continue;
+ if (pod["status"]?["phase"]?.GetValue() is "Succeeded" or "Failed")
+ continue;
+
+ var key = $"{JsonPath.Namespace(pod)}/{JsonPath.Name(pod)}";
+ if (!metrics.TryGetValue(key, out var podMetrics))
+ continue;
+
+ cpu += KubeQuantity.ToCores(podMetrics.Cpu);
+ memory += KubeQuantity.ToBytes(podMetrics.Memory);
+ }
+
+ return new ApplicationUsage(cpu, memory);
+ }
+
+ public static ResourceMetrics? ToDisplayMetrics(ApplicationUsage usage, bool metricsAvailable) {
+ if (!metricsAvailable)
+ return null;
+ if (usage.CpuCores <= 0 && usage.MemoryBytes <= 0)
+ return new ResourceMetrics("", null, "-", "-");
+
+ return new ResourceMetrics(
+ "",
+ null,
+ usage.CpuCores <= 0 ? "-" : KubeQuantity.FormatCores(usage.CpuCores),
+ usage.MemoryBytes <= 0 ? "-" : KubeQuantity.FormatMemoryQuantity(usage.MemoryBytes));
+ }
+
+ public static IReadOnlyDictionary DeploymentByReplicaSet(IEnumerable replicaSets) {
+ var map = new Dictionary(StringComparer.Ordinal);
+ foreach (var replicaSet in replicaSets) {
+ var ns = JsonPath.Namespace(replicaSet) ?? "";
+ var name = JsonPath.Name(replicaSet);
+ if (string.IsNullOrEmpty(name))
+ continue;
+
+ var deploy = (replicaSet["metadata"]?["ownerReferences"] as JsonArray)?
+ .OfType()
+ .FirstOrDefault(o =>
+ string.Equals(o["kind"]?.ToString(), "Deployment", StringComparison.Ordinal)
+ && o["controller"]?.GetValue() == true)
+ ?["name"]?.ToString();
+ if (!string.IsNullOrEmpty(deploy))
+ map[$"{ns}/{name}"] = deploy;
+ }
+
+ return map;
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs b/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs
index e5ec162..6ef1ba4 100644
--- a/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs
@@ -60,6 +60,8 @@ public static class ResourceCatalog {
new("Managed by", "app.managedBy"),
new("Version", "app.version"),
new("Ready", "status.ready"),
+ new("CPU", "app.cpu"),
+ new("Memory", "app.memory"),
new("Status", "status.phase"),
new("Age", "metadata.creationTimestamp")
],
@@ -177,13 +179,13 @@ public static class ResourceCatalog {
[..std, new("Ready", "status.containerStatuses"), new("Restarts", "status.containerStatuses"), new("Status", "pod.status"), new("Node", "spec.nodeName"), new("CPU", "metrics.cpu"), new("Memory", "metrics.memory")],
logs, podTabs),
D("deployments", "Deployments", Workloads, "apps", "v1", "deployments", "Deployment", true,
- [..std, new("Ready", "status.readyReplicas"), new("Up-to-date", "status.updatedReplicas"), new("Available", "status.availableReplicas")],
+ [..std, new("Ready", "status.readyReplicas"), new("Up-to-date", "status.updatedReplicas"), new("Available", "status.availableReplicas"), new("CPU", "metrics.cpu"), new("Memory", "metrics.memory")],
scale, workloadTabs),
D("statefulsets", "StatefulSets", Workloads, "apps", "v1", "statefulsets", "StatefulSet", true,
- [..std, new("Ready", "status.readyReplicas")],
+ [..std, new("Ready", "status.readyReplicas"), new("CPU", "metrics.cpu"), new("Memory", "metrics.memory")],
scale, workloadTabs),
D("daemonsets", "DaemonSets", Workloads, "apps", "v1", "daemonsets", "DaemonSet", true,
- [..std, new("Desired", "status.desiredNumberScheduled"), new("Current", "status.currentNumberScheduled"), new("Ready", "status.numberReady")],
+ [..std, new("Desired", "status.desiredNumberScheduled"), new("Current", "status.currentNumberScheduled"), new("Ready", "status.numberReady"), new("CPU", "metrics.cpu"), new("Memory", "metrics.memory")],
new ResourceActions(CanRestart: true), workloadTabs),
D("replicasets", "ReplicaSets", Workloads, "apps", "v1", "replicasets", "ReplicaSet", true,
[..std, new("Desired", "spec.replicas"), new("Current", "status.replicas"), new("Ready", "status.readyReplicas")],
diff --git a/src/MaksIT.ClusterConsole.Shared/ResourceColumnSort.cs b/src/MaksIT.ClusterConsole.Shared/ResourceColumnSort.cs
index de1597b..912b7d1 100644
--- a/src/MaksIT.ClusterConsole.Shared/ResourceColumnSort.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ResourceColumnSort.cs
@@ -45,8 +45,10 @@ public static class ResourceColumnSort {
"Age" => AgeToSeconds(left).CompareTo(AgeToSeconds(right)),
"Restarts" => ParseInt(left).CompareTo(ParseInt(right)),
"Ready" => CompareReady(left, right),
- "CPU" => KubeQuantity.ToCores(left).CompareTo(KubeQuantity.ToCores(right)),
- "Memory" => KubeQuantity.ToBytes(left).CompareTo(KubeQuantity.ToBytes(right)),
+ "CPU" => left.EndsWith("%", StringComparison.Ordinal)
+ ? ParsePercent(left).CompareTo(ParsePercent(right))
+ : KubeQuantity.ToCores(left).CompareTo(KubeQuantity.ToCores(right)),
+ "Memory" => ParseMemoryBytes(left).CompareTo(ParseMemoryBytes(right)),
"Replicas" or "Desired" or "Current" or "Min" or "Max" or "Port" =>
ParseInt(left).CompareTo(ParseInt(right)),
_ when IsIpHeader(header) => CompareIpList(left, right),
@@ -177,4 +179,21 @@ public static class ResourceColumnSort {
? value
: 0;
}
+
+ private static double ParsePercent(string text) {
+ if (text.StartsWith("<", StringComparison.Ordinal))
+ return 0.05;
+
+ var span = text.AsSpan().Trim().TrimEnd('%');
+ return double.TryParse(span, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : 0;
+ }
+
+ private static long ParseMemoryBytes(string text) {
+ if (text.StartsWith("<", StringComparison.Ordinal))
+ return 0;
+
+ return KubeQuantity.ToBytes(text);
+ }
}
diff --git a/src/MaksIT.ClusterConsole.Shared/ResourceRow.cs b/src/MaksIT.ClusterConsole.Shared/ResourceRow.cs
index 47b9951..a2e66f9 100644
--- a/src/MaksIT.ClusterConsole.Shared/ResourceRow.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ResourceRow.cs
@@ -18,12 +18,21 @@ public sealed class ResourceRow : INotifyPropertyChanged {
public required IReadOnlyDictionary Cells { get; set; }
+ public IReadOnlyDictionary CellTips { get; set; } =
+ EmptyCellTips;
+
+ private static readonly IReadOnlyDictionary EmptyCellTips =
+ new Dictionary(StringComparer.Ordinal);
+
public string Status =>
Cell("Status");
public string Cell(string header) =>
Cells.TryGetValue(header, out var value) ? value : string.Empty;
+ public string CellTip(string header) =>
+ CellTips.TryGetValue(header, out var value) ? value : string.Empty;
+
public string FormatOverview(IEnumerable? containers = null) {
var lines = Cells.Select(kv => $"{kv.Key}: {kv.Value}").ToList();
var workloads = ApplicationManifest.Workloads(Document);
@@ -56,21 +65,28 @@ public sealed class ResourceRow : INotifyPropertyChanged {
var nameChanged = !string.Equals(Name, source.Name, StringComparison.Ordinal);
var namespaceChanged = !string.Equals(Namespace, source.Namespace, StringComparison.Ordinal);
var cellsChanged = !CellsEqual(Cells, source.Cells);
+ var tipsChanged = !CellsEqual(CellTips, source.CellTips);
Name = source.Name;
Namespace = source.Namespace;
Document = source.Document;
Cells = source.Cells;
+ CellTips = source.CellTips;
if (nameChanged)
OnPropertyChanged(nameof(Name));
if (namespaceChanged)
OnPropertyChanged(nameof(Namespace));
- if (!cellsChanged)
+ if (!cellsChanged && !tipsChanged)
return;
- OnPropertyChanged(nameof(Cells));
- OnPropertyChanged(nameof(Status));
+ if (cellsChanged) {
+ OnPropertyChanged(nameof(Cells));
+ OnPropertyChanged(nameof(Status));
+ }
+
+ if (tipsChanged)
+ OnPropertyChanged(nameof(CellTips));
}
public static ResourceRow From(JsonObject item, ResourceDescriptor descriptor, ResourceMetrics? metrics = null) {
@@ -80,7 +96,7 @@ public sealed class ResourceRow : INotifyPropertyChanged {
"status.containerStatuses" when column.Header == "Ready" => JsonPath.PodReady(item),
"status.containerStatuses" when column.Header == "Restarts" => JsonPath.PodRestarts(item),
"metrics.cpu" => metrics?.Cpu ?? "",
- "metrics.memory" => metrics?.Memory ?? "",
+ "metrics.memory" => FormatMetricMemory(metrics?.Memory),
_ => JsonPath.Read(item, column.Path)
};
}
@@ -97,6 +113,15 @@ public sealed class ResourceRow : INotifyPropertyChanged {
private void OnPropertyChanged(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ private static string FormatMetricMemory(string? value) {
+ if (string.IsNullOrEmpty(value))
+ return "";
+ if (value == "-")
+ return value;
+
+ return KubeQuantity.FormatBytesCompact(KubeQuantity.ToBytes(value));
+ }
+
private static bool CellsEqual(IReadOnlyDictionary left, IReadOnlyDictionary right) {
if (ReferenceEquals(left, right))
return true;
diff --git a/src/MaksIT.ClusterConsole.Tests/ApplicationManifestTests.cs b/src/MaksIT.ClusterConsole.Tests/ApplicationManifestTests.cs
index f3e0763..89151f8 100644
--- a/src/MaksIT.ClusterConsole.Tests/ApplicationManifestTests.cs
+++ b/src/MaksIT.ClusterConsole.Tests/ApplicationManifestTests.cs
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
+using MaksIT.ClusterConsole.Client;
using MaksIT.ClusterConsole.Shared;
@@ -31,9 +32,70 @@ public class ApplicationManifestTests {
Assert.Equal("Helm", cells["Managed by"]);
Assert.Equal("1.4.2", cells["Version"]);
Assert.Equal("2/2", cells["Ready"]);
+ Assert.Equal("-", cells["CPU"]);
+ Assert.Equal("-", cells["Memory"]);
Assert.Equal("Running", cells["Status"]);
}
+ [Fact]
+ public void Cells_show_cpu_percent_and_memory_megabytes_when_metrics_available() {
+ var item = Deployment("web", instance: "shop", nameLabel: "storefront", ready: 1, replicas: 1);
+ var cells = ApplicationManifest.Cells(
+ item,
+ new ApplicationUsage(0.5, 536_870_912),
+ clusterCpuAllocatable: 4,
+ metricsAvailable: true);
+ Assert.Equal("12.5%", cells["CPU"]);
+ Assert.Equal("512.0MiB", cells["Memory"]);
+ }
+
+ [Fact]
+ public void SumUsage_aggregates_matching_pods_across_workloads() {
+ var app = ApplicationManifest.Collapse([
+ Deployment("hubble-ui", instance: "cilium", nameLabel: "hubble-ui", ready: 1, replicas: 1),
+ Deployment("hubble-relay", instance: "cilium", nameLabel: "hubble-relay", ready: 1, replicas: 1)
+ ]).Single();
+
+ var pods = new[] {
+ Pod("hubble-ui-1", "apps", instance: "cilium", nameLabel: "hubble-ui", phase: "Running"),
+ Pod("hubble-relay-1", "apps", instance: "cilium", nameLabel: "hubble-relay", phase: "Running"),
+ Pod("other-1", "apps", instance: "other", nameLabel: "other", phase: "Running")
+ };
+ var metrics = new Dictionary {
+ ["apps/hubble-ui-1"] = new("hubble-ui-1", "apps", "100m", "256Mi"),
+ ["apps/hubble-relay-1"] = new("hubble-relay-1", "apps", "250m", "512Mi")
+ };
+
+ var usage = ApplicationManifest.SumUsage(app, pods, metrics);
+ Assert.Equal(0.35, usage.CpuCores, 9);
+ Assert.Equal(805_306_368, usage.MemoryBytes);
+ Assert.Equal("768.0MiB", ApplicationManifest.FormatMemoryUsage(usage.MemoryBytes, metricsAvailable: true));
+ }
+
+ [Fact]
+ public void BelongsToApplication_matches_instance_labels_or_workload_owner() {
+ var app = ApplicationManifest.Collapse([
+ Deployment("hubble-ui", instance: "cilium", nameLabel: "hubble-ui", ready: 1, replicas: 1)
+ ]).Single();
+ var byLabel = Pod("hubble-ui-1", "apps", instance: "cilium", nameLabel: "hubble-ui", phase: "Running");
+ var byOwner = JsonNode.Parse("""
+ {
+ "metadata": {
+ "name": "hubble-ui-abc",
+ "namespace": "apps",
+ "ownerReferences": [{ "name": "hubble-ui", "kind": "DaemonSet" }]
+ },
+ "status": { "phase": "Running" }
+ }
+ """) as JsonObject;
+ var other = Pod("other-1", "apps", instance: "other", nameLabel: "other", phase: "Running");
+
+ Assert.NotNull(byOwner);
+ Assert.True(ApplicationManifest.BelongsToApplication(app, byLabel));
+ Assert.True(ApplicationManifest.BelongsToApplication(app, byOwner));
+ Assert.False(ApplicationManifest.BelongsToApplication(app, other));
+ }
+
[Fact]
public void Labels_on_pod_template_count_as_application_manifest() {
var item = JsonNode.Parse("""
@@ -171,4 +233,23 @@ public class ApplicationManifestTests {
["status"] = new JsonObject { ["readyReplicas"] = ready }
};
}
+
+ private static JsonObject Pod(
+ string name,
+ string ns,
+ string instance,
+ string nameLabel,
+ string phase) {
+ return new JsonObject {
+ ["metadata"] = new JsonObject {
+ ["name"] = name,
+ ["namespace"] = ns,
+ ["labels"] = new JsonObject {
+ [ApplicationManifest.InstanceKey] = instance,
+ [ApplicationManifest.NameKey] = nameLabel
+ }
+ },
+ ["status"] = new JsonObject { ["phase"] = phase }
+ };
+ }
}
diff --git a/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs b/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs
index 3622817..2b381e3 100644
--- a/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs
+++ b/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs
@@ -94,6 +94,7 @@ public class KubeConfigServiceTests {
var built = service.Build("k3s", path);
Assert.True(built.IsSuccess, string.Join("; ", built.Messages));
Assert.Equal("secret-token", built.Value!.AccessToken);
+ Assert.True(built.Value.DisableHttp2);
}
[Fact]
diff --git a/src/MaksIT.ClusterConsole.Tests/KubeQuantityTests.cs b/src/MaksIT.ClusterConsole.Tests/KubeQuantityTests.cs
index b9ece00..e83d489 100644
--- a/src/MaksIT.ClusterConsole.Tests/KubeQuantityTests.cs
+++ b/src/MaksIT.ClusterConsole.Tests/KubeQuantityTests.cs
@@ -1,4 +1,5 @@
using MaksIT.ClusterConsole.Client;
+using MaksIT.ClusterConsole.Shared;
namespace MaksIT.ClusterConsole.Tests;
@@ -18,11 +19,41 @@ public class KubeQuantityTests {
[InlineData("512Mi", 536870912)]
[InlineData("1Gi", 1073741824)]
[InlineData("1000Ki", 1024000)]
+ [InlineData("256.0MiB", 268435456)]
[InlineData("100", 100)]
public void ToBytes_parses_memory(string raw, long expected) {
Assert.Equal(expected, KubeQuantity.ToBytes(raw));
}
+ [Theory]
+ [InlineData(536_870_912, "512Mi")]
+ [InlineData(805_306_368, "768Mi")]
+ public void FormatMemoryQuantity_round_trips_for_metrics(long bytes, string expected) {
+ Assert.Equal(expected, KubeQuantity.FormatMemoryQuantity(bytes));
+ Assert.Equal(bytes, KubeQuantity.ToBytes(KubeQuantity.FormatMemoryQuantity(bytes)));
+ }
+
+ [Theory]
+ [InlineData(536_870_912, "512 MB")]
+ [InlineData(1_048_576, "1 MB")]
+ [InlineData(512_000, "<1 MB")]
+ public void FormatMegabytes_uses_task_manager_style(long bytes, string expected) =>
+ Assert.Equal(expected, KubeQuantity.FormatMegabytes(bytes));
+
+ [Theory]
+ [InlineData(0.001, 4, "<0.1%")]
+ [InlineData(0.0001, 4, "<0.1%")]
+ [InlineData(0, 4, "0%")]
+ public void FormatCpuPercent_uses_task_manager_style(double used, double allocatable, string expected) =>
+ Assert.Equal(expected, ApplicationManifest.FormatCpuPercent(used, allocatable, metricsAvailable: true));
+
+ [Theory]
+ [InlineData(536_870_912, "512.0MiB")]
+ [InlineData(1_048_576, "1.0MiB")]
+ [InlineData(512_000, "<1Mi")]
+ public void FormatMemoryUsage_uses_k8s_compact_units(long bytes, string expected) =>
+ Assert.Equal(expected, ApplicationManifest.FormatMemoryUsage(bytes, metricsAvailable: true));
+
[Fact]
public void ClusterUsage_percent_uses_allocatable() {
var usage = new ClusterUsage(
diff --git a/src/MaksIT.ClusterConsole.Tests/KubernetesApiRetryTests.cs b/src/MaksIT.ClusterConsole.Tests/KubernetesApiRetryTests.cs
new file mode 100644
index 0000000..da12706
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Tests/KubernetesApiRetryTests.cs
@@ -0,0 +1,27 @@
+using System.Net;
+using MaksIT.ClusterConsole.Client;
+
+
+namespace MaksIT.ClusterConsole.Tests;
+
+public class KubernetesApiRetryTests {
+ [Fact]
+ public void IsTransient_detects_http_response_ended() {
+ var ex = new HttpRequestException("The response ended prematurely while waiting for the next frame from the server. (ResponseEnded)");
+ Assert.True(KubernetesApiRetry.IsTransient(ex));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_retries_transient_failures() {
+ var attempts = 0;
+ var result = await KubernetesApiRetry.ExecuteAsync(_ => {
+ attempts++;
+ if (attempts < 2)
+ throw new HttpRequestException("ResponseEnded");
+ return Task.FromResult(42);
+ }, CancellationToken.None);
+
+ Assert.Equal(42, result);
+ Assert.Equal(2, attempts);
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Tests/KubernetesResultTests.cs b/src/MaksIT.ClusterConsole.Tests/KubernetesResultTests.cs
index 0408f84..7bd595d 100644
--- a/src/MaksIT.ClusterConsole.Tests/KubernetesResultTests.cs
+++ b/src/MaksIT.ClusterConsole.Tests/KubernetesResultTests.cs
@@ -1,3 +1,4 @@
+using System.Net;
using System.Text.Json.Nodes;
using MaksIT.ClusterConsole.Client;
@@ -24,4 +25,11 @@ public class KubernetesResultTests {
var root = JsonNode.Parse("""{"metadata":{"continue":"token-1"}}""") as JsonObject;
Assert.Equal("token-1", KubernetesResult.ContinueToken(root));
}
+
+ [Fact]
+ public void Map_transient_http_errors_are_service_unavailable() {
+ var mapped = KubernetesResult.Map(new HttpRequestException("The response ended prematurely while waiting for the next frame from the server. (ResponseEnded)"));
+ Assert.False(mapped.IsSuccess);
+ Assert.Contains("connection dropped", mapped.Messages[0], StringComparison.OrdinalIgnoreCase);
+ }
}
diff --git a/src/MaksIT.ClusterConsole.Tests/PodMetricsAggregateTests.cs b/src/MaksIT.ClusterConsole.Tests/PodMetricsAggregateTests.cs
new file mode 100644
index 0000000..693c5ca
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Tests/PodMetricsAggregateTests.cs
@@ -0,0 +1,129 @@
+using System.Text.Json.Nodes;
+using MaksIT.ClusterConsole.Client;
+using MaksIT.ClusterConsole.Shared;
+
+
+namespace MaksIT.ClusterConsole.Tests;
+
+public class PodMetricsAggregateTests {
+ [Fact]
+ public void SumForOwner_aggregates_pods_owned_by_workload() {
+ var deployment = JsonNode.Parse("""
+ {
+ "metadata": { "name": "web", "namespace": "apps", "uid": "1" },
+ "spec": { "selector": { "matchLabels": { "app": "web" } } }
+ }
+ """) as JsonObject;
+ var pods = new[] {
+ Pod("web-1", "apps", """{ "app": "web" }""", "Running"),
+ Pod("other-1", "apps", """{ "app": "other" }""", "Running")
+ };
+ var metrics = new Dictionary {
+ ["apps/web-1"] = new("web-1", "apps", "100m", "256Mi"),
+ ["apps/other-1"] = new("other-1", "apps", "500m", "1Gi")
+ };
+
+ Assert.NotNull(deployment);
+ var usage = PodMetricsAggregate.SumForOwner(deployment, pods, metrics);
+ Assert.Equal(0.1, usage.CpuCores, 9);
+ Assert.Equal(268_435_456, usage.MemoryBytes);
+
+ var display = PodMetricsAggregate.ToDisplayMetrics(usage, metricsAvailable: true);
+ Assert.NotNull(display);
+ Assert.Equal("100m", display!.Cpu);
+ Assert.Equal("256Mi", display.Memory);
+ }
+
+ [Fact]
+ public void DeploymentByReplicaSet_maps_controller_replica_sets() {
+ var replicaSet = JsonNode.Parse("""
+ {
+ "metadata": {
+ "name": "web-7d4f8b9c6d",
+ "namespace": "apps",
+ "ownerReferences": [
+ { "kind": "Deployment", "name": "web", "controller": true }
+ ]
+ }
+ }
+ """) as JsonObject;
+
+ Assert.NotNull(replicaSet);
+ var map = PodMetricsAggregate.DeploymentByReplicaSet([replicaSet]);
+ Assert.Equal("web", map["apps/web-7d4f8b9c6d"]);
+ }
+
+ [Fact]
+ public void BelongsToApplication_matches_pods_owned_by_replica_set_deployment() {
+ var app = ApplicationManifest.Collapse([
+ Deployment("web", instance: "shop", nameLabel: "storefront", ready: 1, replicas: 1)
+ ]).Single();
+ var map = new Dictionary { ["apps/web-abc"] = "web" };
+ var pod = JsonNode.Parse("""
+ {
+ "metadata": {
+ "name": "web-pod",
+ "namespace": "apps",
+ "ownerReferences": [{ "kind": "ReplicaSet", "name": "web-abc" }]
+ },
+ "status": { "phase": "Running" }
+ }
+ """) as JsonObject;
+ var metrics = new Dictionary {
+ ["apps/web-pod"] = new("web-pod", "apps", "50m", "128Mi")
+ };
+
+ Assert.NotNull(pod);
+ Assert.True(ApplicationManifest.BelongsToApplication(app, pod, map));
+ var usage = ApplicationManifest.SumUsage(app, [pod], metrics, map);
+ Assert.Equal(0.05, usage.CpuCores, 9);
+ }
+
+ [Fact]
+ public void MetricTips_show_task_manager_and_k8s_native_values() {
+ var tips = ApplicationManifest.MetricTips(new ApplicationUsage(0.5, 536_870_912), metricsAvailable: true);
+ Assert.Equal("500m", tips["CPU"]);
+ Assert.Equal("512 MiB · 512 MB", tips["Memory"]);
+ }
+
+ private static JsonObject Pod(string name, string ns, string labelsJson, string phase) {
+ var labels = JsonNode.Parse(labelsJson) as JsonObject;
+ return new JsonObject {
+ ["metadata"] = new JsonObject {
+ ["name"] = name,
+ ["namespace"] = ns,
+ ["labels"] = labels
+ },
+ ["status"] = new JsonObject { ["phase"] = phase }
+ };
+ }
+
+ private static JsonObject Deployment(
+ string name,
+ string? instance = null,
+ string? nameLabel = null,
+ int ready = 0,
+ int replicas = 1) {
+ var labels = new JsonObject();
+ if (instance is not null)
+ labels[ApplicationManifest.InstanceKey] = instance;
+ if (nameLabel is not null)
+ labels[ApplicationManifest.NameKey] = nameLabel;
+
+ return new JsonObject {
+ ["kind"] = "Deployment",
+ ["apiVersion"] = "apps/v1",
+ ["metadata"] = new JsonObject {
+ ["name"] = name,
+ ["namespace"] = "apps",
+ ["uid"] = name,
+ ["labels"] = labels
+ },
+ ["spec"] = new JsonObject {
+ ["replicas"] = replicas,
+ ["selector"] = new JsonObject { ["matchLabels"] = new JsonObject { ["app"] = nameLabel ?? name } }
+ },
+ ["status"] = new JsonObject { ["readyReplicas"] = ready }
+ };
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Tests/ResourceTableTests.cs b/src/MaksIT.ClusterConsole.Tests/ResourceTableTests.cs
index 9e6939a..7053277 100644
--- a/src/MaksIT.ClusterConsole.Tests/ResourceTableTests.cs
+++ b/src/MaksIT.ClusterConsole.Tests/ResourceTableTests.cs
@@ -45,6 +45,10 @@ public class ResourceTableTests {
Assert.True(ResourceColumnSort.Compare("Ready", "1/2", "2/2") < 0);
Assert.True(ResourceColumnSort.Compare("Restarts", "12", "3") > 0);
Assert.True(ResourceColumnSort.Compare("CPU", "250m", "1") < 0);
+ Assert.True(ResourceColumnSort.Compare("CPU", "12.5%", "2%") > 0);
+ Assert.True(ResourceColumnSort.Compare("CPU", "<0.1%", "1%") < 0);
+ Assert.True(ResourceColumnSort.Compare("Memory", "512.0MiB", "1.0MiB") > 0);
+ Assert.True(ResourceColumnSort.Compare("Memory", "<1Mi", "2.0MiB") < 0);
}
[Fact]
@@ -81,22 +85,29 @@ public class ResourceTableTests {
public void CopyFrom_updates_cells_on_the_same_instance() {
var current = Row("web-a", "2d", "Running");
var incoming = Row("web-a", "3d", "CrashLoopBackOff");
+ incoming.CellTips = new Dictionary { ["CPU"] = "500m · 12.5% cluster" };
var cellsChanged = 0;
var statusChanged = 0;
+ var tipsChanged = 0;
current.PropertyChanged += (_, e) => {
if (e.PropertyName == nameof(ResourceRow.Cells))
cellsChanged++;
if (e.PropertyName == nameof(ResourceRow.Status))
statusChanged++;
+
+ if (e.PropertyName == nameof(ResourceRow.CellTips))
+ tipsChanged++;
};
current.CopyFrom(incoming);
Assert.Equal("3d", current.Cell("Age"));
Assert.Equal("CrashLoopBackOff", current.Status);
+ Assert.Equal("500m · 12.5% cluster", current.CellTip("CPU"));
Assert.Equal(1, cellsChanged);
Assert.Equal(1, statusChanged);
+ Assert.Equal(1, tipsChanged);
}
[Fact]
diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs
index 9a3db78..8542a2e 100644
--- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs
+++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs
@@ -134,20 +134,34 @@ public partial class MainWindow : Window {
};
}
- return new DataGridTextColumn {
+ return new DataGridTemplateColumn {
Header = columnHeader,
Tag = header,
CanUserSort = true,
CustomSortComparer = comparer,
- Binding = new Binding(nameof(ResourceRow.Cells)) {
- Mode = BindingMode.OneWay,
- Converter = new DictionaryKeyConverter(header)
- },
+ CellTemplate = TextCellTemplate(header),
Width = new DataGridLength(1, DataGridLengthUnitType.Star),
MinWidth = 72
};
}
+ private static FuncDataTemplate TextCellTemplate(string header) =>
+ new((_, _) => {
+ var text = new TextBlock {
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(6, 0)
+ };
+ text.Bind(TextBlock.TextProperty, new Binding(nameof(ResourceRow.Cells)) {
+ Mode = BindingMode.OneWay,
+ Converter = new DictionaryKeyConverter(header)
+ });
+ text.Bind(ToolTip.TipProperty, new Binding(nameof(ResourceRow.CellTips)) {
+ Mode = BindingMode.OneWay,
+ Converter = new DictionaryKeyConverter(header)
+ });
+ return text;
+ }, true);
+
private static FuncDataTemplate StatusCellTemplate() =>
new((_, _) => {
var text = new TextBlock {
diff --git a/src/MaksIT.ClusterConsole.UI/Themes/Dark.axaml b/src/MaksIT.ClusterConsole.UI/Themes/Dark.axaml
index ab8298f..ffe1317 100644
--- a/src/MaksIT.ClusterConsole.UI/Themes/Dark.axaml
+++ b/src/MaksIT.ClusterConsole.UI/Themes/Dark.axaml
@@ -1,6 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+