(feature): highlight yaml and json, show service reachability, and keep crash reports

This commit is contained in:
Maksym Sadovnychyy 2026-09-26 13:16:33 +02:00
parent d556514972
commit f74e01700f
51 changed files with 1346 additions and 854 deletions

25
.vscode/launch.json vendored Normal file
View File

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

41
.vscode/tasks.json vendored Normal file
View File

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

View File

@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [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 ## [0.7.0] - 2026-09-12
### Added ### Added

View File

@ -1,7 +1,7 @@
# MaksIT.ClusterConsole # MaksIT.ClusterConsole
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-54.4%25-yellowgreen) ![Line Coverage](https://img.shields.io/badge/Line%20Coverage-55%25-yellowgreen)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.8%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) ![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.4%25-green)
![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![.NET](https://img.shields.io/badge/.NET-10-512BD4)
![License](https://img.shields.io/badge/License-Apache%202.0-blue) ![License](https://img.shields.io/badge/License-Apache%202.0-blue)

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.7.0</Version> <Version>0.8.0</Version>
<Product>MaksIT.ClusterConsole</Product> <Product>MaksIT.ClusterConsole</Product>
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle> <AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
</PropertyGroup> </PropertyGroup>

View File

@ -45,7 +45,7 @@ public static class KubeQuantity {
public static string FormatCores(double cores) { public static string FormatCores(double cores) {
if (cores < 1) if (cores < 1)
return $"{cores * 1000:0}m"; return (cores * 1000).ToString("0", CultureInfo.InvariantCulture) + "m";
return cores.ToString("0.##", CultureInfo.InvariantCulture); return cores.ToString("0.##", CultureInfo.InvariantCulture);
} }
@ -56,20 +56,20 @@ public static class KubeQuantity {
const double gi = 1024d * 1024 * 1024; const double gi = 1024d * 1024 * 1024;
const double mi = 1024d * 1024; const double mi = 1024d * 1024;
if (bytes >= gi) if (bytes >= gi)
return $"{bytes / gi:0.##} GiB"; return (bytes / gi).ToString("0.##", CultureInfo.InvariantCulture) + " GiB";
if (bytes >= mi) if (bytes >= mi)
return $"{bytes / mi:0.##} MiB"; return (bytes / mi).ToString("0.##", CultureInfo.InvariantCulture) + " MiB";
return $"{bytes} B"; return bytes.ToString(CultureInfo.InvariantCulture) + " B";
} }
public static string FormatBytesCompact(long bytes) { public static string FormatBytesCompact(long bytes) {
const double gi = 1024d * 1024 * 1024; const double gi = 1024d * 1024 * 1024;
const double mi = 1024d * 1024; const double mi = 1024d * 1024;
if (bytes >= gi) if (bytes >= gi)
return $"{bytes / gi:0.0}GiB"; return (bytes / gi).ToString("0.0", CultureInfo.InvariantCulture) + "GiB";
if (bytes >= mi) if (bytes >= mi)
return $"{bytes / mi:0.0}MiB"; return (bytes / mi).ToString("0.0", CultureInfo.InvariantCulture) + "MiB";
return $"{bytes}B"; return bytes.ToString(CultureInfo.InvariantCulture) + "B";
} }
public static string FormatMegabytes(long bytes) { public static string FormatMegabytes(long bytes) {
@ -78,7 +78,7 @@ public static class KubeQuantity {
if (megabytes < 1) if (megabytes < 1)
return "<1 MB"; return "<1 MB";
return $"{Math.Round(megabytes):0} MB"; return Math.Round(megabytes).ToString("0", CultureInfo.InvariantCulture) + " MB";
} }
public static string FormatMemoryQuantity(long bytes) { public static string FormatMemoryQuantity(long bytes) {

View File

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

View File

@ -0,0 +1,78 @@
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
namespace MaksIT.ClusterConsole.Shared;
/// <summary>Formats unhandled exceptions for a copyable dialog and a crash log file.</summary>
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<Exception>();
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++;
}
}
}

View File

@ -18,6 +18,9 @@ public static class JsonPath {
if (path == "service.externalIP") if (path == "service.externalIP")
return ServiceExternalIp(root as JsonObject); return ServiceExternalIp(root as JsonObject);
if (path == "service.status")
return ServiceStatus(root as JsonObject);
if (path == "pv.claim") if (path == "pv.claim")
return VolumeClaim(root as JsonObject); return VolumeClaim(root as JsonObject);
@ -100,26 +103,45 @@ public static class JsonPath {
return string.IsNullOrWhiteSpace(ns) ? name : $"{ns}/{name}"; 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) { public static string ServiceExternalIp(JsonObject? item) {
if (item is null) if (item is null)
return string.Empty; return string.Empty;
var ips = new List<string>(); var ips = new List<string>();
AddUnique(ips, item["spec"]?["loadBalancerIP"]?.GetValue<string>()); AddUnique(ips, Text(item["spec"]?["loadBalancerIP"]));
if (item["spec"]?["externalIPs"] is JsonArray external) { if (item["spec"]?["externalIPs"] is JsonArray external) {
foreach (var ip in external) foreach (var ip in external)
AddUnique(ips, ip?.GetValue<string>()); AddUnique(ips, Text(ip));
} }
if (item["status"]?["loadBalancer"]?["ingress"] is JsonArray ingress) { if (item["status"]?["loadBalancer"]?["ingress"] is JsonArray ingress) {
foreach (var entry in ingress.OfType<JsonObject>()) { foreach (var entry in ingress.OfType<JsonObject>()) {
AddUnique(ips, entry["ip"]?.GetValue<string>()); AddUnique(ips, Text(entry["ip"]));
AddUnique(ips, entry["hostname"]?.GetValue<string>()); AddUnique(ips, Text(entry["hostname"]));
} }
} }
var annotation = item["metadata"]?["annotations"]?["lbipam.cilium.io/ips"]?.GetValue<string>(); var annotation = Text(item["metadata"]?["annotations"]?["lbipam.cilium.io/ips"]);
if (!string.IsNullOrWhiteSpace(annotation)) { if (!string.IsNullOrWhiteSpace(annotation)) {
foreach (var ip in annotation.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) foreach (var ip in annotation.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
AddUnique(ips, ip); AddUnique(ips, ip);
@ -128,6 +150,46 @@ public static class JsonPath {
return string.Join(",", ips); return string.Join(",", ips);
} }
private static List<string> RequestedLoadBalancerIps(JsonObject item) {
var ips = new List<string>();
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<string> AssignedLoadBalancerIps(JsonObject item) {
var ips = new List<string>();
if (item["status"]?["loadBalancer"]?["ingress"] is JsonArray ingress) {
foreach (var entry in ingress.OfType<JsonObject>()) {
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<JsonObject>()) {
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<string> ips, string? value) { private static void AddUnique(List<string> ips, string? value) {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
return; return;

View File

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

View File

@ -217,7 +217,7 @@ public static class ResourceCatalog {
D("mutatingwebhookconfigurations", "Mutating Webhooks", Config, "admissionregistration.k8s.io", "v1", "mutatingwebhookconfigurations", "MutatingWebhookConfiguration", false, named, crud, yamlTabs), 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("validatingwebhookconfigurations", "Validating Webhooks", Config, "admissionregistration.k8s.io", "v1", "validatingwebhookconfigurations", "ValidatingWebhookConfiguration", false, named, crud, yamlTabs),
D("services", "Services", Network, "", "v1", "services", "Service", true, 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), new ResourceActions(CanPortForward: true), serviceTabs),
D("endpoints", "Endpoints", Network, "", "v1", "endpoints", "Endpoints", true, std, crud, yamlTabs), 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), D("endpointslices", "Endpoint Slices", Network, "discovery.k8s.io", "v1", "endpointslices", "EndpointSlice", true, std, crud, yamlTabs),

View File

@ -21,7 +21,7 @@ public static class ResourceStatusPaint {
if (Contains(value, "fail", "error", "backoff", "unhealthy", "denied")) if (Contains(value, "fail", "error", "backoff", "unhealthy", "denied"))
return ResourceStatusTone.Error; return ResourceStatusTone.Error;
if (Matches(value, "Pending", "ContainerCreating", "PodInitializing", "Terminating", if (Matches(value, "Pending", "Unreachable", "ContainerCreating", "PodInitializing", "Terminating",
"Released", "Progressing", "Waiting", "Unknown", "Stopped", "NotReady", "Released", "Progressing", "Waiting", "Unknown", "Stopped", "NotReady",
"Orphaned", "Missing")) "Orphaned", "Missing"))
return ResourceStatusTone.Warning; return ResourceStatusTone.Warning;

View File

@ -13,7 +13,7 @@ public sealed record PortForwardTarget(string PodName, string Namespace, int Con
public static class ServicePortForward { public static class ServicePortForward {
public static bool IsService(JsonObject? document) => public static bool IsService(JsonObject? document) =>
string.Equals(document?["kind"]?.GetValue<string>(), "Service", StringComparison.OrdinalIgnoreCase); string.Equals(JsonPath.Text(document?["kind"]), "Service", StringComparison.OrdinalIgnoreCase);
public static int? DefaultPort(JsonObject? service) { public static int? DefaultPort(JsonObject? service) {
var first = (service?["spec"]?["ports"] as JsonArray)?.OfType<JsonObject>().FirstOrDefault(); var first = (service?["spec"]?["ports"] as JsonArray)?.OfType<JsonObject>().FirstOrDefault();

View File

@ -9,9 +9,14 @@ namespace MaksIT.ClusterConsole.Shared;
/// </summary> /// </summary>
public static class UserSettingsPath { public static class UserSettingsPath {
public static string Get(string product, string fileName = "settings.json") => 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( Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MaksIT", "MaksIT",
product, product);
fileName);
} }

View File

@ -1,3 +1,6 @@
using System.Globalization;
namespace MaksIT.ClusterConsole.Shared; namespace MaksIT.ClusterConsole.Shared;
public sealed record VolumeEntry(string Name, bool IsDirectory, long Size) { 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) { private static string FormatSize(long size) {
if (size < 1024) if (size < 1024)
return $"{size} B"; return size.ToString(CultureInfo.InvariantCulture) + " B";
if (size < 1024 * 1024) 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";
} }
} }

View File

@ -1,3 +1,4 @@
using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@ -75,14 +76,68 @@ public static class YamlFormatter {
if (value.TryGetValue<bool>(out var b)) if (value.TryGetValue<bool>(out var b))
return b ? "true" : "false"; return b ? "true" : "false";
if (value.TryGetValue<long>(out var l)) if (value.TryGetValue<long>(out var l))
return l.ToString(); return l.ToString(CultureInfo.InvariantCulture);
if (value.TryGetValue<double>(out var d)) if (value.TryGetValue<double>(out var d))
return d.ToString(System.Globalization.CultureInfo.InvariantCulture); return d.ToString(CultureInfo.InvariantCulture);
if (value.TryGetValue<string>(out var text))
return Quote(text ?? "");
var s = value.ToString(); return Quote(value.ToString());
if (s.Contains(':') || s.Contains('#') || s.Contains('\n') || s.Length == 0) }
return $"\"{s.Replace("\"", "\\\"")}\"";
return s; 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) { private static JsonObject ParseSimpleYaml(string yaml) {

View File

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

View File

@ -394,8 +394,38 @@ public class ResourceCatalogTests {
Assert.NotNull(service); Assert.NotNull(service);
var row = ResourceRow.From(service, ResourceCatalog.Find("services")!); var row = ResourceRow.From(service, ResourceCatalog.Find("services")!);
Assert.Equal("LoadBalancer", row.Cells["Type"]); Assert.Equal("LoadBalancer", row.Cells["Type"]);
Assert.Equal("Active", row.Cells["Status"]);
Assert.Equal("10.43.131.123", row.Cells["Cluster IP"]); Assert.Equal("10.43.131.123", row.Cells["Cluster IP"]);
Assert.Equal("172.16.0.11", row.Cells["External IP"]); Assert.Equal("172.16.0.11", row.Cells["External IP"]);
Assert.Equal("5432", row.Cells["Ports"]); 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"]);
} }
} }

View File

@ -10,6 +10,7 @@ public class ResourceTableTests {
[InlineData("Ready", ResourceStatusTone.Healthy)] [InlineData("Ready", ResourceStatusTone.Healthy)]
[InlineData("deployed", ResourceStatusTone.Healthy)] [InlineData("deployed", ResourceStatusTone.Healthy)]
[InlineData("Pending", ResourceStatusTone.Warning)] [InlineData("Pending", ResourceStatusTone.Warning)]
[InlineData("Unreachable", ResourceStatusTone.Warning)]
[InlineData("Unknown", ResourceStatusTone.Warning)] [InlineData("Unknown", ResourceStatusTone.Warning)]
[InlineData("Failed", ResourceStatusTone.Error)] [InlineData("Failed", ResourceStatusTone.Error)]
[InlineData("CrashLoopBackOff", ResourceStatusTone.Error)] [InlineData("CrashLoopBackOff", ResourceStatusTone.Error)]

View File

@ -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<string>());
Assert.Equal("postgresql-postgres-bgp", parsed["metadata"]?["name"]?.GetValue<string>());
Assert.Equal("42", parsed["metadata"]?["resourceVersion"]?.GetValue<string>());
Assert.Contains("postgresql-postgres-bgp", parsed["metadata"]?["annotations"]?["kubectl.kubernetes.io/last-applied-configuration"]?.GetValue<string>());
var row = ResourceRow.From(parsed, ResourceCatalog.Find("services")!);
Assert.Equal("172.16.0.11", row.Cells["External IP"]);
Assert.Equal("5432", row.Cells["Ports"]);
}
}

View File

@ -6,6 +6,7 @@
<Application.Styles> <Application.Styles>
<SimpleTheme /> <SimpleTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml" /> <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml" />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Simple/AvaloniaEdit.xaml" />
<StyleInclude Source="avares://MaksIT.ClusterConsole.UI/Themes/Dark.axaml" /> <StyleInclude Source="avares://MaksIT.ClusterConsole.UI/Themes/Dark.axaml" />
</Application.Styles> </Application.Styles>
</Application> </Application>

View File

@ -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<string> TextProperty =
AvaloniaProperty.Register<SyntaxTextEditor, string>(nameof(Text), "", defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<string?> FileNameProperty =
AvaloniaProperty.Register<SyntaxTextEditor, string?>(nameof(FileName));
public static readonly StyledProperty<bool> PreferYamlProperty =
AvaloniaProperty.Register<SyntaxTextEditor, bool>(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<string>() ?? "");
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);
}
}

View File

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

View File

@ -13,6 +13,11 @@
MinWidth="960" MinWidth="960"
MinHeight="640"> MinHeight="640">
<DockPanel> <DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="Help">
<MenuItem Header="Logs" Click="OnLogsClick" />
</MenuItem>
</Menu>
<Border DockPanel.Dock="Bottom" Padding="8,6" Background="#15171a"> <Border DockPanel.Dock="Bottom" Padding="8,6" Background="#15171a">
<TextBlock Text="{Binding Status}" Classes="hint" VerticalAlignment="Center" /> <TextBlock Text="{Binding Status}" Classes="hint" VerticalAlignment="Center" />
</Border> </Border>
@ -616,7 +621,7 @@
<Button Content="Apply YAML" Command="{Binding ActivePage.ApplyYamlCommand}" IsVisible="{Binding ActivePage.CanApplyYaml}" /> <Button Content="Apply YAML" Command="{Binding ActivePage.ApplyYamlCommand}" IsVisible="{Binding ActivePage.CanApplyYaml}" />
<TextBlock Text="Unsaved YAML" Classes="hint" IsVisible="{Binding ActivePage.IsDirty}" VerticalAlignment="Center" /> <TextBlock Text="Unsaved YAML" Classes="hint" IsVisible="{Binding ActivePage.IsDirty}" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
<TextBox Text="{Binding ActivePage.YamlText}" AcceptsReturn="True" FontFamily="Consolas, monospace" TextWrapping="NoWrap" /> <controls:SyntaxTextEditor Text="{Binding ActivePage.YamlText}" PreferYaml="True" />
</DockPanel> </DockPanel>
</TabItem> </TabItem>
<TabItem Header="Data" IsVisible="{Binding ActivePage.IsDataEditor}"> <TabItem Header="Data" IsVisible="{Binding ActivePage.IsDataEditor}">
@ -631,12 +636,9 @@
Text="Value (multiline paste supported)" Text="Value (multiline paste supported)"
Classes="hint" Classes="hint"
Margin="0,0,0,4" /> Margin="0,0,0,4" />
<TextBox Text="{Binding ActivePage.SelectedDataEntry.Value}" <controls:SyntaxTextEditor Text="{Binding ActivePage.SelectedDataEntry.Value}"
AcceptsReturn="True" FileName="{Binding ActivePage.SelectedDataEntry.Key}"
TextWrapping="Wrap" IsEnabled="{Binding ActivePage.HasSelectedDataEntry}" />
FontFamily="Consolas, monospace"
IsEnabled="{Binding ActivePage.HasSelectedDataEntry}"
PlaceholderText="Select a key, then edit or paste its value here" />
</DockPanel> </DockPanel>
<DataGrid Name="DataEntriesGrid" <DataGrid Name="DataEntriesGrid"
ItemsSource="{Binding ActivePage.DataEntries}" ItemsSource="{Binding ActivePage.DataEntries}"

View File

@ -13,6 +13,7 @@ using MaksIT.ClusterConsole.Shared;
using MaksIT.ClusterConsole.UI.Controls; using MaksIT.ClusterConsole.UI.Controls;
using MaksIT.ClusterConsole.UI.Converters; using MaksIT.ClusterConsole.UI.Converters;
using MaksIT.ClusterConsole.UI.ViewModels; using MaksIT.ClusterConsole.UI.ViewModels;
using MaksIT.ClusterConsole.UI.Windows;
namespace MaksIT.ClusterConsole.UI; namespace MaksIT.ClusterConsole.UI;
@ -48,6 +49,9 @@ public partial class MainWindow : Window {
viewModel.VolumeFilesRequested += OpenVolumeFiles; viewModel.VolumeFilesRequested += OpenVolumeFiles;
} }
private void OnLogsClick(object? sender, RoutedEventArgs e) =>
_ = LogWindow.ShowAsync(this);
private void OpenVolumeFiles(VolumeFilesViewModel files) { private void OpenVolumeFiles(VolumeFilesViewModel files) {
var window = new VolumeFilesWindow(files); var window = new VolumeFilesWindow(files);
window.Show(this); window.Show(this);

View File

@ -15,9 +15,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.2" /> <PackageReference Include="Avalonia" Version="12.1.2" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" /> <PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
<PackageReference Include="Avalonia.Themes.Simple" Version="12.1.2" /> <PackageReference Include="Avalonia.Themes.Simple" Version="12.1.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" /> <PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="MaksIT.Core" Version="1.6.8" /> <PackageReference Include="MaksIT.Core" Version="1.6.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />

View File

@ -1,18 +1,48 @@
using Avalonia; using Avalonia;
using Avalonia.Logging; using Avalonia.Logging;
using Avalonia.Threading;
using MaksIT.ClusterConsole.UI.Dialogs;
namespace MaksIT.ClusterConsole.UI; namespace MaksIT.ClusterConsole.UI;
internal static class Program { internal static class Program {
[STAThread] [STAThread]
public static void Main(string[] args) => public static void Main(string[] args) {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandled;
TaskScheduler.UnobservedTaskException += OnUnobservedTask;
try {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
catch (Exception ex) {
ErrorDialog.ReportBlocking(ex);
}
}
// Linux: X11/XWayland. Avalonia 12.1.2 native Wayland still hangs on GNOME's // Linux: X11/XWayland. Avalonia 12.1.2 native Wayland still hangs on GNOME's
// xdg_toplevel.configure(0, 0) and never maps a window (GNOME app icon). // xdg_toplevel.configure(0, 0) and never maps a window (GNOME app icon).
public static AppBuilder BuildAvaloniaApp() => public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>() AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
.LogToTrace(LogEventLevel.Warning); .LogToTrace(LogEventLevel.Warning)
.AfterSetup(_ => Dispatcher.UIThread.UnhandledException += OnDispatcherUnhandled);
private static void OnDispatcherUnhandled(object? sender, DispatcherUnhandledExceptionEventArgs e) {
e.Handled = true;
ErrorDialog.Report(e.Exception);
}
private static void OnDomainUnhandled(object? sender, UnhandledExceptionEventArgs e) {
if (e.ExceptionObject is not Exception ex)
return;
if (e.IsTerminating)
ErrorDialog.ReportBlocking(ex);
else
ErrorDialog.Report(ex);
}
private static void OnUnobservedTask(object? sender, UnobservedTaskExceptionEventArgs e) {
e.SetObserved();
ErrorDialog.Report(e.Exception);
}
} }

View File

@ -22,6 +22,20 @@
<Setter Property="FontFamily" Value="Segoe UI, Ubuntu, sans-serif" /> <Setter Property="FontFamily" Value="Segoe UI, Ubuntu, sans-serif" />
<Setter Property="FontSize" Value="13" /> <Setter Property="FontSize" Value="13" />
</Style> </Style>
<Style Selector="controls|SyntaxTextEditor">
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Ubuntu Mono, monospace" />
<Setter Property="FontSize" Value="12" />
<Setter Property="ShowLineNumbers" Value="True" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Stretch" />
</Style>
<Style Selector="TextBox.error-dump">
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Ubuntu Mono, monospace" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Background" Value="#0E1620" />
<Setter Property="Foreground" Value="#E8F4FC" />
<Setter Property="CaretBrush" Value="#E8F4FC" />
</Style>
<Style Selector="TextBlock.hint"> <Style Selector="TextBlock.hint">
<Setter Property="Foreground" Value="#9aa0a6" /> <Setter Property="Foreground" Value="#9aa0a6" />
<Setter Property="FontSize" Value="12" /> <Setter Property="FontSize" Value="12" />

View File

@ -673,21 +673,27 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
if (_workspace.Session is null) if (_workspace.Session is null)
return; return;
var doc = YamlFormatter.ToJsonObject(YamlText); try {
if (doc is null) { var doc = YamlFormatter.ToJsonObject(YamlText);
_setStatus("YAML is empty or invalid."); if (doc is null) {
return; _setStatus("YAML is empty or invalid.");
return;
}
var applied = await _workspace.ApplyDocumentAsync(doc);
_setStatus(applied.IsSuccess ? "Applied YAML." : string.Join("; ", applied.Messages));
if (!applied.IsSuccess)
return;
IsDirty = false;
_detailsUid = null;
await RefreshRowsAsync();
await LoadDetailsAsync();
}
catch (Exception ex) {
AppLog.Write(ex);
_setStatus(ex.Message);
} }
var applied = await _workspace.ApplyDocumentAsync(doc);
_setStatus(applied.IsSuccess ? "Applied YAML." : string.Join("; ", applied.Messages));
if (!applied.IsSuccess)
return;
IsDirty = false;
_detailsUid = null;
await RefreshRowsAsync();
await LoadDetailsAsync();
} }
[RelayCommand] [RelayCommand]

View File

@ -0,0 +1,19 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace MaksIT.ClusterConsole.UI.ViewModels;
public sealed partial class ErrorReportViewModel : ObservableObject {
public ErrorReportViewModel(string report) {
Report = report ?? "";
}
public string Report { get; }
[ObservableProperty]
private string copyStatus = "";
public void MarkCopied() =>
CopyStatus = "Copied";
}

View File

@ -0,0 +1,73 @@
using CommunityToolkit.Mvvm.ComponentModel;
using MaksIT.ClusterConsole.Shared;
namespace MaksIT.ClusterConsole.UI.ViewModels;
public sealed partial class LogViewModel : ObservableObject {
public LogViewModel() {
Refresh();
}
public IReadOnlyList<string> Files { get; private set; } = [];
[ObservableProperty]
private string selectedFile = "";
[ObservableProperty]
private string report = "";
[ObservableProperty]
private string copyStatus = "";
public void Refresh() {
try {
var dir = AppLog.Directory();
Directory.CreateDirectory(dir);
Files = Directory.GetFiles(dir)
.OrderByDescending(File.GetLastWriteTimeUtc)
.Select(Path.GetFileName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Cast<string>()
.ToList();
}
catch {
Files = [];
}
OnPropertyChanged(nameof(Files));
if (Files.Count == 0) {
SelectedFile = "";
Report = "";
return;
}
if (string.IsNullOrWhiteSpace(SelectedFile) || !Files.Contains(SelectedFile, StringComparer.OrdinalIgnoreCase))
SelectedFile = Files[0];
else
LoadSelected();
}
partial void OnSelectedFileChanged(string value) =>
LoadSelected();
public void MarkCopied() =>
CopyStatus = "Copied";
private void LoadSelected() {
CopyStatus = "";
if (string.IsNullOrWhiteSpace(SelectedFile)) {
Report = "";
return;
}
try {
var path = Path.Combine(AppLog.Directory(), SelectedFile);
Report = File.Exists(path) ? File.ReadAllText(path) : "";
}
catch (Exception ex) {
Report = ex.Message;
}
}
}

View File

@ -0,0 +1,47 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MaksIT.ClusterConsole.UI.ViewModels"
x:Class="MaksIT.ClusterConsole.UI.Windows.ErrorWindow"
x:DataType="vm:ErrorReportViewModel"
Width="720"
Height="520"
MinWidth="480"
MinHeight="360"
CanResize="True"
ShowInTaskbar="True"
Topmost="True"
WindowStartupLocation="CenterOwner"
Icon="/Assets/icon.ico"
Title="Cluster Console">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Bottom"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,12,0,0">
<TextBlock Text="{Binding CopyStatus}"
Classes="hint"
VerticalAlignment="Center"
Margin="0,0,8,0" />
<Button Content="Copy"
Click="OnCopyClick" />
<Button Content="Close"
Padding="16,8"
Click="OnCloseClick"
IsDefault="True"
IsCancel="True" />
</StackPanel>
<TextBlock DockPanel.Dock="Top"
Text="Something went wrong. The details below are also saved under Help → Logs."
Classes="hint"
TextWrapping="Wrap"
Margin="0,0,0,12" />
<TextBox Text="{Binding Report}"
Classes="error-dump"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</DockPanel>
</Window>

View File

@ -0,0 +1,31 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using MaksIT.ClusterConsole.UI.ViewModels;
namespace MaksIT.ClusterConsole.UI.Windows;
public partial class ErrorWindow : Window {
public ErrorWindow() {
InitializeComponent();
}
private async void OnCopyClick(object? sender, RoutedEventArgs e) {
if (DataContext is not ErrorReportViewModel vm)
return;
var clipboard = Clipboard;
if (clipboard is null)
return;
try {
await clipboard.SetTextAsync(vm.Report);
vm.MarkCopied();
}
catch {
}
}
private void OnCloseClick(object? sender, RoutedEventArgs e) =>
Close();
}

View File

@ -0,0 +1,44 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MaksIT.ClusterConsole.UI.ViewModels"
x:Class="MaksIT.ClusterConsole.UI.Windows.LogWindow"
x:DataType="vm:LogViewModel"
Width="860"
Height="560"
MinWidth="560"
MinHeight="360"
CanResize="True"
WindowStartupLocation="CenterOwner"
Icon="/Assets/icon.ico"
Title="Logs">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Bottom"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,12,0,0">
<TextBlock Text="{Binding CopyStatus}"
Classes="hint"
VerticalAlignment="Center"
Margin="0,0,8,0" />
<Button Content="Copy"
Click="OnCopyClick" />
<Button Content="Close"
Padding="16,8"
Click="OnCloseClick"
IsDefault="True"
IsCancel="True" />
</StackPanel>
<ComboBox DockPanel.Dock="Top"
ItemsSource="{Binding Files}"
SelectedItem="{Binding SelectedFile}"
Margin="0,0,0,12" />
<TextBox Text="{Binding Report}"
Classes="error-dump"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</DockPanel>
</Window>

View File

@ -0,0 +1,39 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using MaksIT.ClusterConsole.UI.ViewModels;
namespace MaksIT.ClusterConsole.UI.Windows;
public partial class LogWindow : Window {
public LogWindow() {
InitializeComponent();
}
public static Task ShowAsync(Window owner) {
var window = new LogWindow {
DataContext = new LogViewModel(),
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
return window.ShowDialog(owner);
}
private async void OnCopyClick(object? sender, RoutedEventArgs e) {
if (DataContext is not LogViewModel vm)
return;
var clipboard = Clipboard;
if (clipboard is null)
return;
try {
await clipboard.SetTextAsync(vm.Report);
vm.MarkCopied();
}
catch {
}
}
private void OnCloseClick(object? sender, RoutedEventArgs e) =>
Close();
}

View File

@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MaksIT.ClusterConsole.UI.ViewModels" xmlns:vm="using:MaksIT.ClusterConsole.UI.ViewModels"
xmlns:shared="using:MaksIT.ClusterConsole.Shared" xmlns:shared="using:MaksIT.ClusterConsole.Shared"
xmlns:controls="using:MaksIT.ClusterConsole.UI.Controls"
x:Class="MaksIT.ClusterConsole.UI.VolumeFilesWindow" x:Class="MaksIT.ClusterConsole.UI.VolumeFilesWindow"
x:DataType="vm:VolumeFilesViewModel" x:DataType="vm:VolumeFilesViewModel"
Title="{Binding WindowTitle}" Title="{Binding WindowTitle}"
@ -60,11 +61,9 @@
Classes="hint" Classes="hint"
Margin="0,0,0,8" Margin="0,0,0,8"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<TextBox Text="{Binding EditorText}" <controls:SyntaxTextEditor Text="{Binding EditorText}"
AcceptsReturn="True" FileName="{Binding OpenFilePath}"
FontFamily="Consolas, Cascadia Mono, monospace" IsReadOnly="{Binding EditorReadOnly}" />
TextWrapping="NoWrap"
IsReadOnly="{Binding EditorReadOnly}" />
</DockPanel> </DockPanel>
</Grid> </Grid>
</DockPanel> </DockPanel>

View File

@ -1,139 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. -->
<Theme xmlns="http://wixtoolset.org/schemas/v4/thmutil">
<Font Id="0" Height="-12" Weight="500" Foreground="windowtext" Background="window">Segoe UI</Font>
<Font Id="1" Height="-24" Weight="500" Foreground="windowtext">Segoe UI</Font>
<Font Id="2" Height="-22" Weight="500" Foreground="graytext">Segoe UI</Font>
<Font Id="3" Height="-12" Weight="500" Foreground="windowtext" Background="window">Segoe UI</Font>
<Window Width="600" Height="450" HexStyle="100a0000" FontId="0" Caption="#(loc.Caption)">
<Page Name="Help">
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
<Label X="11" Y="80" Width="-11" Height="32" FontId="2" DisablePrefix="yes">#(loc.HelpHeader)</Label>
<Label X="11" Y="121" Width="-11" Height="-35" FontId="3" DisablePrefix="yes">#(loc.HelpText)</Label>
<Button Name="HelpCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.HelpCloseButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Loading">
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes" Visible="no" Name="CheckingForUpdatesLabel" />
</Page>
<Page Name="Install">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="32" FontId="2" DisablePrefix="yes">#(loc.InstallHeader)</Label>
<Label X="185" Y="91" Width="-11" Height="40" FontId="3" DisablePrefix="yes">
<Text Condition="WixStdBASuppressOptionsUI">#(loc.InstallMessage)</Text>
<Text Condition="NOT WixStdBASuppressOptionsUI">#(loc.InstallMessageOptions)</Text>
</Label>
<Label X="185" Y="138" Width="-11" Height="17" FontId="3">#(loc.OptionsLocationLabel)</Label>
<Editbox Name="InstallFolder" X="185" Y="160" Width="-91" Height="21" TabStop="yes" FontId="3" FileSystemAutoComplete="yes" />
<Button Name="InstallFolderBrowseButton" X="-11" Y="159" Width="75" Height="23" TabStop="yes" FontId="3">
<Text>#(loc.OptionsBrowseButton)</Text>
<BrowseDirectoryAction VariableName="InstallFolder" />
</Button>
<Checkbox Name="InstallDesktopShortcut" X="185" Y="192" Width="-11" Height="17" TabStop="yes" FontId="3">Place a shortcut on the desktop</Checkbox>
<Hypertext Name="EulaHyperlink" X="185" Y="-111" Width="-11" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallLicenseLinkText)</Hypertext>
<Label Name="InstallVersion" X="185" Y="-81" Width="-11" Height="17" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBAShowVersion">#(loc.InstallVersion)</Label>
<Checkbox Name="EulaAcceptCheckbox" X="185" Y="-51" Width="-11" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallAcceptCheckbox)</Checkbox>
<Button Name="InstallUpdateButton" X="11" Y="-11" Width="200" Height="23" TabStop="yes" FontId="0" EnableCondition="WixStdBAUpdateAvailable" HideWhenDisabled="yes">#(loc.UpdateButton)</Button>
<Button Name="OptionsButton" X="-171" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" VisibleCondition="NOT WixStdBASuppressOptionsUI">
<Text>#(loc.InstallOptionsButton)</Text>
<ChangePageAction Page="Options" />
</Button>
<Button Name="InstallButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.InstallInstallButton)</Button>
<Button Name="InstallCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.InstallCancelButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Options">
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
<Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.OptionsHeader)</Label>
<RadioButtons Name="WixStdBAScope">
<RadioButton X="11" Y="180" Height="20" Width="-11" Value="PerUser" EnableCondition="WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3" HideWhenDisabled="yes" FontId="3">#(loc.OptionsPerUserScopeText)</RadioButton>
<RadioButton X="11" Y="200" Height="20" Width="-11" Value="PerMachine" EnableCondition="WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3" HideWhenDisabled="yes" FontId="3">#(loc.OptionsPerMachineScopeText)</RadioButton>
</RadioButtons>
<Button Name="OptionsOkButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.OptionsOkButton)</Text>
<ChangePageAction Page="Install" />
</Button>
<Button Name="OptionsCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.OptionsCancelButton)</Text>
<ChangePageAction Page="Install" Cancel="yes" />
</Button>
</Page>
<Page Name="Progress">
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
<Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.ProgressHeader)</Label>
<Label X="11" Y="141" Width="70" Height="17" FontId="3" DisablePrefix="yes">#(loc.ProgressLabel)</Label>
<Label Name="OverallProgressPackageText" X="85" Y="141" Width="-11" Height="17" FontId="3" DisablePrefix="yes">#(loc.OverallProgressPackageText)</Label>
<Progressbar Name="OverallCalculatedProgressbar" X="11" Y="163" Width="-11" Height="20" />
<Button Name="ProgressCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">#(loc.ProgressCancelButton)</Button>
</Page>
<Page Name="Modify">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.ModifyHeader)</Label>
<Button Name="ModifyUpdateButton" X="11" Y="-11" Width="200" Height="23" TabStop="yes" FontId="0" EnableCondition="WixStdBAUpdateAvailable" HideWhenDisabled="yes">#(loc.UpdateButton)</Button>
<Button Name="RepairButton" X="-171" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.ModifyRepairButton)</Button>
<Button Name="UninstallButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.ModifyUninstallButton)</Button>
<Button Name="ModifyCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.ModifyCancelButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Success">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">
<Text>#(loc.SuccessHeader)</Text>
<Text Condition="WixBundleAction = 2">#(loc.SuccessLayoutHeader)</Text>
<Text Condition="WixBundleAction = 3">#(loc.SuccessUnsafeUninstallHeader)</Text>
<Text Condition="WixBundleAction = 4">#(loc.SuccessUninstallHeader)</Text>
<Text Condition="WixBundleAction = 5">#(loc.SuccessCacheHeader)</Text>
<Text Condition="WixBundleAction = 6">#(loc.SuccessInstallHeader)</Text>
<Text Condition="WixBundleAction = 7">#(loc.SuccessModifyHeader)</Text>
<Text Condition="WixBundleAction = 8">#(loc.SuccessRepairHeader)</Text>
</Label>
<Button Name="LaunchButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.SuccessLaunchButton)</Button>
<Label X="185" Y="-51" Width="400" Height="34" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBARestartRequired">
<Text>#(loc.SuccessRestartText)</Text>
<Text Condition="WixBundleAction = 3">#(loc.SuccessUninstallRestartText)</Text>
</Label>
<Button Name="SuccessRestartButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.SuccessRestartButton)</Button>
<Button Name="SuccessCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.SuccessCloseButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Failure">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">
<Text>#(loc.FailureHeader)</Text>
<Text Condition="WixBundleAction = 2">#(loc.FailureLayoutHeader)</Text>
<Text Condition="WixBundleAction = 3">#(loc.FailureUnsafeUninstallHeader)</Text>
<Text Condition="WixBundleAction = 4">#(loc.FailureUninstallHeader)</Text>
<Text Condition="WixBundleAction = 5">#(loc.FailureCacheHeader)</Text>
<Text Condition="WixBundleAction = 6">#(loc.FailureInstallHeader)</Text>
<Text Condition="WixBundleAction = 7">#(loc.FailureModifyHeader)</Text>
<Text Condition="WixBundleAction = 8">#(loc.FailureRepairHeader)</Text>
</Label>
<Hypertext Name="FailureLogFileLink" X="185" Y="121" Width="-11" Height="68" FontId="3" TabStop="yes" HideWhenDisabled="yes">#(loc.FailureHyperlinkLogText)</Hypertext>
<Hypertext Name="FailureMessageText" X="185" Y="-115" Width="-11" Height="80" FontId="3" TabStop="yes" HideWhenDisabled="yes" />
<Label X="185" Y="-57" Width="-11" Height="80" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBARestartRequired">#(loc.FailureRestartText)</Label>
<Button Name="FailureRestartButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.FailureRestartButton)</Button>
<Button Name="FailureCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.FailureCloseButton)</Text>
<CloseWindowAction />
</Button>
</Page>
</Window>
</Theme>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,27 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="MAKS.IT">
<defs>
<linearGradient id="hi" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#33A5CF"/>
<stop offset="1" stop-color="#006199"/>
</linearGradient>
<linearGradient id="mid" x1="0.2" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#0795C5"/>
<stop offset="1" stop-color="#003D7A"/>
</linearGradient>
<linearGradient id="deep" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#00427E"/>
<stop offset="1" stop-color="#002A6A"/>
</linearGradient>
<linearGradient id="navy" x1="1" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#00306F"/>
<stop offset="1" stop-color="#001428"/>
</linearGradient>
</defs>
<!-- Origami M: sharp facets, light from upper-left. No wordmark. -->
<polygon fill="url(#hi)" points="28,40 78,40 62,216 12,216"/>
<polygon fill="url(#navy)" points="78,40 118,128 102,216 62,216"/>
<polygon fill="url(#mid)" points="118,128 158,40 174,216 102,216"/>
<polygon fill="url(#hi)" points="158,40 228,40 244,216 174,216"/>
<polygon fill="url(#deep)" points="28,40 78,40 70,128 20,128"/>
<polygon fill="url(#navy)" points="158,40 228,40 220,128 166,128"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,89 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Builds an npm workspace (install + build script).
.DESCRIPTION
Runs npm ci (or npm install when useCi is false) and npm run build in the
configured workspace root. Requires NpmReleaseVersion to have set
shared npmWorkspaceRoot unless workspaceRoot is configured explicitly.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$shared = $Settings.context
Assert-Command npm
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot
}
else {
throw "NpmBuild plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
}
$useCi = $true
if ($null -ne $pluginSettings.useCi) {
$useCi = [bool]$pluginSettings.useCi
}
$buildScript = 'build'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.buildScript)) {
$buildScript = [string]$pluginSettings.buildScript
}
Push-Location $workspaceRoot
try {
if ($useCi) {
Write-Log -Level "STEP" -Message "Running npm ci in '$workspaceRoot'..."
npm ci
if ($LASTEXITCODE -ne 0) {
throw "npm ci failed with exit code $LASTEXITCODE."
}
}
else {
Write-Log -Level "STEP" -Message "Running npm install in '$workspaceRoot'..."
npm install
if ($LASTEXITCODE -ne 0) {
throw "npm install failed with exit code $LASTEXITCODE."
}
}
Write-Log -Level "STEP" -Message "Running npm run $buildScript..."
npm run $buildScript
if ($LASTEXITCODE -ne 0) {
throw "npm run $buildScript failed with exit code $LASTEXITCODE."
}
Write-Log -Level "OK" -Message " npm build completed."
}
finally {
Pop-Location
}
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,75 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
npm/Jest test plugin for the test engine.
.DESCRIPTION
Runs Jest with coverage via TestRunner.Invoke-NpmJestTestsWithCoverage and publishes
normalized metrics on the shared engine context for downstream plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Invoke-NpmJestTestsWithCoverage"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
Assert-Command npm
if (-not $pluginSettings.workspaceRoot) {
throw "NpmJestTest plugin requires 'workspaceRoot' in scriptSettings.json."
}
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $scriptDir)
$workspaceRoot = $workspaceRoots[0]
$testScript = 'test'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.testScript)) {
$testScript = [string]$pluginSettings.testScript
}
$coverageDirectory = 'coverage'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.coverageDirectory)) {
$coverageDirectory = [string]$pluginSettings.coverageDirectory
}
$testResult = Invoke-NpmJestTestsWithCoverage -WorkspaceRoot $workspaceRoot -TestScript $testScript -CoverageDirectory $coverageDirectory
if (-not $testResult.Success) {
throw "Tests failed. $($testResult.Error)"
}
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext"
Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'workspaceRoot' -Value $workspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot'
Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult
if (($testResult.PSObject.Properties.Name -contains 'CoverageSummaryFile') -and $testResult.CoverageSummaryFile) {
Set-EngineFact -Context $sharedSettings -Namespace 'test' -Name 'coverageSummaryFile' -Value $testResult.CoverageSummaryFile -Overwrite Replace -LegacyProperty 'coverageSummaryFile'
}
Write-Log -Level "OK" -Message " All tests passed!"
Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"
Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%"
Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%"
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,139 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Packs npm workspace packages into .tgz release artifacts.
.DESCRIPTION
Runs npm pack for each configured workspace package and publishes the
resulting tarball paths into shared release context (releaseAssetPaths)
for the GitHub release plugin.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
$pluginSettings = $Settings
$shared = $Settings.context
Assert-Command npm
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot
}
else {
throw "NpmPack plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
}
$artifactsDirectory = $null
if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) {
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $shared.scriptDir ([string]$pluginSettings.artifactsDir)))
Set-EngineState -Context $shared -Name 'artifactsDirectory' -Value $artifactsDirectory
}
elseif ($shared.PSObject.Properties['artifactsDirectory'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.artifactsDirectory)) {
$artifactsDirectory = [string]$shared.artifactsDirectory
}
else {
throw "NpmPack plugin requires release-stage artifactsDirectory."
}
$packOrder = @()
if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
$packOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) {
$packOrder = @([string]$pluginSettings.publishOrder)
}
}
elseif ($pluginSettings.packOrder) {
if ($pluginSettings.packOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.packOrder -is [string])) {
$packOrder = @($pluginSettings.packOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.packOrder)) {
$packOrder = @([string]$pluginSettings.packOrder)
}
}
if ($packOrder.Count -eq 0) {
throw "NpmPack plugin requires non-empty 'publishOrder' or 'packOrder' (workspace package names)."
}
Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
if (-not $useWorkspaces -and $packOrder.Count -gt 1) {
throw "NpmPack plugin requires npm workspaces when packing more than one package."
}
if (-not (Test-Path $artifactsDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
$releaseAssetPaths = @()
Push-Location $workspaceRoot
try {
foreach ($packageName in $packOrder) {
Write-Log -Level "STEP" -Message "Packing npm package '$packageName'..."
if ($useWorkspaces) {
$tarballName = (npm pack -w $packageName --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1)
}
else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
$tarballName = (npm pack --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1)
}
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$tarballName)) {
throw "npm pack failed for '$packageName'."
}
$tarballPath = Join-Path $artifactsDirectory ([string]$tarballName).Trim()
if (-not (Test-Path $tarballPath -PathType Leaf)) {
throw "Could not locate pack output for '$packageName' at: $tarballPath"
}
$strayTarballPath = Join-Path $workspaceRoot ([string]$tarballName).Trim()
if ((Test-Path $strayTarballPath -PathType Leaf) -and $strayTarballPath -ne $tarballPath) {
Remove-Item -LiteralPath $strayTarballPath -Force -ErrorAction SilentlyContinue
}
Write-Log -Level "OK" -Message " Package ready: $tarballPath"
$releaseAssetPaths += $tarballPath
}
}
finally {
Pop-Location
}
Set-EngineState -Context $shared -Name 'releaseDir' -Value $artifactsDirectory
Set-EngineFact -Context $shared -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
if ($releaseAssetPaths.Count -gt 0) {
$packageItem = Get-Item -LiteralPath $releaseAssetPaths[0]
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageFile' -Value $packageItem -Overwrite Replace -LegacyProperty 'packageFile'
}
Write-Log -Level "OK" -Message " npm pack completed ($($releaseAssetPaths.Count) tarball(s))."
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -1,62 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
function Get-NpmRootPackageJson {
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot
)
$packageJsonPath = Join-Path $WorkspaceRoot 'package.json'
if (-not (Test-Path $packageJsonPath -PathType Leaf)) {
throw "package.json not found at '$packageJsonPath'."
}
return Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
}
function Test-NpmWorkspacesConfigured {
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot
)
$json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot
if (-not $json.PSObject.Properties['workspaces'] -or $null -eq $json.workspaces) {
return $false
}
$workspaces = $json.workspaces
if ($workspaces -is [string]) {
return -not [string]::IsNullOrWhiteSpace($workspaces)
}
if ($workspaces -is [System.Collections.IEnumerable]) {
$entries = @($workspaces | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
return $entries.Count -gt 0
}
return $false
}
function Assert-NpmRootPackageName {
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot,
[Parameter(Mandatory = $true)]
[string]$ExpectedPackageName
)
$json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot
$rootPackageName = [string]$json.name
if ([string]::IsNullOrWhiteSpace($rootPackageName)) {
throw "Root package.json at '$WorkspaceRoot' is missing 'name'."
}
if ($rootPackageName -ne $ExpectedPackageName) {
throw "publishOrder package '$ExpectedPackageName' does not match root package name '$rootPackageName' (npm workspaces not configured)."
}
}
Export-ModuleMember -Function Test-NpmWorkspacesConfigured, Assert-NpmRootPackageName, Get-NpmRootPackageJson

