mirror of
https://github.com/MAKS-IT-COM/maksit-cluster-console.git
synced 2026-09-30 00:38:10 +02:00
(feature): live usage metrics and stabler cluster API lists
This commit is contained in:
parent
bfe49c4603
commit
fee5ca8249
20
CHANGELOG.md
20
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
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# MaksIT.ClusterConsole
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.4.2</Version>
|
||||
<Version>0.5.0</Version>
|
||||
<Product>MaksIT.ClusterConsole</Product>
|
||||
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
@ -500,6 +500,22 @@ public sealed class ClusterSession : IClusterSession {
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<double>> 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<double>.Ok(cpu);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
return KubernetesResult.Map<double>(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<IReadOnlyDictionary<string, ResourceMetrics>>> GetPodMetricsAsync(
|
||||
string? @namespace,
|
||||
CancellationToken cancellationToken = default) {
|
||||
@ -803,9 +819,11 @@ public sealed class ClusterSession : IClusterSession {
|
||||
var listed = new List<JsonObject>();
|
||||
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<JsonObject>()) {
|
||||
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) {
|
||||
|
||||
@ -80,6 +80,8 @@ public interface IClusterSession : IDisposable {
|
||||
|
||||
Task<Result<ClusterUsage>> GetClusterUsageAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Result<double>> GetClusterCpuAllocatableAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Result> PatchContainerResourcesAsync(
|
||||
WorkloadContainerLimit row,
|
||||
string cpuLimit,
|
||||
|
||||
@ -145,6 +145,7 @@ public sealed class KubeConfigService : IKubeConfigService {
|
||||
return Result<KubernetesClientConfiguration>.NotFound(null, "kubeconfig not found");
|
||||
|
||||
var cfg = KubernetesClientConfiguration.BuildConfigFromConfigFile(path, contextName);
|
||||
cfg.DisableHttp2 = true;
|
||||
return Result<KubernetesClientConfiguration>.Ok(cfg);
|
||||
}
|
||||
catch (KubeConfigException ex) {
|
||||
|
||||
@ -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 = "";
|
||||
|
||||
44
src/MaksIT.ClusterConsole.Client/KubernetesApiRetry.cs
Normal file
44
src/MaksIT.ClusterConsole.Client/KubernetesApiRetry.cs
Normal file
@ -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<T> ExecuteAsync<T>(
|
||||
Func<CancellationToken, Task<T>> 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.");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<string, string> Cells(JsonObject item) {
|
||||
public static IReadOnlyDictionary<string, string> Cells(
|
||||
JsonObject item,
|
||||
ApplicationUsage? usage = null,
|
||||
double clusterCpuAllocatable = 0,
|
||||
bool metricsAvailable = false) {
|
||||
var labels = Labels(item);
|
||||
var kind = item["kind"]?.GetValue<string>() ?? "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<string, string> MetricTips(
|
||||
ApplicationUsage? usage,
|
||||
bool metricsAvailable) {
|
||||
if (!metricsAvailable || usage is null)
|
||||
return EmptyTips;
|
||||
|
||||
var tips = new Dictionary<string, string>(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<JsonObject> pods,
|
||||
IReadOnlyDictionary<string, ResourceMetrics> metrics,
|
||||
IReadOnlyDictionary<string, string>? deploymentByReplicaSet = null) {
|
||||
var cpu = 0d;
|
||||
long memory = 0;
|
||||
foreach (var pod in pods) {
|
||||
if (!BelongsToApplication(application, pod, deploymentByReplicaSet))
|
||||
continue;
|
||||
if (pod["status"]?["phase"]?.GetValue<string>() 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<string, string>? 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<JsonObject>()) {
|
||||
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<string, string> EmptyTips =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
private static JsonObject CollapseGroup(IGrouping<string, JsonObject> group) {
|
||||
var members = group
|
||||
.OrderBy(m => JsonPath.Name(m), StringComparer.OrdinalIgnoreCase)
|
||||
|
||||
65
src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.Metrics.cs
Normal file
65
src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.Metrics.cs
Normal file
@ -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<double> 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<Result<IReadOnlyList<ResourceRow>>> ListWorkloadsWithPodMetricsAsync(
|
||||
ResourceDescriptor descriptor,
|
||||
IReadOnlyList<JsonObject> 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<string, ResourceMetrics>)new Dictionary<string, ResourceMetrics>();
|
||||
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<IReadOnlyList<ResourceRow>>.Ok(rows);
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,7 @@ public sealed partial class ClusterWorkspace {
|
||||
|
||||
public async Task<Result> 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<IReadOnlyList<ResourceRow>>(null, false, listed.Messages, listed.StatusCode);
|
||||
|
||||
if (PodMetricsAggregate.WorkloadResourceIds.Contains(descriptor.Id))
|
||||
return await ListWorkloadsWithPodMetricsAsync(descriptor, listed.Value ?? [], @namespace, filter, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
IReadOnlyDictionary<string, ResourceMetrics>? 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<IReadOnlyList<ResourceRow>>(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<string, ResourceMetrics>)new Dictionary<string, ResourceMetrics>();
|
||||
var metricsAvailable = podMetrics.Count > 0;
|
||||
var clusterCpuAllocatable = allocatableResult;
|
||||
var deploymentByReplicaSet = replicaSetsResult.IsSuccess
|
||||
? PodMetricsAggregate.DeploymentByReplicaSet(replicaSetsResult.Value ?? [])
|
||||
: (IReadOnlyDictionary<string, string>)new Dictionary<string, string>();
|
||||
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)
|
||||
|
||||
70
src/MaksIT.ClusterConsole.Shared/PodMetricsAggregate.cs
Normal file
70
src/MaksIT.ClusterConsole.Shared/PodMetricsAggregate.cs
Normal file
@ -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<string> WorkloadResourceIds = new HashSet<string>(StringComparer.Ordinal) {
|
||||
"deployments",
|
||||
"statefulsets",
|
||||
"daemonsets"
|
||||
};
|
||||
|
||||
public static ApplicationUsage SumForOwner(
|
||||
JsonObject owner,
|
||||
IEnumerable<JsonObject> pods,
|
||||
IReadOnlyDictionary<string, ResourceMetrics> metrics) {
|
||||
var cpu = 0d;
|
||||
long memory = 0;
|
||||
foreach (var pod in pods) {
|
||||
if (!ResourceOwnership.Owns(pod, owner))
|
||||
continue;
|
||||
if (pod["status"]?["phase"]?.GetValue<string>() 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<string, string> DeploymentByReplicaSet(IEnumerable<JsonObject> replicaSets) {
|
||||
var map = new Dictionary<string, string>(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<JsonObject>()
|
||||
.FirstOrDefault(o =>
|
||||
string.Equals(o["kind"]?.ToString(), "Deployment", StringComparison.Ordinal)
|
||||
&& o["controller"]?.GetValue<bool?>() == true)
|
||||
?["name"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(deploy))
|
||||
map[$"{ns}/{name}"] = deploy;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@ -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")],
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,12 +18,21 @@ public sealed class ResourceRow : INotifyPropertyChanged {
|
||||
|
||||
public required IReadOnlyDictionary<string, string> Cells { get; set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> CellTips { get; set; } =
|
||||
EmptyCellTips;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> EmptyCellTips =
|
||||
new Dictionary<string, string>(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<PodContainer>? 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<string, string> left, IReadOnlyDictionary<string, string> right) {
|
||||
if (ReferenceEquals(left, right))
|
||||
return true;
|
||||
|
||||
@ -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<string, ResourceMetrics> {
|
||||
["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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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(
|
||||
|
||||
27
src/MaksIT.ClusterConsole.Tests/KubernetesApiRetryTests.cs
Normal file
27
src/MaksIT.ClusterConsole.Tests/KubernetesApiRetryTests.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
129
src/MaksIT.ClusterConsole.Tests/PodMetricsAggregateTests.cs
Normal file
129
src/MaksIT.ClusterConsole.Tests/PodMetricsAggregateTests.cs
Normal file
@ -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<string, ResourceMetrics> {
|
||||
["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<string, string> { ["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<string, ResourceMetrics> {
|
||||
["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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<string, string> { ["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]
|
||||
|
||||
@ -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<ResourceRow> 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<ResourceRow> StatusCellTemplate() =>
|
||||
new((_, _) => {
|
||||
var text = new TextBlock {
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:MaksIT.ClusterConsole.UI.Controls">
|
||||
<Styles.Resources>
|
||||
<SolidColorBrush x:Key="DataGridGridLinesBrush" Color="#33383e" />
|
||||
<SolidColorBrush x:Key="DataGridColumnHeaderBackgroundBrush" Color="#1e2124" />
|
||||
<SolidColorBrush x:Key="DataGridColumnHeaderForegroundBrush" Color="#9aa0a6" />
|
||||
<SolidColorBrush x:Key="DataGridColumnHeaderHoveredBackgroundBrush" Color="#2a2e33" />
|
||||
<SolidColorBrush x:Key="DataGridColumnHeaderPressedBackgroundBrush" Color="#33383e" />
|
||||
<SolidColorBrush x:Key="DataGridRowHoveredBackgroundColor" Color="#2a2e33" />
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedBackgroundBrush" Color="#3d5afe" />
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedHoveredBackgroundBrush" Color="#4d6bff" />
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedUnfocusedBackgroundBrush" Color="#2f364d" />
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedHoveredUnfocusedBackgroundBrush" Color="#3a4258" />
|
||||
</Styles.Resources>
|
||||
<Style Selector="Window">
|
||||
<Setter Property="Background" Value="#1e2124" />
|
||||
<Setter Property="Foreground" Value="#e6e6e6" />
|
||||
@ -188,10 +200,35 @@
|
||||
</Style>
|
||||
<Style Selector="DataGrid">
|
||||
<Setter Property="Background" Value="#25282c" />
|
||||
<Setter Property="RowBackground" Value="#25282c" />
|
||||
<Setter Property="GridLinesVisibility" Value="Horizontal" />
|
||||
<Setter Property="HorizontalGridLinesBrush" Value="#33383e" />
|
||||
<Setter Property="VerticalGridLinesBrush" Value="#33383e" />
|
||||
<Setter Property="HorizontalScrollBarVisibility" Value="Auto" />
|
||||
<Setter Property="controls:DataGridColumns.IndependentResize" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="DataGridRow:nth-child(even)">
|
||||
<Setter Property="Background" Value="#22262a" />
|
||||
</Style>
|
||||
<Style Selector="DataGridRow:pointerover">
|
||||
<Setter Property="Background" Value="#2a2e33" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader">
|
||||
<Setter Property="Background" Value="#1e2124" />
|
||||
<Setter Property="Foreground" Value="#9aa0a6" />
|
||||
<Setter Property="SeparatorBrush" Value="#33383e" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="MinHeight" Value="30" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader:pointerover /template/ Grid#PART_ColumnHeaderRoot">
|
||||
<Setter Property="Background" Value="#2a2e33" />
|
||||
</Style>
|
||||
<Style Selector="DataGrid /template/ Rectangle#PART_ColumnHeadersAndRowsSeparator">
|
||||
<Setter Property="Fill" Value="#33383e" />
|
||||
</Style>
|
||||
<Style Selector="DataGridRow /template/ Rectangle#PART_BottomGridLine">
|
||||
<Setter Property="Fill" Value="#33383e" />
|
||||
</Style>
|
||||
<Style Selector="Button.column-filter">
|
||||
<Setter Property="Padding" Value="4,0" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user