(feature): live usage metrics and stabler cluster API lists

This commit is contained in:
Maksym Sadovnychyy 2026-08-22 12:41:20 +02:00
parent bfe49c4603
commit fee5ca8249
25 changed files with 854 additions and 45 deletions

View File

@ -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

View File

@ -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)

View File

@ -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>

View File

@ -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) {

View File

@ -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,

View File

@ -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) {

View File

@ -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 = "";

View 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.");
}
}

View File

@ -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;

View File

@ -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)

View 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);
}
}

View File

@ -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)

View 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;
}
}

View File

@ -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")],

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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 }
};
}
}

View File

@ -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]

View File

@ -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(

View 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);
}
}

View File

@ -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);
}
}

View 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 }
};
}
}

View File

@ -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]

View File

@ -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 {

View File

@ -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" />