From fee5ca8249262aa400ca84bf752bef2ff206f096 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sat, 22 Aug 2026 12:41:20 +0200 Subject: [PATCH] (feature): live usage metrics and stabler cluster API lists --- CHANGELOG.md | 20 +++ README.md | 6 +- src/Directory.Build.props | 2 +- .../ClusterSession.cs | 59 ++++++-- .../IClusterSession.cs | 2 + .../KubeConfigService.cs | 1 + .../KubeQuantity.cs | 40 +++++- .../KubernetesApiRetry.cs | 44 ++++++ .../KubernetesResult.cs | 4 + .../ApplicationManifest.cs | 124 ++++++++++++++++- .../ClusterWorkspace.Metrics.cs | 65 +++++++++ .../ClusterWorkspace.cs | 49 +++++-- .../PodMetricsAggregate.cs | 70 ++++++++++ .../ResourceCatalog.cs | 8 +- .../ResourceColumnSort.cs | 23 +++- .../ResourceRow.cs | 33 ++++- .../ApplicationManifestTests.cs | 81 +++++++++++ .../KubeConfigServiceTests.cs | 1 + .../KubeQuantityTests.cs | 31 +++++ .../KubernetesApiRetryTests.cs | 27 ++++ .../KubernetesResultTests.cs | 8 ++ .../PodMetricsAggregateTests.cs | 129 ++++++++++++++++++ .../ResourceTableTests.cs | 11 ++ .../MainWindow.axaml.cs | 24 +++- .../Themes/Dark.axaml | 37 +++++ 25 files changed, 854 insertions(+), 45 deletions(-) create mode 100644 src/MaksIT.ClusterConsole.Client/KubernetesApiRetry.cs create mode 100644 src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.Metrics.cs create mode 100644 src/MaksIT.ClusterConsole.Shared/PodMetricsAggregate.cs create mode 100644 src/MaksIT.ClusterConsole.Tests/KubernetesApiRetryTests.cs create mode 100644 src/MaksIT.ClusterConsole.Tests/PodMetricsAggregateTests.cs 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 -![Line Coverage](https://img.shields.io/badge/Line%20Coverage-53.8%25-yellowgreen) -![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.4%25-yellowgreen) -![Method Coverage](https://img.shields.io/badge/Method%20Coverage-61.5%25-green) +![Line Coverage](https://img.shields.io/badge/Line%20Coverage-54.2%25-yellowgreen) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.7%25-yellowgreen) +![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.1%25-green) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![License](https://img.shields.io/badge/License-Apache%202.0-blue) ![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-0078D6) 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 @@ + + + + + + + + + + + + + + + + + +