mirror of
https://github.com/MAKS-IT-COM/maksit-cluster-console.git
synced 2026-09-30 00:38:10 +02:00
(feature): highlight yaml and json, show service reachability, and keep crash reports
This commit is contained in:
parent
d556514972
commit
f74e01700f
25
.vscode/launch.json
vendored
Normal file
25
.vscode/launch.json
vendored
Normal 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
41
.vscode/tasks.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.0] - 2026-09-26
|
||||
|
||||
### Added
|
||||
|
||||
- Help → Logs shows the app log and crash reports, and an unhandled error opens a copyable debug window instead of closing the app.
|
||||
- YAML view, volume files, and ConfigMap/Secret values highlight as JSON or YAML. A data key or file name uses its extension (`.json`, `.yaml`, `.yml`); a key without one stays plain. The resource YAML tab is always YAML.
|
||||
- Services table includes Status: Active when the load-balancer address is assigned, Pending when none is set, and Unreachable when a BGP or requested address is not the one in `status.loadBalancer.ingress` (or Cilium IPAM reports it unsatisfied).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Applying YAML for a LoadBalancer service (for example a Cilium BGP service whose `last-applied-configuration` annotation contains a newline) no longer crashes. Multiline and quoted strings are written as valid YAML.
|
||||
|
||||
## [0.7.0] - 2026-09-12
|
||||
|
||||
### Added
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# MaksIT.ClusterConsole
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.7.0</Version>
|
||||
<Version>0.8.0</Version>
|
||||
<Product>MaksIT.ClusterConsole</Product>
|
||||
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
@ -45,7 +45,7 @@ public static class KubeQuantity {
|
||||
|
||||
public static string FormatCores(double cores) {
|
||||
if (cores < 1)
|
||||
return $"{cores * 1000:0}m";
|
||||
return (cores * 1000).ToString("0", CultureInfo.InvariantCulture) + "m";
|
||||
return cores.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@ -56,20 +56,20 @@ public static class KubeQuantity {
|
||||
const double gi = 1024d * 1024 * 1024;
|
||||
const double mi = 1024d * 1024;
|
||||
if (bytes >= gi)
|
||||
return $"{bytes / gi:0.##} GiB";
|
||||
return (bytes / gi).ToString("0.##", CultureInfo.InvariantCulture) + " GiB";
|
||||
if (bytes >= mi)
|
||||
return $"{bytes / mi:0.##} MiB";
|
||||
return $"{bytes} B";
|
||||
return (bytes / mi).ToString("0.##", CultureInfo.InvariantCulture) + " MiB";
|
||||
return bytes.ToString(CultureInfo.InvariantCulture) + " B";
|
||||
}
|
||||
|
||||
public static string FormatBytesCompact(long bytes) {
|
||||
const double gi = 1024d * 1024 * 1024;
|
||||
const double mi = 1024d * 1024;
|
||||
if (bytes >= gi)
|
||||
return $"{bytes / gi:0.0}GiB";
|
||||
return (bytes / gi).ToString("0.0", CultureInfo.InvariantCulture) + "GiB";
|
||||
if (bytes >= mi)
|
||||
return $"{bytes / mi:0.0}MiB";
|
||||
return $"{bytes}B";
|
||||
return (bytes / mi).ToString("0.0", CultureInfo.InvariantCulture) + "MiB";
|
||||
return bytes.ToString(CultureInfo.InvariantCulture) + "B";
|
||||
}
|
||||
|
||||
public static string FormatMegabytes(long bytes) {
|
||||
@ -78,7 +78,7 @@ public static class KubeQuantity {
|
||||
if (megabytes < 1)
|
||||
return "<1 MB";
|
||||
|
||||
return $"{Math.Round(megabytes):0} MB";
|
||||
return Math.Round(megabytes).ToString("0", CultureInfo.InvariantCulture) + " MB";
|
||||
}
|
||||
|
||||
public static string FormatMemoryQuantity(long bytes) {
|
||||
|
||||
29
src/MaksIT.ClusterConsole.Shared/AppLog.cs
Normal file
29
src/MaksIT.ClusterConsole.Shared/AppLog.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
78
src/MaksIT.ClusterConsole.Shared/ErrorReport.cs
Normal file
78
src/MaksIT.ClusterConsole.Shared/ErrorReport.cs
Normal 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,9 @@ public static class JsonPath {
|
||||
if (path == "service.externalIP")
|
||||
return ServiceExternalIp(root as JsonObject);
|
||||
|
||||
if (path == "service.status")
|
||||
return ServiceStatus(root as JsonObject);
|
||||
|
||||
if (path == "pv.claim")
|
||||
return VolumeClaim(root as JsonObject);
|
||||
|
||||
@ -100,26 +103,45 @@ public static class JsonPath {
|
||||
return string.IsNullOrWhiteSpace(ns) ? name : $"{ns}/{name}";
|
||||
}
|
||||
|
||||
public static string ServiceStatus(JsonObject? item) {
|
||||
if (item is null)
|
||||
return string.Empty;
|
||||
|
||||
var type = Text(item["spec"]?["type"]);
|
||||
if (!type.Equals("LoadBalancer", StringComparison.OrdinalIgnoreCase))
|
||||
return string.IsNullOrWhiteSpace(type) ? string.Empty : "Active";
|
||||
|
||||
var requested = RequestedLoadBalancerIps(item);
|
||||
var assigned = AssignedLoadBalancerIps(item);
|
||||
var requestedMissing = requested.Any(ip => !assigned.Contains(ip, StringComparer.OrdinalIgnoreCase));
|
||||
if (requested.Count > 0 && (requestedMissing || !IpamSatisfied(item)))
|
||||
return "Unreachable";
|
||||
if (assigned.Count == 0)
|
||||
return "Pending";
|
||||
|
||||
return "Active";
|
||||
}
|
||||
|
||||
public static string ServiceExternalIp(JsonObject? item) {
|
||||
if (item is null)
|
||||
return string.Empty;
|
||||
|
||||
var ips = new List<string>();
|
||||
AddUnique(ips, item["spec"]?["loadBalancerIP"]?.GetValue<string>());
|
||||
AddUnique(ips, Text(item["spec"]?["loadBalancerIP"]));
|
||||
|
||||
if (item["spec"]?["externalIPs"] is JsonArray external) {
|
||||
foreach (var ip in external)
|
||||
AddUnique(ips, ip?.GetValue<string>());
|
||||
AddUnique(ips, Text(ip));
|
||||
}
|
||||
|
||||
if (item["status"]?["loadBalancer"]?["ingress"] is JsonArray ingress) {
|
||||
foreach (var entry in ingress.OfType<JsonObject>()) {
|
||||
AddUnique(ips, entry["ip"]?.GetValue<string>());
|
||||
AddUnique(ips, entry["hostname"]?.GetValue<string>());
|
||||
AddUnique(ips, Text(entry["ip"]));
|
||||
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)) {
|
||||
foreach (var ip in annotation.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
AddUnique(ips, ip);
|
||||
@ -128,6 +150,46 @@ public static class JsonPath {
|
||||
return string.Join(",", ips);
|
||||
}
|
||||
|
||||
private static List<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) {
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
|
||||
32
src/MaksIT.ClusterConsole.Shared/MarkupSyntax.cs
Normal file
32
src/MaksIT.ClusterConsole.Shared/MarkupSyntax.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -217,7 +217,7 @@ public static class ResourceCatalog {
|
||||
D("mutatingwebhookconfigurations", "Mutating Webhooks", Config, "admissionregistration.k8s.io", "v1", "mutatingwebhookconfigurations", "MutatingWebhookConfiguration", false, named, crud, yamlTabs),
|
||||
D("validatingwebhookconfigurations", "Validating Webhooks", Config, "admissionregistration.k8s.io", "v1", "validatingwebhookconfigurations", "ValidatingWebhookConfiguration", false, named, crud, yamlTabs),
|
||||
D("services", "Services", Network, "", "v1", "services", "Service", true,
|
||||
[..std, new("Type", "spec.type"), new("Cluster IP", "spec.clusterIP"), new("External IP", "service.externalIP"), new("Ports", "spec.ports")],
|
||||
[..std, new("Type", "spec.type"), new("Status", "service.status"), new("Cluster IP", "spec.clusterIP"), new("External IP", "service.externalIP"), new("Ports", "spec.ports")],
|
||||
new ResourceActions(CanPortForward: true), serviceTabs),
|
||||
D("endpoints", "Endpoints", Network, "", "v1", "endpoints", "Endpoints", true, std, crud, yamlTabs),
|
||||
D("endpointslices", "Endpoint Slices", Network, "discovery.k8s.io", "v1", "endpointslices", "EndpointSlice", true, std, crud, yamlTabs),
|
||||
|
||||
@ -21,7 +21,7 @@ public static class ResourceStatusPaint {
|
||||
if (Contains(value, "fail", "error", "backoff", "unhealthy", "denied"))
|
||||
return ResourceStatusTone.Error;
|
||||
|
||||
if (Matches(value, "Pending", "ContainerCreating", "PodInitializing", "Terminating",
|
||||
if (Matches(value, "Pending", "Unreachable", "ContainerCreating", "PodInitializing", "Terminating",
|
||||
"Released", "Progressing", "Waiting", "Unknown", "Stopped", "NotReady",
|
||||
"Orphaned", "Missing"))
|
||||
return ResourceStatusTone.Warning;
|
||||
|
||||
@ -13,7 +13,7 @@ public sealed record PortForwardTarget(string PodName, string Namespace, int Con
|
||||
|
||||
public static class ServicePortForward {
|
||||
public static bool IsService(JsonObject? document) =>
|
||||
string.Equals(document?["kind"]?.GetValue<string>(), "Service", StringComparison.OrdinalIgnoreCase);
|
||||
string.Equals(JsonPath.Text(document?["kind"]), "Service", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static int? DefaultPort(JsonObject? service) {
|
||||
var first = (service?["spec"]?["ports"] as JsonArray)?.OfType<JsonObject>().FirstOrDefault();
|
||||
|
||||
@ -9,9 +9,14 @@ namespace MaksIT.ClusterConsole.Shared;
|
||||
/// </summary>
|
||||
public static class UserSettingsPath {
|
||||
public static string Get(string product, string fileName = "settings.json") =>
|
||||
Path.Combine(ProductDirectory(product), fileName);
|
||||
|
||||
public static string LogsDirectory(string product) =>
|
||||
Path.Combine(ProductDirectory(product), "logs");
|
||||
|
||||
public static string ProductDirectory(string product) =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"MaksIT",
|
||||
product,
|
||||
fileName);
|
||||
product);
|
||||
}
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
using System.Globalization;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.Shared;
|
||||
|
||||
public sealed record VolumeEntry(string Name, bool IsDirectory, long Size) {
|
||||
@ -9,11 +12,11 @@ public sealed record VolumeEntry(string Name, bool IsDirectory, long Size) {
|
||||
|
||||
private static string FormatSize(long size) {
|
||||
if (size < 1024)
|
||||
return $"{size} B";
|
||||
return size.ToString(CultureInfo.InvariantCulture) + " B";
|
||||
if (size < 1024 * 1024)
|
||||
return $"{size / 1024.0:0.#} KB";
|
||||
return (size / 1024.0).ToString("0.#", CultureInfo.InvariantCulture) + " KB";
|
||||
|
||||
return $"{size / (1024.0 * 1024.0):0.#} MB";
|
||||
return (size / (1024.0 * 1024.0)).ToString("0.#", CultureInfo.InvariantCulture) + " MB";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@ -75,14 +76,68 @@ public static class YamlFormatter {
|
||||
if (value.TryGetValue<bool>(out var b))
|
||||
return b ? "true" : "false";
|
||||
if (value.TryGetValue<long>(out var l))
|
||||
return l.ToString();
|
||||
return l.ToString(CultureInfo.InvariantCulture);
|
||||
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();
|
||||
if (s.Contains(':') || s.Contains('#') || s.Contains('\n') || s.Length == 0)
|
||||
return $"\"{s.Replace("\"", "\\\"")}\"";
|
||||
return s;
|
||||
return Quote(value.ToString());
|
||||
}
|
||||
|
||||
private static string Quote(string text) {
|
||||
if (!NeedsQuote(text))
|
||||
return text;
|
||||
|
||||
var escaped = new StringBuilder(text.Length + 2);
|
||||
escaped.Append('"');
|
||||
foreach (var c in text) {
|
||||
switch (c) {
|
||||
case '\\':
|
||||
escaped.Append("\\\\");
|
||||
break;
|
||||
case '"':
|
||||
escaped.Append("\\\"");
|
||||
break;
|
||||
case '\n':
|
||||
escaped.Append("\\n");
|
||||
break;
|
||||
case '\r':
|
||||
escaped.Append("\\r");
|
||||
break;
|
||||
case '\t':
|
||||
escaped.Append("\\t");
|
||||
break;
|
||||
default:
|
||||
escaped.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
escaped.Append('"');
|
||||
return escaped.ToString();
|
||||
}
|
||||
|
||||
private static bool NeedsQuote(string text) {
|
||||
if (text.Length == 0)
|
||||
return true;
|
||||
if (text is "true" or "false" or "null" or "yes" or "no" or "~")
|
||||
return true;
|
||||
if (char.IsWhiteSpace(text[0]) || char.IsWhiteSpace(text[^1]))
|
||||
return true;
|
||||
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||
return true;
|
||||
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
|
||||
&& text.Contains('.') && !text.Contains(".."))
|
||||
return true;
|
||||
|
||||
foreach (var c in text) {
|
||||
if (c is ':' or '#' or '\n' or '\r' or '\t' or '"' or '\\'
|
||||
or '{' or '}' or '[' or ']' or ',' or '&' or '*' or '!' or '|' or '>' or '%' or '@' or '`' or '\'')
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static JsonObject ParseSimpleYaml(string yaml) {
|
||||
|
||||
28
src/MaksIT.ClusterConsole.Tests/MarkupSyntaxTests.cs
Normal file
28
src/MaksIT.ClusterConsole.Tests/MarkupSyntaxTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -394,8 +394,38 @@ public class ResourceCatalogTests {
|
||||
Assert.NotNull(service);
|
||||
var row = ResourceRow.From(service, ResourceCatalog.Find("services")!);
|
||||
Assert.Equal("LoadBalancer", row.Cells["Type"]);
|
||||
Assert.Equal("Active", row.Cells["Status"]);
|
||||
Assert.Equal("10.43.131.123", row.Cells["Cluster IP"]);
|
||||
Assert.Equal("172.16.0.11", row.Cells["External IP"]);
|
||||
Assert.Equal("5432", row.Cells["Ports"]);
|
||||
|
||||
var pending = JsonNode.Parse("""
|
||||
{
|
||||
"metadata": { "name": "web" },
|
||||
"spec": { "type": "LoadBalancer", "clusterIP": "10.43.1.2", "ports": [{ "port": 80 }] }
|
||||
}
|
||||
""") as JsonObject;
|
||||
Assert.NotNull(pending);
|
||||
Assert.Equal("Pending", ResourceRow.From(pending, ResourceCatalog.Find("services")!).Cells["Status"]);
|
||||
|
||||
var unreachable = JsonNode.Parse("""
|
||||
{
|
||||
"metadata": {
|
||||
"name": "web",
|
||||
"annotations": { "lbipam.cilium.io/ips": "172.16.0.99" }
|
||||
},
|
||||
"spec": { "type": "LoadBalancer", "clusterIP": "10.43.1.3", "ports": [{ "port": 80 }] },
|
||||
"status": {
|
||||
"conditions": [{
|
||||
"type": "io.cilium/lb-ipam-request-satisfied",
|
||||
"status": "False",
|
||||
"reason": "no_pool"
|
||||
}]
|
||||
}
|
||||
}
|
||||
""") as JsonObject;
|
||||
Assert.NotNull(unreachable);
|
||||
Assert.Equal("Unreachable", ResourceRow.From(unreachable, ResourceCatalog.Find("services")!).Cells["Status"]);
|
||||
Assert.Equal("172.16.0.99", ResourceRow.From(unreachable, ResourceCatalog.Find("services")!).Cells["External IP"]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ public class ResourceTableTests {
|
||||
[InlineData("Ready", ResourceStatusTone.Healthy)]
|
||||
[InlineData("deployed", ResourceStatusTone.Healthy)]
|
||||
[InlineData("Pending", ResourceStatusTone.Warning)]
|
||||
[InlineData("Unreachable", ResourceStatusTone.Warning)]
|
||||
[InlineData("Unknown", ResourceStatusTone.Warning)]
|
||||
[InlineData("Failed", ResourceStatusTone.Error)]
|
||||
[InlineData("CrashLoopBackOff", ResourceStatusTone.Error)]
|
||||
|
||||
52
src/MaksIT.ClusterConsole.Tests/YamlApplyTests.cs
Normal file
52
src/MaksIT.ClusterConsole.Tests/YamlApplyTests.cs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
<Application.Styles>
|
||||
<SimpleTheme />
|
||||
<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" />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
|
||||
123
src/MaksIT.ClusterConsole.UI/Controls/SyntaxTextEditor.cs
Normal file
123
src/MaksIT.ClusterConsole.UI/Controls/SyntaxTextEditor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
146
src/MaksIT.ClusterConsole.UI/Dialogs/ErrorDialog.cs
Normal file
146
src/MaksIT.ClusterConsole.UI/Dialogs/ErrorDialog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,11 @@
|
||||
MinWidth="960"
|
||||
MinHeight="640">
|
||||
<DockPanel>
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="Help">
|
||||
<MenuItem Header="Logs" Click="OnLogsClick" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<Border DockPanel.Dock="Bottom" Padding="8,6" Background="#15171a">
|
||||
<TextBlock Text="{Binding Status}" Classes="hint" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
@ -616,7 +621,7 @@
|
||||
<Button Content="Apply YAML" Command="{Binding ActivePage.ApplyYamlCommand}" IsVisible="{Binding ActivePage.CanApplyYaml}" />
|
||||
<TextBlock Text="Unsaved YAML" Classes="hint" IsVisible="{Binding ActivePage.IsDirty}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBox Text="{Binding ActivePage.YamlText}" AcceptsReturn="True" FontFamily="Consolas, monospace" TextWrapping="NoWrap" />
|
||||
<controls:SyntaxTextEditor Text="{Binding ActivePage.YamlText}" PreferYaml="True" />
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
<TabItem Header="Data" IsVisible="{Binding ActivePage.IsDataEditor}">
|
||||
@ -631,12 +636,9 @@
|
||||
Text="Value (multiline paste supported)"
|
||||
Classes="hint"
|
||||
Margin="0,0,0,4" />
|
||||
<TextBox Text="{Binding ActivePage.SelectedDataEntry.Value}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
FontFamily="Consolas, monospace"
|
||||
IsEnabled="{Binding ActivePage.HasSelectedDataEntry}"
|
||||
PlaceholderText="Select a key, then edit or paste its value here" />
|
||||
<controls:SyntaxTextEditor Text="{Binding ActivePage.SelectedDataEntry.Value}"
|
||||
FileName="{Binding ActivePage.SelectedDataEntry.Key}"
|
||||
IsEnabled="{Binding ActivePage.HasSelectedDataEntry}" />
|
||||
</DockPanel>
|
||||
<DataGrid Name="DataEntriesGrid"
|
||||
ItemsSource="{Binding ActivePage.DataEntries}"
|
||||
|
||||
@ -13,6 +13,7 @@ using MaksIT.ClusterConsole.Shared;
|
||||
using MaksIT.ClusterConsole.UI.Controls;
|
||||
using MaksIT.ClusterConsole.UI.Converters;
|
||||
using MaksIT.ClusterConsole.UI.ViewModels;
|
||||
using MaksIT.ClusterConsole.UI.Windows;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI;
|
||||
@ -48,6 +49,9 @@ public partial class MainWindow : Window {
|
||||
viewModel.VolumeFilesRequested += OpenVolumeFiles;
|
||||
}
|
||||
|
||||
private void OnLogsClick(object? sender, RoutedEventArgs e) =>
|
||||
_ = LogWindow.ShowAsync(this);
|
||||
|
||||
private void OpenVolumeFiles(VolumeFilesViewModel files) {
|
||||
var window = new VolumeFilesWindow(files);
|
||||
window.Show(this);
|
||||
|
||||
@ -15,9 +15,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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.Themes.Simple" 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="MaksIT.Core" Version="1.6.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||
|
||||
@ -1,18 +1,48 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Logging;
|
||||
using Avalonia.Threading;
|
||||
using MaksIT.ClusterConsole.UI.Dialogs;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI;
|
||||
|
||||
internal static class Program {
|
||||
[STAThread]
|
||||
public static void Main(string[] args) =>
|
||||
public static void Main(string[] 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
|
||||
// xdg_toplevel.configure(0, 0) and never maps a window (GNOME app icon).
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,20 @@
|
||||
<Setter Property="FontFamily" Value="Segoe UI, Ubuntu, sans-serif" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</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">
|
||||
<Setter Property="Foreground" Value="#9aa0a6" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
|
||||
@ -673,6 +673,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
||||
if (_workspace.Session is null)
|
||||
return;
|
||||
|
||||
try {
|
||||
var doc = YamlFormatter.ToJsonObject(YamlText);
|
||||
if (doc is null) {
|
||||
_setStatus("YAML is empty or invalid.");
|
||||
@ -689,6 +690,11 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
||||
await RefreshRowsAsync();
|
||||
await LoadDetailsAsync();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
AppLog.Write(ex);
|
||||
_setStatus(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ApplyDataAsync() {
|
||||
|
||||
@ -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";
|
||||
}
|
||||
73
src/MaksIT.ClusterConsole.UI/ViewModels/LogViewModel.cs
Normal file
73
src/MaksIT.ClusterConsole.UI/ViewModels/LogViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/MaksIT.ClusterConsole.UI/Windows/ErrorWindow.axaml
Normal file
47
src/MaksIT.ClusterConsole.UI/Windows/ErrorWindow.axaml
Normal 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>
|
||||
31
src/MaksIT.ClusterConsole.UI/Windows/ErrorWindow.axaml.cs
Normal file
31
src/MaksIT.ClusterConsole.UI/Windows/ErrorWindow.axaml.cs
Normal 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();
|
||||
}
|
||||
44
src/MaksIT.ClusterConsole.UI/Windows/LogWindow.axaml
Normal file
44
src/MaksIT.ClusterConsole.UI/Windows/LogWindow.axaml
Normal 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>
|
||||
39
src/MaksIT.ClusterConsole.UI/Windows/LogWindow.axaml.cs
Normal file
39
src/MaksIT.ClusterConsole.UI/Windows/LogWindow.axaml.cs
Normal 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();
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:MaksIT.ClusterConsole.UI.ViewModels"
|
||||
xmlns:shared="using:MaksIT.ClusterConsole.Shared"
|
||||
xmlns:controls="using:MaksIT.ClusterConsole.UI.Controls"
|
||||
x:Class="MaksIT.ClusterConsole.UI.VolumeFilesWindow"
|
||||
x:DataType="vm:VolumeFilesViewModel"
|
||||
Title="{Binding WindowTitle}"
|
||||
@ -60,10 +61,8 @@
|
||||
Classes="hint"
|
||||
Margin="0,0,0,8"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBox Text="{Binding EditorText}"
|
||||
AcceptsReturn="True"
|
||||
FontFamily="Consolas, Cascadia Mono, monospace"
|
||||
TextWrapping="NoWrap"
|
||||
<controls:SyntaxTextEditor Text="{Binding EditorText}"
|
||||
FileName="{Binding OpenFilePath}"
|
||||
IsReadOnly="{Binding EditorReadOnly}" />
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
|
||||
@ -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 |
@ -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 |
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
78
utils/plugins/Platform/CollectCoverage.psm1
Normal file
78
utils/plugins/Platform/CollectCoverage.psm1
Normal 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
|
||||
52
utils/plugins/Platform/ContainerEngineProbe.psm1
Normal file
52
utils/plugins/Platform/ContainerEngineProbe.psm1
Normal 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
|
||||
124
utils/plugins/Platform/DiscoverPackageArtifacts.psm1
Normal file
124
utils/plugins/Platform/DiscoverPackageArtifacts.psm1
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user