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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Help → Logs shows the app log and crash reports, and an unhandled error opens a copyable debug window instead of closing the app.
|
||||||
|
- YAML view, volume files, and ConfigMap/Secret values highlight as JSON or YAML. A data key or file name uses its extension (`.json`, `.yaml`, `.yml`); a key without one stays plain. The resource YAML tab is always YAML.
|
||||||
|
- Services table includes Status: Active when the load-balancer address is assigned, Pending when none is set, and Unreachable when a BGP or requested address is not the one in `status.loadBalancer.ingress` (or Cilium IPAM reports it unsatisfied).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Applying YAML for a LoadBalancer service (for example a Cilium BGP service whose `last-applied-configuration` annotation contains a newline) no longer crashes. Multiline and quoted strings are written as valid YAML.
|
||||||
|
|
||||||
## [0.7.0] - 2026-09-12
|
## [0.7.0] - 2026-09-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# MaksIT.ClusterConsole
|
# MaksIT.ClusterConsole
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

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