diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c6e76d9 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/MaksIT.ClusterConsole.UI/bin/Debug/net10.0/MaksIT.ClusterConsole.UI.dll", + "args": [], + "cwd": "${workspaceFolder}/src/MaksIT.ClusterConsole.UI", + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a649527 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/MaksIT.ClusterConsole.UI/MaksIT.ClusterConsole.UI.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/MaksIT.ClusterConsole.UI/MaksIT.ClusterConsole.UI.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/MaksIT.ClusterConsole.UI/MaksIT.ClusterConsole.UI.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea3e14..040a8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.8.0] - 2026-09-26 + +### Added + +- Help → Logs shows the app log and crash reports, and an unhandled error opens a copyable debug window instead of closing the app. +- YAML view, volume files, and ConfigMap/Secret values highlight as JSON or YAML. A data key or file name uses its extension (`.json`, `.yaml`, `.yml`); a key without one stays plain. The resource YAML tab is always YAML. +- Services table includes Status: Active when the load-balancer address is assigned, Pending when none is set, and Unreachable when a BGP or requested address is not the one in `status.loadBalancer.ingress` (or Cilium IPAM reports it unsatisfied). + +### Fixed + +- Applying YAML for a LoadBalancer service (for example a Cilium BGP service whose `last-applied-configuration` annotation contains a newline) no longer crashes. Multiline and quoted strings are written as valid YAML. + ## [0.7.0] - 2026-09-12 ### Added diff --git a/README.md b/README.md index d16c3ff..81b0a2b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # MaksIT.ClusterConsole -![Line Coverage](https://img.shields.io/badge/Line%20Coverage-54.4%25-yellowgreen) -![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.8%25-yellowgreen) +![Line Coverage](https://img.shields.io/badge/Line%20Coverage-55%25-yellowgreen) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-47.1%25-yellowgreen) ![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.4%25-green) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![License](https://img.shields.io/badge/License-Apache%202.0-blue) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 9aa53ab..d7bae65 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.7.0 + 0.8.0 MaksIT.ClusterConsole MaksIT.ClusterConsole diff --git a/src/MaksIT.ClusterConsole.Client/KubeQuantity.cs b/src/MaksIT.ClusterConsole.Client/KubeQuantity.cs index 4f28a29..2042d0f 100644 --- a/src/MaksIT.ClusterConsole.Client/KubeQuantity.cs +++ b/src/MaksIT.ClusterConsole.Client/KubeQuantity.cs @@ -45,7 +45,7 @@ public static class KubeQuantity { public static string FormatCores(double cores) { if (cores < 1) - return $"{cores * 1000:0}m"; + return (cores * 1000).ToString("0", CultureInfo.InvariantCulture) + "m"; return cores.ToString("0.##", CultureInfo.InvariantCulture); } @@ -56,20 +56,20 @@ public static class KubeQuantity { const double gi = 1024d * 1024 * 1024; const double mi = 1024d * 1024; if (bytes >= gi) - return $"{bytes / gi:0.##} GiB"; + return (bytes / gi).ToString("0.##", CultureInfo.InvariantCulture) + " GiB"; if (bytes >= mi) - return $"{bytes / mi:0.##} MiB"; - return $"{bytes} B"; + return (bytes / mi).ToString("0.##", CultureInfo.InvariantCulture) + " MiB"; + return bytes.ToString(CultureInfo.InvariantCulture) + " B"; } public static string FormatBytesCompact(long bytes) { const double gi = 1024d * 1024 * 1024; const double mi = 1024d * 1024; if (bytes >= gi) - return $"{bytes / gi:0.0}GiB"; + return (bytes / gi).ToString("0.0", CultureInfo.InvariantCulture) + "GiB"; if (bytes >= mi) - return $"{bytes / mi:0.0}MiB"; - return $"{bytes}B"; + return (bytes / mi).ToString("0.0", CultureInfo.InvariantCulture) + "MiB"; + return bytes.ToString(CultureInfo.InvariantCulture) + "B"; } public static string FormatMegabytes(long bytes) { @@ -78,7 +78,7 @@ public static class KubeQuantity { if (megabytes < 1) return "<1 MB"; - return $"{Math.Round(megabytes):0} MB"; + return Math.Round(megabytes).ToString("0", CultureInfo.InvariantCulture) + " MB"; } public static string FormatMemoryQuantity(long bytes) { diff --git a/src/MaksIT.ClusterConsole.Shared/AppLog.cs b/src/MaksIT.ClusterConsole.Shared/AppLog.cs new file mode 100644 index 0000000..23fbfe7 --- /dev/null +++ b/src/MaksIT.ClusterConsole.Shared/AppLog.cs @@ -0,0 +1,29 @@ +namespace MaksIT.ClusterConsole.Shared; + + +public static class AppLog { + private static readonly Lock Gate = new(); + + public static string Directory() => + UserSettingsPath.LogsDirectory(ConfigurationFileService.ProductFolder); + + public static string FilePath() => + Path.Combine(Directory(), "app.log"); + + public static void Write(string message) { + try { + System.IO.Directory.CreateDirectory(Directory()); + var line = DateTimeOffset.UtcNow.ToString("u") + " " + (message ?? "").TrimEnd(); + lock (Gate) + File.AppendAllText(FilePath(), line + Environment.NewLine); + } + catch { + } + } + + public static void Write(Exception exception) { + if (exception is null) + return; + Write(ErrorReport.Format(exception)); + } +} diff --git a/src/MaksIT.ClusterConsole.Shared/ErrorReport.cs b/src/MaksIT.ClusterConsole.Shared/ErrorReport.cs new file mode 100644 index 0000000..dad115b --- /dev/null +++ b/src/MaksIT.ClusterConsole.Shared/ErrorReport.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; + + +namespace MaksIT.ClusterConsole.Shared; + + +/// Formats unhandled exceptions for a copyable dialog and a crash log file. +public static class ErrorReport { + public static string Capture(Exception exception) { + ArgumentNullException.ThrowIfNull(exception); + var body = Format(exception); + AppLog.Write(body); + var path = TryWrite(body); + if (string.IsNullOrWhiteSpace(path)) + return body; + return body + Environment.NewLine + Environment.NewLine + "Log: " + path; + } + + public static string Format(Exception exception) { + ArgumentNullException.ThrowIfNull(exception); + var text = new StringBuilder(); + text.AppendLine("Cluster Console " + Version()); + text.AppendLine("MaksIT"); + text.AppendLine(DateTimeOffset.UtcNow.ToString("u")); + text.AppendLine(RuntimeInformation.OSDescription); + text.AppendLine(RuntimeInformation.FrameworkDescription); + text.AppendLine((Environment.Is64BitProcess ? "64-bit" : "32-bit") + " process"); + text.AppendLine(); + AppendException(text, exception); + return text.ToString().TrimEnd(); + } + + public static string? TryWrite(string report) { + try { + var dir = AppLog.Directory(); + System.IO.Directory.CreateDirectory(dir); + var name = "crash-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Environment.ProcessId + ".txt"; + var path = Path.Combine(dir, name); + File.WriteAllText(path, report ?? ""); + return path; + } + catch { + return null; + } + } + + private static string Version() { + var version = Assembly.GetEntryAssembly()?.GetName().Version; + return version is null ? "" : version.ToString(); + } + + private static void AppendException(StringBuilder text, Exception exception) { + var seen = new HashSet(); + var current = exception; + var depth = 0; + while (current is not null && seen.Add(current)) { + if (depth > 0) + text.AppendLine().AppendLine("--- inner ---"); + text.AppendLine(current.GetType().FullName); + text.AppendLine(current.Message); + if (!string.IsNullOrWhiteSpace(current.StackTrace)) + text.AppendLine(current.StackTrace); + if (current is AggregateException aggregate) { + foreach (var inner in aggregate.InnerExceptions) { + if (inner is null || ReferenceEquals(inner, current.InnerException) || !seen.Add(inner)) + continue; + text.AppendLine().AppendLine("--- aggregate ---"); + text.AppendLine(inner.ToString()); + } + } + + current = current.InnerException; + depth++; + } + } +} diff --git a/src/MaksIT.ClusterConsole.Shared/JsonPath.cs b/src/MaksIT.ClusterConsole.Shared/JsonPath.cs index 2383ff1..ec96491 100644 --- a/src/MaksIT.ClusterConsole.Shared/JsonPath.cs +++ b/src/MaksIT.ClusterConsole.Shared/JsonPath.cs @@ -18,6 +18,9 @@ public static class JsonPath { if (path == "service.externalIP") return ServiceExternalIp(root as JsonObject); + if (path == "service.status") + return ServiceStatus(root as JsonObject); + if (path == "pv.claim") return VolumeClaim(root as JsonObject); @@ -100,26 +103,45 @@ public static class JsonPath { return string.IsNullOrWhiteSpace(ns) ? name : $"{ns}/{name}"; } + public static string ServiceStatus(JsonObject? item) { + if (item is null) + return string.Empty; + + var type = Text(item["spec"]?["type"]); + if (!type.Equals("LoadBalancer", StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrWhiteSpace(type) ? string.Empty : "Active"; + + var requested = RequestedLoadBalancerIps(item); + var assigned = AssignedLoadBalancerIps(item); + var requestedMissing = requested.Any(ip => !assigned.Contains(ip, StringComparer.OrdinalIgnoreCase)); + if (requested.Count > 0 && (requestedMissing || !IpamSatisfied(item))) + return "Unreachable"; + if (assigned.Count == 0) + return "Pending"; + + return "Active"; + } + public static string ServiceExternalIp(JsonObject? item) { if (item is null) return string.Empty; var ips = new List(); - AddUnique(ips, item["spec"]?["loadBalancerIP"]?.GetValue()); + AddUnique(ips, Text(item["spec"]?["loadBalancerIP"])); if (item["spec"]?["externalIPs"] is JsonArray external) { foreach (var ip in external) - AddUnique(ips, ip?.GetValue()); + AddUnique(ips, Text(ip)); } if (item["status"]?["loadBalancer"]?["ingress"] is JsonArray ingress) { foreach (var entry in ingress.OfType()) { - AddUnique(ips, entry["ip"]?.GetValue()); - AddUnique(ips, entry["hostname"]?.GetValue()); + AddUnique(ips, Text(entry["ip"])); + AddUnique(ips, Text(entry["hostname"])); } } - var annotation = item["metadata"]?["annotations"]?["lbipam.cilium.io/ips"]?.GetValue(); + var annotation = Text(item["metadata"]?["annotations"]?["lbipam.cilium.io/ips"]); if (!string.IsNullOrWhiteSpace(annotation)) { foreach (var ip in annotation.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) AddUnique(ips, ip); @@ -128,6 +150,46 @@ public static class JsonPath { return string.Join(",", ips); } + private static List RequestedLoadBalancerIps(JsonObject item) { + var ips = new List(); + AddUnique(ips, Text(item["spec"]?["loadBalancerIP"])); + var annotation = Text(item["metadata"]?["annotations"]?["lbipam.cilium.io/ips"]); + if (!string.IsNullOrWhiteSpace(annotation)) { + foreach (var ip in annotation.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + AddUnique(ips, ip); + } + + return ips; + } + + private static List AssignedLoadBalancerIps(JsonObject item) { + var ips = new List(); + if (item["status"]?["loadBalancer"]?["ingress"] is JsonArray ingress) { + foreach (var entry in ingress.OfType()) { + AddUnique(ips, Text(entry["ip"])); + AddUnique(ips, Text(entry["hostname"])); + } + } + + return ips; + } + + private static bool IpamSatisfied(JsonObject item) { + if (item["status"]?["conditions"] is not JsonArray conditions) + return true; + + foreach (var condition in conditions.OfType()) { + var type = Text(condition["type"]); + if (!type.Contains("ipam", StringComparison.OrdinalIgnoreCase) + || !type.Contains("satisfied", StringComparison.OrdinalIgnoreCase)) + continue; + + return Text(condition["status"]).Equals("True", StringComparison.OrdinalIgnoreCase); + } + + return true; + } + private static void AddUnique(List ips, string? value) { if (string.IsNullOrWhiteSpace(value)) return; diff --git a/src/MaksIT.ClusterConsole.Shared/MarkupSyntax.cs b/src/MaksIT.ClusterConsole.Shared/MarkupSyntax.cs new file mode 100644 index 0000000..75fe708 --- /dev/null +++ b/src/MaksIT.ClusterConsole.Shared/MarkupSyntax.cs @@ -0,0 +1,32 @@ +namespace MaksIT.ClusterConsole.Shared; + +public enum MarkupSyntax { + None, + Json, + Yaml +} + +public static class MarkupSyntaxDetector { + public static MarkupSyntax Detect(string? fileName, bool preferYaml = false) { + if (preferYaml) + return MarkupSyntax.Yaml; + + return FromFileName(fileName); + } + + public static MarkupSyntax FromFileName(string? fileName) { + var name = fileName?.Replace('\\', '/'); + var slash = name?.LastIndexOf('/') ?? -1; + if (slash >= 0) + name = name![(slash + 1)..]; + + if (name is null) + return MarkupSyntax.None; + if (name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + return MarkupSyntax.Json; + if (name.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)) + return MarkupSyntax.Yaml; + + return MarkupSyntax.None; + } +} diff --git a/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs b/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs index 6ef1ba4..dcedc83 100644 --- a/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs +++ b/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs @@ -217,7 +217,7 @@ public static class ResourceCatalog { D("mutatingwebhookconfigurations", "Mutating Webhooks", Config, "admissionregistration.k8s.io", "v1", "mutatingwebhookconfigurations", "MutatingWebhookConfiguration", false, named, crud, yamlTabs), D("validatingwebhookconfigurations", "Validating Webhooks", Config, "admissionregistration.k8s.io", "v1", "validatingwebhookconfigurations", "ValidatingWebhookConfiguration", false, named, crud, yamlTabs), D("services", "Services", Network, "", "v1", "services", "Service", true, - [..std, new("Type", "spec.type"), new("Cluster IP", "spec.clusterIP"), new("External IP", "service.externalIP"), new("Ports", "spec.ports")], + [..std, new("Type", "spec.type"), new("Status", "service.status"), new("Cluster IP", "spec.clusterIP"), new("External IP", "service.externalIP"), new("Ports", "spec.ports")], new ResourceActions(CanPortForward: true), serviceTabs), D("endpoints", "Endpoints", Network, "", "v1", "endpoints", "Endpoints", true, std, crud, yamlTabs), D("endpointslices", "Endpoint Slices", Network, "discovery.k8s.io", "v1", "endpointslices", "EndpointSlice", true, std, crud, yamlTabs), diff --git a/src/MaksIT.ClusterConsole.Shared/ResourceStatusPaint.cs b/src/MaksIT.ClusterConsole.Shared/ResourceStatusPaint.cs index 6c30984..78a4a79 100644 --- a/src/MaksIT.ClusterConsole.Shared/ResourceStatusPaint.cs +++ b/src/MaksIT.ClusterConsole.Shared/ResourceStatusPaint.cs @@ -21,7 +21,7 @@ public static class ResourceStatusPaint { if (Contains(value, "fail", "error", "backoff", "unhealthy", "denied")) return ResourceStatusTone.Error; - if (Matches(value, "Pending", "ContainerCreating", "PodInitializing", "Terminating", + if (Matches(value, "Pending", "Unreachable", "ContainerCreating", "PodInitializing", "Terminating", "Released", "Progressing", "Waiting", "Unknown", "Stopped", "NotReady", "Orphaned", "Missing")) return ResourceStatusTone.Warning; diff --git a/src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs b/src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs index 0f4c9f5..08a886a 100644 --- a/src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs +++ b/src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs @@ -13,7 +13,7 @@ public sealed record PortForwardTarget(string PodName, string Namespace, int Con public static class ServicePortForward { public static bool IsService(JsonObject? document) => - string.Equals(document?["kind"]?.GetValue(), "Service", StringComparison.OrdinalIgnoreCase); + string.Equals(JsonPath.Text(document?["kind"]), "Service", StringComparison.OrdinalIgnoreCase); public static int? DefaultPort(JsonObject? service) { var first = (service?["spec"]?["ports"] as JsonArray)?.OfType().FirstOrDefault(); diff --git a/src/MaksIT.ClusterConsole.Shared/UserSettingsPath.cs b/src/MaksIT.ClusterConsole.Shared/UserSettingsPath.cs index e581cc2..aa46dc0 100644 --- a/src/MaksIT.ClusterConsole.Shared/UserSettingsPath.cs +++ b/src/MaksIT.ClusterConsole.Shared/UserSettingsPath.cs @@ -9,9 +9,14 @@ namespace MaksIT.ClusterConsole.Shared; /// public static class UserSettingsPath { public static string Get(string product, string fileName = "settings.json") => + Path.Combine(ProductDirectory(product), fileName); + + public static string LogsDirectory(string product) => + Path.Combine(ProductDirectory(product), "logs"); + + public static string ProductDirectory(string product) => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MaksIT", - product, - fileName); + product); } diff --git a/src/MaksIT.ClusterConsole.Shared/VolumeListing.cs b/src/MaksIT.ClusterConsole.Shared/VolumeListing.cs index 6f11d81..6cbe46d 100644 --- a/src/MaksIT.ClusterConsole.Shared/VolumeListing.cs +++ b/src/MaksIT.ClusterConsole.Shared/VolumeListing.cs @@ -1,3 +1,6 @@ +using System.Globalization; + + namespace MaksIT.ClusterConsole.Shared; public sealed record VolumeEntry(string Name, bool IsDirectory, long Size) { @@ -9,11 +12,11 @@ public sealed record VolumeEntry(string Name, bool IsDirectory, long Size) { private static string FormatSize(long size) { if (size < 1024) - return $"{size} B"; + return size.ToString(CultureInfo.InvariantCulture) + " B"; if (size < 1024 * 1024) - return $"{size / 1024.0:0.#} KB"; + return (size / 1024.0).ToString("0.#", CultureInfo.InvariantCulture) + " KB"; - return $"{size / (1024.0 * 1024.0):0.#} MB"; + return (size / (1024.0 * 1024.0)).ToString("0.#", CultureInfo.InvariantCulture) + " MB"; } } diff --git a/src/MaksIT.ClusterConsole.Shared/YamlFormatter.cs b/src/MaksIT.ClusterConsole.Shared/YamlFormatter.cs index 8e57dd2..6e1934e 100644 --- a/src/MaksIT.ClusterConsole.Shared/YamlFormatter.cs +++ b/src/MaksIT.ClusterConsole.Shared/YamlFormatter.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -75,14 +76,68 @@ public static class YamlFormatter { if (value.TryGetValue(out var b)) return b ? "true" : "false"; if (value.TryGetValue(out var l)) - return l.ToString(); + return l.ToString(CultureInfo.InvariantCulture); if (value.TryGetValue(out var d)) - return d.ToString(System.Globalization.CultureInfo.InvariantCulture); + return d.ToString(CultureInfo.InvariantCulture); + if (value.TryGetValue(out var text)) + return Quote(text ?? ""); - var s = value.ToString(); - if (s.Contains(':') || s.Contains('#') || s.Contains('\n') || s.Length == 0) - return $"\"{s.Replace("\"", "\\\"")}\""; - return s; + return Quote(value.ToString()); + } + + private static string Quote(string text) { + if (!NeedsQuote(text)) + return text; + + var escaped = new StringBuilder(text.Length + 2); + escaped.Append('"'); + foreach (var c in text) { + switch (c) { + case '\\': + escaped.Append("\\\\"); + break; + case '"': + escaped.Append("\\\""); + break; + case '\n': + escaped.Append("\\n"); + break; + case '\r': + escaped.Append("\\r"); + break; + case '\t': + escaped.Append("\\t"); + break; + default: + escaped.Append(c); + break; + } + } + + escaped.Append('"'); + return escaped.ToString(); + } + + private static bool NeedsQuote(string text) { + if (text.Length == 0) + return true; + if (text is "true" or "false" or "null" or "yes" or "no" or "~") + return true; + if (char.IsWhiteSpace(text[0]) || char.IsWhiteSpace(text[^1])) + return true; + if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + return true; + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _) + && text.Contains('.') && !text.Contains("..")) + return true; + + foreach (var c in text) { + if (c is ':' or '#' or '\n' or '\r' or '\t' or '"' or '\\' + or '{' or '}' or '[' or ']' or ',' or '&' or '*' or '!' or '|' or '>' or '%' or '@' or '`' or '\'') + return true; + } + + return false; } private static JsonObject ParseSimpleYaml(string yaml) { diff --git a/src/MaksIT.ClusterConsole.Tests/MarkupSyntaxTests.cs b/src/MaksIT.ClusterConsole.Tests/MarkupSyntaxTests.cs new file mode 100644 index 0000000..4759e83 --- /dev/null +++ b/src/MaksIT.ClusterConsole.Tests/MarkupSyntaxTests.cs @@ -0,0 +1,28 @@ +using MaksIT.ClusterConsole.Shared; + + +namespace MaksIT.ClusterConsole.Tests; + +public class MarkupSyntaxTests { + [Fact] + public void Uses_key_or_file_extension() { + Assert.Equal(MarkupSyntax.Json, MarkupSyntaxDetector.FromFileName("app.json")); + Assert.Equal(MarkupSyntax.Json, MarkupSyntaxDetector.FromFileName("config/app.JSON")); + Assert.Equal(MarkupSyntax.Yaml, MarkupSyntaxDetector.FromFileName("values.yaml")); + Assert.Equal(MarkupSyntax.Yaml, MarkupSyntaxDetector.FromFileName("chart.yml")); + } + + [Fact] + public void Keys_without_a_markup_extension_stay_plain() { + Assert.Equal(MarkupSyntax.None, MarkupSyntaxDetector.FromFileName("password")); + Assert.Equal(MarkupSyntax.None, MarkupSyntaxDetector.FromFileName("config.txt")); + Assert.Equal(MarkupSyntax.None, MarkupSyntaxDetector.FromFileName(null)); + Assert.Equal(MarkupSyntax.None, MarkupSyntaxDetector.Detect("{\n \"a\": 1\n}")); + } + + [Fact] + public void Resource_yaml_tab_is_always_yaml() { + Assert.Equal(MarkupSyntax.Yaml, MarkupSyntaxDetector.Detect(null, preferYaml: true)); + Assert.Equal(MarkupSyntax.Yaml, MarkupSyntaxDetector.Detect("notes.txt", preferYaml: true)); + } +} diff --git a/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs b/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs index d11cd78..8d959bf 100644 --- a/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs +++ b/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs @@ -394,8 +394,38 @@ public class ResourceCatalogTests { Assert.NotNull(service); var row = ResourceRow.From(service, ResourceCatalog.Find("services")!); Assert.Equal("LoadBalancer", row.Cells["Type"]); + Assert.Equal("Active", row.Cells["Status"]); Assert.Equal("10.43.131.123", row.Cells["Cluster IP"]); Assert.Equal("172.16.0.11", row.Cells["External IP"]); Assert.Equal("5432", row.Cells["Ports"]); + + var pending = JsonNode.Parse(""" + { + "metadata": { "name": "web" }, + "spec": { "type": "LoadBalancer", "clusterIP": "10.43.1.2", "ports": [{ "port": 80 }] } + } + """) as JsonObject; + Assert.NotNull(pending); + Assert.Equal("Pending", ResourceRow.From(pending, ResourceCatalog.Find("services")!).Cells["Status"]); + + var unreachable = JsonNode.Parse(""" + { + "metadata": { + "name": "web", + "annotations": { "lbipam.cilium.io/ips": "172.16.0.99" } + }, + "spec": { "type": "LoadBalancer", "clusterIP": "10.43.1.3", "ports": [{ "port": 80 }] }, + "status": { + "conditions": [{ + "type": "io.cilium/lb-ipam-request-satisfied", + "status": "False", + "reason": "no_pool" + }] + } + } + """) as JsonObject; + Assert.NotNull(unreachable); + Assert.Equal("Unreachable", ResourceRow.From(unreachable, ResourceCatalog.Find("services")!).Cells["Status"]); + Assert.Equal("172.16.0.99", ResourceRow.From(unreachable, ResourceCatalog.Find("services")!).Cells["External IP"]); } } diff --git a/src/MaksIT.ClusterConsole.Tests/ResourceTableTests.cs b/src/MaksIT.ClusterConsole.Tests/ResourceTableTests.cs index 7053277..bf79c8d 100644 --- a/src/MaksIT.ClusterConsole.Tests/ResourceTableTests.cs +++ b/src/MaksIT.ClusterConsole.Tests/ResourceTableTests.cs @@ -10,6 +10,7 @@ public class ResourceTableTests { [InlineData("Ready", ResourceStatusTone.Healthy)] [InlineData("deployed", ResourceStatusTone.Healthy)] [InlineData("Pending", ResourceStatusTone.Warning)] + [InlineData("Unreachable", ResourceStatusTone.Warning)] [InlineData("Unknown", ResourceStatusTone.Warning)] [InlineData("Failed", ResourceStatusTone.Error)] [InlineData("CrashLoopBackOff", ResourceStatusTone.Error)] diff --git a/src/MaksIT.ClusterConsole.Tests/YamlApplyTests.cs b/src/MaksIT.ClusterConsole.Tests/YamlApplyTests.cs new file mode 100644 index 0000000..5ebb372 --- /dev/null +++ b/src/MaksIT.ClusterConsole.Tests/YamlApplyTests.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Nodes; +using MaksIT.ClusterConsole.Shared; + + +namespace MaksIT.ClusterConsole.Tests; + +public class YamlApplyTests { + [Fact] + public void Bgp_service_yaml_round_trips_and_lists() { + var original = JsonNode.Parse(""" + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "postgresql-postgres-bgp", + "namespace": "postgresql", + "resourceVersion": "42", + "annotations": { + "lbipam.cilium.io/ips": "172.16.0.11", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"name\":\"postgresql-postgres-bgp\"}}\n" + } + }, + "spec": { + "type": "LoadBalancer", + "clusterIP": "10.43.131.123", + "clusterIPs": ["10.43.131.123"], + "loadBalancerIP": "172.16.0.11", + "externalTrafficPolicy": "Cluster", + "ipFamilies": ["IPv4"], + "ipFamilyPolicy": "SingleStack", + "ports": [{ "name": "tcp-postgresql", "port": 5432, "protocol": "TCP", "targetPort": 5432 }], + "selector": { "app": "postgres" } + }, + "status": { + "loadBalancer": { "ingress": [{ "ip": "172.16.0.11", "ipMode": "VIP" }] } + } + } + """) as JsonObject; + + Assert.NotNull(original); + var yaml = YamlFormatter.FromJson(ResourceDocument.PrepareForEdit(original)); + var parsed = YamlFormatter.ToJsonObject(yaml); + Assert.NotNull(parsed); + Assert.Equal("Service", parsed["kind"]?.GetValue()); + Assert.Equal("postgresql-postgres-bgp", parsed["metadata"]?["name"]?.GetValue()); + Assert.Equal("42", parsed["metadata"]?["resourceVersion"]?.GetValue()); + Assert.Contains("postgresql-postgres-bgp", parsed["metadata"]?["annotations"]?["kubectl.kubernetes.io/last-applied-configuration"]?.GetValue()); + var row = ResourceRow.From(parsed, ResourceCatalog.Find("services")!); + Assert.Equal("172.16.0.11", row.Cells["External IP"]); + Assert.Equal("5432", row.Cells["Ports"]); + } +} diff --git a/src/MaksIT.ClusterConsole.UI/App.axaml b/src/MaksIT.ClusterConsole.UI/App.axaml index 4b9db51..f7a6b4c 100644 --- a/src/MaksIT.ClusterConsole.UI/App.axaml +++ b/src/MaksIT.ClusterConsole.UI/App.axaml @@ -6,6 +6,7 @@ + diff --git a/src/MaksIT.ClusterConsole.UI/Controls/SyntaxTextEditor.cs b/src/MaksIT.ClusterConsole.UI/Controls/SyntaxTextEditor.cs new file mode 100644 index 0000000..f2ea87f --- /dev/null +++ b/src/MaksIT.ClusterConsole.UI/Controls/SyntaxTextEditor.cs @@ -0,0 +1,123 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.TextMate; +using MaksIT.ClusterConsole.Shared; +using TextMateSharp.Grammars; + + +namespace MaksIT.ClusterConsole.UI.Controls; + +public class SyntaxTextEditor : TextEditor { + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text), "", defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty FileNameProperty = + AvaloniaProperty.Register(nameof(FileName)); + + public static readonly StyledProperty PreferYamlProperty = + AvaloniaProperty.Register(nameof(PreferYaml)); + + static readonly RegistryOptions Registry = new(ThemeName.DarkPlus); + + protected override Type StyleKeyOverride => typeof(TextEditor); + + TextMate.Installation? _textMate; + string? _scope; + bool _updating; + + public SyntaxTextEditor() { + FontFamily = new FontFamily("Cascadia Mono, Consolas, Ubuntu Mono, monospace"); + ShowLineNumbers = true; + Options.EnableHyperlinks = false; + Document.Changed += OnDocumentChanged; + } + + public new string Text { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? FileName { + get => GetValue(FileNameProperty); + set => SetValue(FileNameProperty, value); + } + + public bool PreferYaml { + get => GetValue(PreferYamlProperty); + set => SetValue(PreferYamlProperty, value); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { + base.OnAttachedToVisualTree(e); + _textMate ??= this.InstallTextMate(Registry); + ApplySyntax(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { + base.OnPropertyChanged(change); + if (change.Property == TextProperty) + SetDocumentText(change.GetNewValue() ?? ""); + else if (change.Property == FileNameProperty || change.Property == PreferYamlProperty) + ApplySyntax(); + else if (change.Property == DocumentProperty) { + if (change.OldValue is TextDocument oldDocument) + oldDocument.Changed -= OnDocumentChanged; + if (change.NewValue is TextDocument newDocument) + newDocument.Changed += OnDocumentChanged; + } + } + + void SetDocumentText(string value) { + if (Document.Text == value) + return; + + _updating = true; + Document.Text = value; + _updating = false; + } + + void OnDocumentChanged(object? sender, DocumentChangeEventArgs e) { + if (_updating) + return; + + var text = Document.Text; + if (!string.Equals(Text, text, StringComparison.Ordinal)) + Text = text; + } + + void ApplySyntax() { + if (_textMate is null) + return; + + var syntax = MarkupSyntaxDetector.Detect(FileName, PreferYaml); + var scope = ScopeFor(syntax); + if (string.Equals(_scope, scope, StringComparison.Ordinal)) + return; + + _scope = scope; + if (scope is null) { + _textMate.Dispose(); + _textMate = this.InstallTextMate(Registry); + return; + } + + _textMate.SetGrammar(scope); + } + + static string? ScopeFor(MarkupSyntax syntax) { + var extension = syntax switch { + MarkupSyntax.Json => ".json", + MarkupSyntax.Yaml => ".yaml", + _ => null + }; + if (extension is null) + return null; + + var language = Registry.GetLanguageByExtension(extension); + return language is null ? null : Registry.GetScopeByLanguageId(language.Id); + } +} diff --git a/src/MaksIT.ClusterConsole.UI/Dialogs/ErrorDialog.cs b/src/MaksIT.ClusterConsole.UI/Dialogs/ErrorDialog.cs new file mode 100644 index 0000000..9fccf10 --- /dev/null +++ b/src/MaksIT.ClusterConsole.UI/Dialogs/ErrorDialog.cs @@ -0,0 +1,146 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using MaksIT.ClusterConsole.Shared; +using MaksIT.ClusterConsole.UI.ViewModels; +using MaksIT.ClusterConsole.UI.Windows; + + +namespace MaksIT.ClusterConsole.UI.Dialogs; + + +internal static class ErrorDialog { + private static int _open; + + public static void Report(Exception? exception) => + Present(exception, wait: false); + + public static void ReportBlocking(Exception? exception) => + Present(exception, wait: true); + + private static void Present(Exception? exception, bool wait) { + if (exception is null) + return; + var report = ErrorReport.Capture(exception); + var dispatcher = TryDispatcher(); + if (dispatcher is null) { + if (wait) + ShowStandalone(report); + return; + } + + if (dispatcher.CheckAccess()) { + if (wait) + ShowUntilClosed(report); + else + _ = ShowAsync(report); + return; + } + + if (wait) + dispatcher.Invoke(() => ShowUntilClosed(report)); + else + dispatcher.Post(() => _ = ShowAsync(report)); + } + + private static Dispatcher? TryDispatcher() { + try { + return Dispatcher.UIThread; + } + catch { + return null; + } + } + + private static async Task ShowAsync(string report) { + if (Interlocked.Exchange(ref _open, 1) != 0) + return; + try { + var window = Create(report); + var owner = ActiveWindow(); + if (owner is { IsVisible: true }) { + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + await window.ShowDialog(owner); + return; + } + + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + var closed = new TaskCompletionSource(); + window.Closed += (_, _) => closed.TrySetResult(); + window.Show(); + await closed.Task; + } + catch { + } + finally { + Interlocked.Exchange(ref _open, 0); + } + } + + private static void ShowUntilClosed(string report) { + if (Interlocked.Exchange(ref _open, 1) != 0) + return; + try { + var window = Create(report); + var owner = ActiveWindow(); + var closed = false; + window.Closed += (_, _) => closed = true; + if (owner is { IsVisible: true }) { + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + window.Show(owner); + } + else { + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + window.Show(); + } + + var dispatcher = Dispatcher.UIThread; + while (!closed) + dispatcher.RunJobs(); + } + catch { + } + finally { + Interlocked.Exchange(ref _open, 0); + } + } + + private static void ShowStandalone(string report) { + if (Interlocked.Exchange(ref _open, 1) != 0) + return; + try { + AppBuilder.Configure() + .UsePlatformDetect() + .AfterSetup(builder => { + if (builder.Instance?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life) + return; + life.ShutdownMode = ShutdownMode.OnMainWindowClose; + life.MainWindow = Create(report); + }) + .StartWithClassicDesktopLifetime([]); + } + catch { + } + finally { + Interlocked.Exchange(ref _open, 0); + } + } + + private static ErrorWindow Create(string report) => + new() { + DataContext = new ErrorReportViewModel(report), + WindowStartupLocation = WindowStartupLocation.CenterScreen + }; + + private static Window? ActiveWindow() { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life) + return null; + foreach (var window in life.Windows) { + if (window.IsActive && window is not ErrorWindow) + return window; + } + + return life.MainWindow is ErrorWindow ? null : life.MainWindow; + } +} diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml index 7e5bcd0..c6b45d5 100644 --- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml +++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml @@ -13,6 +13,11 @@ MinWidth="960" MinHeight="640"> + + + + + @@ -616,7 +621,7 @@