View File

@ -1,158 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Publishes npm workspace packages to the npm registry.
.DESCRIPTION
Publishes packages in configured order using RepoUtilsSecrets slot Npm.
Uses a temporary .npmrc in the workspace root.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
$pluginSettings = $Settings
$shared = $Settings.context
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared
Assert-Command npm
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot
}
else {
throw "NpmPublish plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
}
$registry = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.registry)) {
'https://registry.npmjs.org'
}
else {
[string]$pluginSettings.registry
}
$access = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.access)) {
'public'
}
else {
[string]$pluginSettings.access
}
$npmDistTag = $null
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) {
$npmDistTag = [string]$pluginSettings.npmDistTag
}
else {
$npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version)
}
$publishOrder = @()
if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
$publishOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) {
$publishOrder = @([string]$pluginSettings.publishOrder)
}
}
if ($publishOrder.Count -eq 0) {
throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)."
}
$npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' -PluginDisplayName 'NpmPublish' -Required
Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
if (-not $useWorkspaces -and $publishOrder.Count -gt 1) {
throw "NpmPublish plugin requires npm workspaces when publishing more than one package."
}
if ($dryRun) {
foreach ($packageName in $publishOrder) {
$tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag }
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry (dist-tag $tagNote)"
}
return
}
$npmToken = Get-RepoUtilsSecretSlot -Name $npmSecret -Settings $shared
if ([string]::IsNullOrWhiteSpace($npmToken)) {
throw "npm API key is not set. Set RepoUtilsSecrets slot '$npmSecret' (npmSecret)."
}
$registryHost = ([uri]$registry).Host
$tempNpmRcPath = Join-Path $workspaceRoot ".npmrc.release-temp"
$npmRcContent = @"
registry=$registry
//$registryHost/:_authToken=$npmToken
"@
Push-Location $workspaceRoot
try {
Set-Content -Path $tempNpmRcPath -Value $npmRcContent -Encoding UTF8 -NoNewline
foreach ($packageName in $publishOrder) {
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
$publishArgs = @('publish')
if ($useWorkspaces) {
$publishArgs += @('-w', $packageName)
}
else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
}
$publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath)
if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) {
$publishArgs += @('--tag', $npmDistTag)
Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)."
}
npm @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "Failed to publish npm package '$packageName'."
}
Write-Log -Level "OK" -Message " Published $packageName."
}
Write-Log -Level "OK" -Message " npm publish completed."
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion"
Add-EnginePublishCompletion -Context $shared -Publisher 'NpmPublish'
}
finally {
if (Test-Path $tempNpmRcPath -PathType Leaf) {
Remove-Item -Path $tempNpmRcPath -Force -ErrorAction SilentlyContinue
}
Pop-Location
}
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $true }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -1,106 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Loads npm workspace release version into shared context.
.DESCRIPTION
Reads semver from the configured workspace package.json and writes it to
shared context version. Optionally synchronizes version fields across
workspace package manifests before build/publish.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-PackageJsonVersionInternal {
param(
[Parameter(Mandatory = $true)]
[string]$PackageJsonPath
)
if (-not (Test-Path $PackageJsonPath -PathType Leaf)) {
throw "NpmReleaseVersion: package.json not found at '$PackageJsonPath'."
}
$json = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
$version = [string]$json.version
if ([string]::IsNullOrWhiteSpace($version)) {
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
}
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
if (-not (Test-ReleaseSemver -Version $version)) {
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
}
return $version
}
function Set-PackageJsonVersionInternal {
param(
[Parameter(Mandatory = $true)]
[string]$PackageJsonPath,
[Parameter(Mandatory = $true)]
[string]$Version
)
$raw = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8
$json = $raw | ConvertFrom-Json
$json.version = $Version
($json | ConvertTo-Json -Depth 100) + [Environment]::NewLine | Set-Content -Path $PackageJsonPath -Encoding UTF8 -NoNewline
}
function Get-PluginMetadata {
return [pscustomobject]@{ providesVersion = $true }
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
$pluginSettings = $Settings
$shared = $Settings.context
$packageJsonPaths = @(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)
if ($packageJsonPaths.Count -eq 0) {
throw "NpmReleaseVersion plugin requires 'packageJsonPath' in scriptSettings.json."
}
$packageJsonPath = $packageJsonPaths[0]
$version = Get-PackageJsonVersionInternal -PackageJsonPath $packageJsonPath
$syncWorkspaceVersions = $false
if ($null -ne $pluginSettings.syncWorkspaceVersions) {
$syncWorkspaceVersions = [bool]$pluginSettings.syncWorkspaceVersions
}
if ($syncWorkspaceVersions) {
$workspaceRoot = Split-Path -Parent $packageJsonPath
$packageManifests = Get-ChildItem -Path (Join-Path $workspaceRoot 'packages') -Recurse -Filter package.json -File -ErrorAction SilentlyContinue
foreach ($manifest in $packageManifests) {
Set-PackageJsonVersionInternal -PackageJsonPath $manifest.FullName -Version $version
}
Write-Log -Level "OK" -Message " Synchronized workspace package versions to $version."
}
$npmWorkspaceRoot = Split-Path -Parent $packageJsonPath
Set-EngineState -Context $shared -Name 'version' -Value $version
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'workspaceRoot' -Value $npmWorkspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot'
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageJsonPath' -Value $packageJsonPath -Overwrite Replace -LegacyProperty 'npmPackageJsonPath'
Write-Log -Level "OK" -Message " Release version loaded by NpmReleaseVersion plugin: $version"
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -0,0 +1,78 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Collects coverage metrics from an existing test results directory.
.DESCRIPTION
Stack-agnostic parser for recovered container or host test output. Auto-detects .NET
Cobertura (coverage.cobertura.xml) or Jest (coverage-summary.json) and publishes the
same shared-context keys as DotNetTest / NpmJestTest for QualityGate.
Use after DockerContainerBuilder / PodmanContainerBuilder when tests run inside a container image.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1'
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log'
Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Get-CoverageFromResultsDirectory'
Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Publish-CoverageMetricsToSharedContext'
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$resultsDirSetting = $pluginSettings.resultsDir
if ([string]::IsNullOrWhiteSpace($resultsDirSetting)) {
if ($sharedSettings.PSObject.Properties.Name -contains 'testResultsDirectory' -and $sharedSettings.testResultsDirectory) {
$resultsDirSetting = $sharedSettings.testResultsDirectory
}
else {
throw "CollectCoverage requires 'resultsDir' in plugin settings or testResultsDirectory on shared context (from ContainerBuilder)."
}
}
$resultsDirectory = if ([System.IO.Path]::IsPathRooted([string]$resultsDirSetting)) {
[string]$resultsDirSetting
}
else {
[System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$resultsDirSetting)))
}
$format = 'auto'
if ($pluginSettings.PSObject.Properties.Name -contains 'format' -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.format)) {
$format = [string]$pluginSettings.format
}
Write-Log -Level 'STEP' -Message "Collecting coverage from $resultsDirectory ..."
$testResult = Get-CoverageFromResultsDirectory -ResultsDirectory $resultsDirectory -Format $format -Silent:$true
if (-not $testResult.Success) {
throw $testResult.Error
}
Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult
$formatLabel = if ($testResult.CoverageFormat) { $testResult.CoverageFormat } else { 'unknown' }
Write-Log -Level 'OK' -Message " Coverage collected ($formatLabel)."
Write-Log -Level 'INFO' -Message " Line Coverage: $($testResult.LineRate)%"
Write-Log -Level 'INFO' -Message " Branch Coverage: $($testResult.BranchRate)%"
Write-Log -Level 'INFO' -Message " Method Coverage: $($testResult.MethodRate)%"
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $false }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -0,0 +1,52 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Probes docker/podman availability and sets mandatory shared engine state for the pipeline.
.DESCRIPTION
Must be the first enabled plugin. Sets containerEngineProbeCompleted, activeContainerEngine,
dockerEngineAvailable, podmanEngineAvailable, and compose CLI fields on shared context.
Fails the pipeline when no usable engine is found.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1'
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log'
Import-PluginDependency -ModuleName 'ContainerEngineSupport' -RequiredCommand 'Resolve-ActiveContainerEngineState'
$shared = $Settings.context
Write-Log -Level 'STEP' -Message 'Probing container engine availability...'
$state = Resolve-ActiveContainerEngineState
Set-ContainerEngineSharedState -SharedSettings $shared -State $state
$display = Get-ContainerEngineDisplayName -Engine (Get-SharedContainerEngineInvocation -SharedSettings $shared)
Write-Log -Level 'OK' -Message " Active engine: $display"
Write-Log -Level 'INFO' -Message " docker CLI available: $($state.DockerEngineAvailable); podman CLI available: $($state.PodmanEngineAvailable)"
if ([string]::IsNullOrWhiteSpace([string]$state.ComposeExecutable)) {
Write-Log -Level 'INFO' -Message ' Compose CLI: not available (Compose plugins will fail if enabled).'
}
else {
Write-Log -Level 'INFO' -Message " Compose CLI: $($state.ComposeExecutable) $($state.ComposeBaseArgs -join ' ')"
}
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $false }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -0,0 +1,124 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Discovers package artifacts produced by pack plugins or container build pipelines.
.DESCRIPTION
Stack-agnostic scanner for artifactsDir. Detects .NET .nupkg/.snupkg or npm .tgz files
and populates shared context (packageFile, releaseAssetPaths, releaseArchiveInputs).
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1'
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log'
Import-PluginDependency -ModuleName 'DotNetArtifactSupport' -RequiredCommand 'Resolve-DotNetPackageArtifacts'
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$version = $sharedSettings.version
if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) {
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir)))
if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
}
else {
$sharedSettings | Add-Member -NotePropertyName artifactsDirectory -NotePropertyValue $artifactsDirectory -Force
$sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
}
}
else {
$artifactsDirectory = $sharedSettings.artifactsDirectory
}
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
throw 'DiscoverPackageArtifacts requires artifactsDir in plugin settings or artifactsDirectory on shared context.'
}
$nupkgCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.nupkg' -ErrorAction SilentlyContinue | Where-Object {
$_.Name -notlike '*.symbols.nupkg' -and $_.Name -notlike '*.snupkg'
})
$tgzCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.tgz' -ErrorAction SilentlyContinue)
if ($nupkgCandidates.Count -gt 0 -and $tgzCandidates.Count -gt 0) {
throw "DiscoverPackageArtifacts found both NuGet and npm artifacts in $artifactsDirectory. Use one package stack per repository."
}
if ($nupkgCandidates.Count -gt 0) {
if ([string]::IsNullOrWhiteSpace([string]$version)) {
throw 'DiscoverPackageArtifacts requires release version on shared context when discovering .nupkg artifacts.'
}
Write-Log -Level 'STEP' -Message "Discovering NuGet package artifacts in $artifactsDirectory ..."
$resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version
Write-Log -Level 'OK' -Message " Package ready: $($resolved.PackageFile.FullName)"
if ($resolved.SymbolsPackageFile) {
Write-Log -Level 'OK' -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)"
}
else {
Write-Log -Level 'WARN' -Message " Symbols package (.snupkg) not found for version $version."
}
$sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force
$sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $resolved.SymbolsPackageFile -Force
$sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
$sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
}
return
}
if ($tgzCandidates.Count -gt 0) {
Import-PluginDependency -ModuleName 'NpmArtifactSupport' -RequiredCommand 'Resolve-NpmPackageArtifacts'
Write-Log -Level 'STEP' -Message "Discovering npm package artifacts in $artifactsDirectory ..."
$resolved = Resolve-NpmPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version
foreach ($tarball in $resolved.PackageFiles) {
Write-Log -Level 'OK' -Message " Package ready: $($tarball.FullName)"
}
$sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force
$sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
$sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseAssetPaths -Force
$sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
}
}
return
}
throw "DiscoverPackageArtifacts found no .nupkg or .tgz files in: $artifactsDirectory"
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $false }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata