mirror of
https://github.com/MAKS-IT-COM/maksit-cluster-console.git
synced 2026-11-14 20:56:12 +01:00
(refactor): use shared desktop windows and align the store listing with current screens
This commit is contained in:
parent
1e6ded6695
commit
4883aa1099
@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.9] - 2026-10-09
|
||||
|
||||
### Changed
|
||||
|
||||
- About, What's New, the log window, and the crash dialog come from the MaksIT.Core.UI package. Crash text and the screenshot-tour flags come from MaksIT.Core.Desktop.
|
||||
- The README and the Microsoft Store listing use the current screens: deployment pods, Helm history, Dapr sidecars, Longhorn volumes, and node images. The listing text matches Helm history, overview chat, Longhorn, CloudNativePG, and a username-and-password context.
|
||||
- AppStream screenshots stay on tag `v0.8.6`, where those images are stored. The `0.8.9` release entry points at this version's changelog.
|
||||
|
||||
## [0.8.8] - 2026-10-06
|
||||
|
||||
### Fixed
|
||||
|
||||
28
README.md
28
README.md
@ -1,8 +1,8 @@
|
||||
# MaksIT.ClusterConsole
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@ -55,12 +55,11 @@ One row per `app.kubernetes.io/instance` (or `name`) and namespace. CPU is perce
|
||||
|
||||
Ready, Restarts, Status, Node, CPU, and Memory. Filters and sort persist per cluster. The open table follows the API watch. Details: Overview, YAML, Events, Logs, Terminal (interactive shell). **Container** picks which container logs, the shell, Attach, and Debug use. **Attach** follows that container's output. **Debug** adds an ephemeral container beside it; the image box defaults to `busybox:1.36`. Kubernetes leaves that container on the running pod. It is gone when the pod is recreated. **Force delete** uses grace period 0 and strips finalizers.
|
||||
|
||||
<!-- microsoft-store 4 -->
|
||||
<!-- microsoft-store 3 -->
|
||||

|
||||
|
||||
**Logs** shows that container's output. **Follow** tails new lines instead of a one-time snapshot.
|
||||
|
||||
<!-- microsoft-store 5 -->
|
||||

|
||||
|
||||
### Chat
|
||||
@ -71,7 +70,6 @@ Settings → AI turns Chat on, on a resource and on the cluster overview. The en
|
||||
|
||||
Read-only tools are cluster issues, object YAML, logs, and events. **Allow the assistant to change the cluster** adds restart, pod delete (not force-delete), scale, and YAML apply. Each of those waits for **Approve** or **Reject** in the chat. Reject leaves the cluster unchanged. No cloud AI API.
|
||||
|
||||
<!-- microsoft-store 7 -->
|
||||

|
||||
|
||||
### Volume files
|
||||
@ -88,7 +86,6 @@ The control plane is the system processes in the `dapr` namespace: injector, ope
|
||||
|
||||
A Configuration is sidecar settings for apps that reference it: tracing, metrics, and which features are on. It is not a ConfigMap, and it is not a Component.
|
||||
|
||||
<!-- microsoft-store 10 -->
|
||||

|
||||
|
||||
A Subscription says an app wants messages from a topic. Topic is the name. The sidecar receives them and posts them to the app.
|
||||
@ -97,13 +94,14 @@ A Subscription says an app wants messages from a topic. Topic is the name. The s
|
||||
|
||||
A sidecar is the `daprd` container in an application pod. This list is those pods. Logs and a terminal are for that pod.
|
||||
|
||||
<!-- microsoft-store 8 -->
|
||||

|
||||
|
||||
### Port forwarding
|
||||
|
||||
**Network → Port Forwarding**: tunnels persist, restore on reconnect, and retarget a running pod. Double-click **Active** opens `http://127.0.0.1:{port}/`. **Rebind** changes the local port.
|
||||
|
||||
<!-- microsoft-store 8 -->
|
||||
<!-- microsoft-store 6 -->
|
||||

|
||||
|
||||
### Connections
|
||||
@ -118,6 +116,7 @@ Cluster, Nodes, Applications, Workloads, Config, Network, Storage, Namespaces, E
|
||||
|
||||
Longhorn volumes and nodes appear when that API is installed. **State** is attached or detached. **Robustness** is healthy, degraded, or faulted.
|
||||
|
||||
<!-- microsoft-store 9 -->
|
||||

|
||||
|
||||
### Tables
|
||||
@ -128,19 +127,19 @@ Longhorn volumes and nodes appear when that API is installed. **State** is attac
|
||||
|
||||
View, server-side apply, create, and delete. **New** starts an empty document for a kind you can create. Apply falls back to create or replace when the API server rejects an apply patch. JSON and YAML highlighting follows the document. ConfigMap and Secret values use the key name (`.json`, `.yaml`, `.yml`) or stay plain.
|
||||
|
||||
<!-- microsoft-store 6 -->
|
||||
<!-- microsoft-store 5 -->
|
||||

|
||||
|
||||
### Workloads
|
||||
|
||||
The Workloads section opens on counts for Pods, Deployments, StatefulSets, DaemonSets, ReplicaSets, Jobs, CronJobs, and ReplicationControllers.
|
||||
|
||||
<!-- microsoft-store 3 -->
|
||||

|
||||
|
||||
A tile opens that table. **Scale** applies to a Deployment, StatefulSet, ReplicaSet, or ReplicationController. **Restart** applies to a Deployment, StatefulSet, or DaemonSet. Pause, resume, history, and undo are on a Deployment. A workload's **Pod** picker chooses which pod Logs and Terminal follow. **Trigger** starts a Job from a CronJob. **Force delete namespace** removes the selected namespace, except `default`, `kube-system`, `kube-public`, and `kube-node-lease`.
|
||||
|
||||

|
||||
<!-- microsoft-store 4 -->
|
||||

|
||||
|
||||
### Nodes
|
||||
|
||||
@ -150,14 +149,13 @@ A tile opens that table. **Scale** applies to a Deployment, StatefulSet, Replica
|
||||
|
||||
The Images tab marks cached images Used or Unused from the pods on that node.
|
||||
|
||||
<!-- microsoft-store 10 -->
|
||||

|
||||
|
||||
### Config
|
||||
|
||||
ConfigMaps and Secrets have a **Data** tab: keys, a decoded preview, a binary flag, **Add key**, and **Apply data**. Secret values are saved back as `stringData`. The same tables cover resource quotas, limit ranges, HorizontalPodAutoscalers, PodDisruptionBudgets, leases, runtime classes, webhooks, and admission policies.
|
||||
|
||||

|
||||
|
||||
### Network
|
||||
|
||||
A Service **Status** is Active when a load-balancer address is assigned, Pending when none is set, and Unreachable when a requested or BGP address is not the one the cluster reports. The navigator also lists Endpoints, EndpointSlices, Ingresses, NetworkPolicies, and IP addresses.
|
||||
@ -190,9 +188,11 @@ Extra columns from the CRD `additionalPrinterColumns`.
|
||||
|
||||
Charts lists each chart version installed in the cluster, taken from Helm release secrets, with the releases that use it. A release shows every stored revision, the user-supplied values, the rendered manifest, and a diff between two revisions. Chart install and upgrade stay on the Helm CLI.
|
||||
|
||||
<!-- microsoft-store 9 -->
|
||||

|
||||
|
||||
<!-- microsoft-store 7 -->
|
||||

|
||||
|
||||
### Metrics
|
||||
|
||||
CPU and memory when the metrics API is available. Charts use the same metrics-server data as `kubectl top`.
|
||||
|
||||
@ -2,14 +2,12 @@
|
||||
|
||||
Short notes shown in the app after an upgrade. The full history, including maintainer notes, is [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## [0.8.8] - 2026-10-06
|
||||
|
||||
- A ControllerRevision opens in the details pane. Its data values are shown as text.
|
||||
|
||||
## [0.8.7] - 2026-10-05
|
||||
## [0.8.9] - 2026-10-09
|
||||
|
||||
- When AI is on, Chat sits on the cluster overview. Analyze issues explains the current warnings and errors.
|
||||
- The details pane, and the overview chat, can collapse to a rail and open again. The choice is kept.
|
||||
- A ControllerRevision opens in the details pane. Its data values are shown as text.
|
||||
- Help → About, What's New, and Logs open in the shared windows. A crash still shows the details and keeps a copy under Help → Logs.
|
||||
|
||||
## [0.8.6] - 2026-10-03
|
||||
|
||||
|
||||
@ -65,6 +65,12 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.8.9" date="2026-10-09">
|
||||
<url type="details">https://github.com/MAKS-IT-COM/maksit-cluster-console/blob/v0.8.9/CHANGELOG.md</url>
|
||||
<description>
|
||||
<p>About, What's New, logs, and crash reports use the shared desktop windows.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.8.8" date="2026-10-06">
|
||||
<url type="details">https://github.com/MAKS-IT-COM/maksit-cluster-console/blob/v0.8.8/CHANGELOG.md</url>
|
||||
<description>
|
||||
|
||||
@ -9,7 +9,7 @@ Values for `com.maks_it.clusterconsole.yml` (or `.json`) at the top of the Flath
|
||||
| Homepage | [https://maks-it.com/](https://maks-it.com/) |
|
||||
| Project | `src/MaksIT.ClusterConsole.UI/MaksIT.ClusterConsole.UI.csproj` |
|
||||
| Target | `net10.0` |
|
||||
| Source | Release tag on `https://github.com/MAKS-IT-COM/maksit-cluster-console` (currently `v0.8.8`) |
|
||||
| Source | Release tag on `https://github.com/MAKS-IT-COM/maksit-cluster-console` (currently `v0.8.9`) |
|
||||
| Runtime | `org.freedesktop.Platform` |
|
||||
| SDK | `org.freedesktop.Sdk` |
|
||||
| SDK extension | `org.freedesktop.Sdk.Extension.dotnet10` |
|
||||
|
||||
@ -9,7 +9,7 @@ Upstream files stay in this repository. The Flathub build installs them from the
|
||||
| [src/MaksIT.ClusterConsole.UI/Assets/icon.svg](../../src/MaksIT.ClusterConsole.UI/Assets/icon.svg) | Icon, installed as `share/icons/hicolor/scalable/apps/com.maks_it.clusterconsole.svg`. |
|
||||
| [LICENSE.md](../../LICENSE.md) | Apache-2.0. Install to `$FLATPAK_DEST/share/licenses/com.maks_it.clusterconsole/` because the filename is `LICENSE.md`. |
|
||||
|
||||
Screenshot URLs in the metainfo use tag `v0.8.6`, where those images are stored. The `0.8.8` release entry points at tag `v0.8.8`. On a newer release, point `<image>` at the tag that contains the screenshots and `<release>` at that version.
|
||||
Screenshot URLs in the metainfo use tag `v0.8.6`, where those images are stored. The `0.8.9` release entry points at tag `v0.8.9`. On a newer release, point `<image>` at the tag that contains the screenshots and `<release>` at that version.
|
||||
|
||||
## Pull request
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ Hardware checkboxes (keyboard, memory, processor) stay in [CONTRIBUTING.md](../.
|
||||
Shown at the top of the listing. The field allows 1,000 characters. Keep this under 270 so every Store view shows the full sentence.
|
||||
|
||||
```text
|
||||
A desktop app for an existing Kubernetes cluster. Connect with your kubeconfig, then browse resources, apply YAML, follow pod logs, open a shell, and forward ports to this PC. Optional local chat can repair the cluster after you approve each change.
|
||||
A desktop app for an existing Kubernetes cluster. Connect with your kubeconfig, then browse resources, apply YAML, follow pod logs, open a shell, and forward ports. Inspect Helm and Dapr. Optional local chat can repair the cluster after you approve each change.
|
||||
```
|
||||
|
||||
## Description
|
||||
@ -23,15 +23,15 @@ Work on a Kubernetes cluster from Windows without keeping a browser dashboard or
|
||||
|
||||
Connect with the kubeconfig you already use. What you can see and change is exactly what that identity is allowed to do. Click a catalog row to open that context in this app. The radio beside the row only writes kubectl current-context, so the command line can follow. This app does not read that field. Several contexts can stay open here while kubectl still has one current context.
|
||||
|
||||
Move through the cluster from one navigator: cluster and nodes, applications, workloads, config, network, storage, namespaces, events, Helm releases, Dapr, access control, and custom resources, including CRDs already installed. Several contexts can stay connected. Add a context with a token, a client certificate, or pasted k3s certificate data. The open table watches the API, and a label selector is sent with the list. Search filters the rows already loaded. Filters and sort stay per cluster. Selecting several rows runs a footer action on each of them. Custom resource tables show extra columns from the CRD. Open a row for YAML, events, and status. Hint explains the kind on screen.
|
||||
Move through the cluster from one navigator: cluster and nodes, applications, workloads, config, network, storage, namespaces, events, Helm releases, Dapr, access control, and custom resources, including CRDs already installed. Longhorn volumes and nodes, and CloudNativePG clusters, appear when those APIs are installed. Several contexts can stay connected. Add a context with a token, a client certificate, pasted k3s certificate data, or a username and password. The open table watches the API, and a label selector is sent with the list. Search filters the rows already loaded. Filters and sort stay per cluster. Selecting several rows runs a footer action on each of them. Custom resource tables show extra columns from the CRD. Open a row for YAML, events, and status. Hint explains the kind on screen.
|
||||
|
||||
Everyday changes stay in the same window. Apply or create YAML. If the API server rejects an apply patch, apply falls back to create or replace. Force-delete strips finalizers. Scale a Deployment, StatefulSet, ReplicaSet, or ReplicationController. Restart a Deployment, StatefulSet, or DaemonSet. Pause, resume, and undo a Deployment rollout. Trigger a CronJob. Cordon and Drain stay hidden unless another Ready node can still take pods. Uncordon still restores a node that is already cordoned. Drain asks first and shows which pods will move and which will remain, including anything a disruption budget would refuse. When AI is on, that dialog also includes a short note. The note does not drain the node. On a pod, follow the log, open a shell, attach output, or add a debug container. The image defaults to busybox and stays until the pod is recreated. ConfigMaps and Secrets edit as data keys. A Service shows Active, Pending, or Unreachable.
|
||||
Everyday changes stay in the same window. Apply or create YAML. If the API server rejects an apply patch, apply falls back to create or replace. JSON and YAML highlighting follows the document. Force-delete strips finalizers. Scale a Deployment, StatefulSet, ReplicaSet, or ReplicationController. Restart a Deployment, StatefulSet, or DaemonSet. Pause, resume, and undo a Deployment rollout. Trigger a CronJob. Force delete namespace removes a selected namespace, except default, kube-system, kube-public, and kube-node-lease. Cordon and Drain stay hidden unless another Ready node can still take pods. Uncordon still restores a node that is already cordoned. Drain asks first and shows which pods will move and which will remain, including anything a disruption budget would refuse. When AI is on, that dialog also includes a short note. The note does not drain the node. The Images tab marks cached images Used or Unused from the pods on that node. On a pod, follow the log, open a shell, attach output, or add a debug container. The image defaults to busybox and stays until the pod is recreated. ConfigMaps and Secrets edit as data keys, with a decoded preview. A Service shows Active, Pending, or Unreachable.
|
||||
|
||||
Resize a persistent volume claim, or change reclaim policy on a storage class and its volumes. Approve or deny a certificate signing request, and create a ServiceAccount token that is shown once. Port forwards are saved, restored when you reconnect, and a double-click on an active forward opens it in the browser.
|
||||
Resize a persistent volume claim, or change reclaim policy on a storage class and its volumes. Approve or deny a certificate signing request, and create a ServiceAccount token that is shown once. Port forwards are saved, restored when you reconnect, and a double-click on an active forward opens it in the browser. Rebind changes the local port.
|
||||
|
||||
CPU and memory appear on the overview, applications, and workload tables when the cluster has metrics-server. From the overview you can patch container CPU and memory against node capacity. Persistent volumes and claims open a file browser for viewing, editing, downloading, and uploading files. Helm and Dapr pages list objects that are already in the cluster.
|
||||
CPU and memory appear on the overview, applications, and workload tables when the cluster has metrics-server. From the overview you can patch container CPU and memory against node capacity. Persistent volumes and claims open a file browser for viewing, editing, downloading, and uploading files. Helm charts list each installed chart version. A release shows revision history, user-supplied values, the rendered manifest, and a diff between two revisions. Chart install and upgrade stay on the Helm CLI. Dapr pages list components, configurations, subscriptions, sidecars, and the control plane that are already in the cluster.
|
||||
|
||||
Chat is optional and stays on this PC. It talks to a local Ollama server. The model is any one you have pulled. qwen3:8b is the default and is enough to read issues, YAML, logs, and events. Repairs work better with a larger model such as qwen3:14b, qwen3:32b, or qwen3-coder:30b. Turn on Allow the assistant to change the cluster and the same chat can restart a workload, delete a pod, scale, or apply YAML. Each of those waits for Approve or Reject. Reject leaves the cluster unchanged. Cluster data is not sent to a cloud AI service.
|
||||
Chat is optional and stays on this PC. It talks to a local Ollama server, on the overview and on a selected resource. The model is any one you have pulled. qwen3:8b is the default and is enough to read issues, YAML, logs, and events. On the overview, Analyze issues explains the current warnings and errors. Repairs work better with a larger model such as qwen3:14b, qwen3:32b, or qwen3-coder:30b. Turn on Allow the assistant to change the cluster and the same chat can restart a workload, delete a pod, scale, or apply YAML. Each of those waits for Approve or Reject. Reject leaves the cluster unchanged. Pod delete here is not force-delete. Cluster data is not sent to a cloud AI service.
|
||||
|
||||
Your layout, open clusters, and saved port forwards are stored on this computer. Cluster Console talks to a cluster you already run.
|
||||
|
||||
|
||||
@ -9,9 +9,9 @@ Kubernetes GUI
|
||||
kubeconfig
|
||||
pod logs
|
||||
YAML
|
||||
Helm releases
|
||||
port forwarding
|
||||
Helm
|
||||
Dapr
|
||||
local Ollama
|
||||
```
|
||||
|
||||
The listing description is in [description.md](description.md). Product features are in [product-features.md](product-features.md).
|
||||
|
||||
@ -4,23 +4,24 @@ Paste into Partner Center → Store listings → **Product features**.
|
||||
|
||||
Short summaries of the product's key features. The Store shows them as a bulleted list. Up to 20 app features. Each feature is at most 200 characters. Paste the sentence only; Partner Center adds the bullet.
|
||||
|
||||
- Click a catalog row to open that cluster here. The radio only writes kubectl current-context for the command line. This app does not use that field. The overview shows CPU, memory, and pod counts.
|
||||
- Click a catalog row to open that cluster in this app. The radio only writes kubectl current-context. This app does not use that field. The overview shows CPU, memory, pod counts, and resource limits.
|
||||
- Group applications by instance and namespace from standard labels. CPU is a percent of cluster allocatable. Memory is summed from owned pods when metrics are available.
|
||||
- Pod tables show ready, restarts, status, node, CPU, and memory. Follow logs, open a shell, attach output, or add a busybox debug container. Filters and sort stay per cluster.
|
||||
- Ask any local Ollama model about the selection. Default qwen3:8b reads issues, YAML, logs, and events. Larger models such as qwen3:14b, qwen3:32b, or qwen3-coder:30b fit repairs better. No cloud AI.
|
||||
- Allow the assistant to change the cluster. Restart, pod delete, scale, and YAML apply each wait for Approve or Reject in the chat. Reject leaves the cluster unchanged.
|
||||
- Browse, edit, download, and upload files on a persistent volume or claim. Double-click a PV or PVC row to open the explorer.
|
||||
- Ask a local Ollama model about the cluster or the selection. On the overview, Analyze issues explains the current warnings and errors. The default model is qwen3:8b. No cloud AI.
|
||||
- Allow the assistant to change the cluster. Restart, pod delete, scale, and YAML apply each wait for Approve or Reject. Reject leaves the cluster unchanged. A larger local model fits repairs better.
|
||||
- Browse, edit, download, and upload files on a persistent volume or claim. Double-click a PV or PVC row to open the file browser.
|
||||
- Open Dapr from the navigator: components, configurations, subscriptions, resiliency, HTTP endpoints, sidecars, and the control plane.
|
||||
- Save port forwards, restore them on reconnect, and retarget a running pod. Double-click an active forward to open it in the browser. Rebind changes the local port.
|
||||
- Add a kubeconfig context with a token, client certificate, or pasted k3s data. Several clusters can stay connected in this app while kubectl keeps a single current-context.
|
||||
- Browse cluster, nodes, workloads, config, network, storage, namespaces, events, Helm, Dapr, access control, and custom resources, including CRDs installed in the cluster.
|
||||
- The open table watches the API. A label selector is sent with the list. Column filters and sort stay on this PC, per cluster.
|
||||
- View, apply, create, and delete resource YAML, including force delete. If the API server rejects an apply patch, apply falls back to create or replace.
|
||||
- Scale and restart workloads. Pause, resume, and undo a Deployment rollout. Trigger a CronJob. Cordon, uncordon, and drain a node after a table of pods that will move or remain. Cordon and Drain stay hidden unless another Ready node remains. When AI is on, the drain dialog includes a short note.
|
||||
- Resize a persistent volume claim. Change reclaim policy on a storage class and its volumes.
|
||||
- Add a kubeconfig context with a token, client certificate, pasted k3s data, or a username and password. Several clusters can stay connected here while kubectl keeps one current-context.
|
||||
- Browse cluster, nodes, workloads, config, network, storage, namespaces, events, Helm, Dapr, access control, and custom resources. Longhorn and CloudNativePG appear when those APIs are installed.
|
||||
- The open table watches the API. A label selector is sent with the list. Search filters loaded rows. Filters and sort stay per cluster. Shift or Ctrl selection runs a footer action on the selection.
|
||||
- View, apply, create, and delete resource YAML, including force delete. If the API server rejects an apply patch, apply falls back to create or replace. JSON and YAML highlighting follows the document.
|
||||
- Scale and restart workloads. Pause, resume, and undo a Deployment. Trigger a CronJob. Cordon and Drain stay hidden unless another Ready node remains. When AI is on, drain adds a short note.
|
||||
- Resize a persistent volume claim. Change reclaim policy on a storage class and its volumes. The Images tab marks cached node images Used or Unused.
|
||||
- ConfigMaps and Secrets have a Data tab with keys, a decoded preview, and apply. A Service status is Active, Pending, or Unreachable.
|
||||
- Approve or deny a certificate signing request. Create a ServiceAccount token and show it once.
|
||||
- Custom resource tables use extra columns from the CRD additionalPrinterColumns.
|
||||
- Helm charts list each installed chart version. A release shows history, user-supplied values, the rendered manifest, and a diff between two revisions.
|
||||
- Show CPU and memory when the cluster has metrics-server.
|
||||
- Custom resource tables use extra columns from the CRD additionalPrinterColumns. Hint explains the kind on screen.
|
||||
- Helm charts list each installed chart version. A release shows history, user-supplied values, the rendered manifest, and a diff between two revisions. Install and upgrade stay on the Helm CLI.
|
||||
- CPU and memory appear when the cluster has metrics-server, on the overview and on application and workload tables.
|
||||
|
||||
The listing description and the other Store text fields are in [description.md](description.md).
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.8.8</Version>
|
||||
<Version>0.8.9</Version>
|
||||
<Product>Cluster Console</Product>
|
||||
<AssemblyTitle>Cluster Console</AssemblyTitle>
|
||||
<Company>MaksIT</Company>
|
||||
|
||||
@ -1,40 +1,22 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using MaksIT.Core.Desktop;
|
||||
|
||||
|
||||
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);
|
||||
public static string Capture(Exception exception) =>
|
||||
CrashReport.Capture(
|
||||
exception,
|
||||
AppInfo.ProductName + " " + Version(),
|
||||
AppInfo.Brand,
|
||||
AppLog.Directory,
|
||||
AppLog.Write);
|
||||
|
||||
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(AppInfo.ProductName + " " + Version());
|
||||
text.AppendLine(AppInfo.Brand);
|
||||
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 Format(Exception exception) =>
|
||||
CrashReport.Format(exception, AppInfo.ProductName + " " + Version(), AppInfo.Brand);
|
||||
|
||||
/// <summary>
|
||||
/// A cancelled Kubernetes watch aborts the socket while <c>ReadLineAsync</c> is still running.
|
||||
@ -54,21 +36,6 @@ public static class ErrorReport {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -101,34 +68,4 @@ public static class ErrorReport {
|
||||
pending.Push(current.InnerException);
|
||||
}
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="KubernetesClient" Version="19.0.2" />
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.8" />
|
||||
<PackageReference Include="MaksIT.Core.Desktop" Version="0.1.0" />
|
||||
<PackageReference Include="MaksIT.Results" Version="2.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
namespace MaksIT.ClusterConsole.Shared;
|
||||
|
||||
|
||||
public sealed record ReleaseNote(string Version, IReadOnlyList<string> Added);
|
||||
|
||||
public static class ReleaseNotes {
|
||||
public const string ResourceName = "MaksIT.ClusterConsole.Shared.WHATSNEW.md";
|
||||
|
||||
@ -17,124 +14,9 @@ public static class ReleaseNotes {
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ReleaseNote> AddedSince(string markdown, string? seenVersion, string currentVersion) {
|
||||
if (!TryParse(currentVersion, out var current))
|
||||
return [];
|
||||
|
||||
var seenKnown = TryParse(seenVersion, out var seen);
|
||||
|
||||
if (seenKnown && seen >= current)
|
||||
return [];
|
||||
|
||||
var matches = new List<ReleaseNote>();
|
||||
|
||||
foreach (var section in Parse(markdown)) {
|
||||
if (section.Number > current || section.Added.Count == 0)
|
||||
continue;
|
||||
|
||||
if (!seenKnown) {
|
||||
if (section.Number != current)
|
||||
continue;
|
||||
}
|
||||
else if (section.Number <= seen)
|
||||
continue;
|
||||
|
||||
matches.Add(new ReleaseNote(section.Text, section.Added));
|
||||
}
|
||||
|
||||
matches.Sort((left, right) => {
|
||||
TryParse(right.Version, out var newer);
|
||||
TryParse(left.Version, out var older);
|
||||
|
||||
return newer.CompareTo(older);
|
||||
});
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
private static List<ParsedSection> Parse(string markdown) {
|
||||
var sections = new List<ParsedSection>();
|
||||
ParsedSection? current = null;
|
||||
var inAdded = false;
|
||||
|
||||
foreach (var raw in markdown.Split('\n')) {
|
||||
var line = raw.TrimEnd('\r').Trim();
|
||||
|
||||
if (line.StartsWith("## ", StringComparison.Ordinal)) {
|
||||
if (current is not null)
|
||||
sections.Add(current);
|
||||
|
||||
current = null;
|
||||
inAdded = false;
|
||||
|
||||
if (!TryHeading(line, out var text, out var number))
|
||||
continue;
|
||||
|
||||
current = new ParsedSection(text, number);
|
||||
inAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current is null)
|
||||
continue;
|
||||
|
||||
if (line.StartsWith("### ", StringComparison.Ordinal)) {
|
||||
inAdded = line.Equals("### Added", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inAdded && line.StartsWith("- ", StringComparison.Ordinal))
|
||||
current.Added.Add(line[2..].Trim());
|
||||
}
|
||||
|
||||
if (current is not null)
|
||||
sections.Add(current);
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
private static bool TryHeading(string line, out string text, out Version number) {
|
||||
text = "";
|
||||
number = new Version();
|
||||
|
||||
if (!line.StartsWith("## [", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var end = line.IndexOf(']', 4);
|
||||
|
||||
if (end < 0)
|
||||
return false;
|
||||
|
||||
text = line[4..end];
|
||||
|
||||
if (text.Equals("Unreleased", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
return TryParse(text, out number);
|
||||
}
|
||||
|
||||
private static bool TryParse(string? version, out Version number) {
|
||||
number = new Version();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
return false;
|
||||
|
||||
var text = version.Trim();
|
||||
var plus = text.IndexOf('+', StringComparison.Ordinal);
|
||||
|
||||
if (plus >= 0)
|
||||
text = text[..plus];
|
||||
|
||||
return Version.TryParse(text, out number!);
|
||||
}
|
||||
|
||||
private sealed class ParsedSection(string text, Version number) {
|
||||
public string Text { get; } = text;
|
||||
|
||||
public Version Number { get; } = number;
|
||||
|
||||
public List<string> Added { get; } = [];
|
||||
}
|
||||
public static IReadOnlyList<MaksIT.Core.Desktop.ReleaseNote> AddedSince(
|
||||
string markdown,
|
||||
string? seenVersion,
|
||||
string currentVersion) =>
|
||||
MaksIT.Core.Desktop.ReleaseNotes.AddedSince(markdown, seenVersion, currentVersion);
|
||||
}
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
using MaksIT.Core.Desktop.Snapshots;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.Shared;
|
||||
|
||||
|
||||
@ -22,77 +25,35 @@ public sealed class ScreenshotTourOptions {
|
||||
/// <summary>
|
||||
/// Reads <c>--screenshots</c>, <c>--context</c>, <c>--views</c>, and <c>--settle-ms</c>.
|
||||
/// Returns false when a screenshot flag is present but incomplete.
|
||||
/// With no <c>--views</c>, the tour is the welcome screen plus every feature.
|
||||
/// </summary>
|
||||
public static bool TryParse(IReadOnlyList<string>? args, out ScreenshotTourOptions? options, out string? error) {
|
||||
options = null;
|
||||
error = null;
|
||||
|
||||
if (args is null || args.Count == 0)
|
||||
return true;
|
||||
|
||||
string? directory = null;
|
||||
string? context = null;
|
||||
IReadOnlyList<string>? views = null;
|
||||
var settle = 600;
|
||||
var requested = false;
|
||||
|
||||
for (var i = 0; i < args.Count; i++) {
|
||||
switch (args[i]) {
|
||||
case "--screenshots":
|
||||
requested = true;
|
||||
|
||||
if (!TryTake(args, ref i, "--screenshots", out directory, out error))
|
||||
if (!AppViewSnapshotOptions.TryParse(args, out var snapshot, out error))
|
||||
return false;
|
||||
|
||||
break;
|
||||
case "--context":
|
||||
if (snapshot is null)
|
||||
return true;
|
||||
|
||||
string? context = null;
|
||||
|
||||
if (args is not null) {
|
||||
for (var i = 0; i < args.Count; i++) {
|
||||
if (args[i] != "--context")
|
||||
continue;
|
||||
|
||||
if (!TryTake(args, ref i, "--context", out context, out error))
|
||||
return false;
|
||||
|
||||
break;
|
||||
case "--views":
|
||||
if (!TryTake(args, ref i, "--views", out var viewList, out error))
|
||||
return false;
|
||||
|
||||
views = viewList!
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (views.Count == 0) {
|
||||
error = "Pass at least one view id after --views.";
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--settle-ms":
|
||||
if (!TryTake(args, ref i, "--settle-ms", out var settleText, out error))
|
||||
return false;
|
||||
|
||||
if (!int.TryParse(settleText, out settle) || settle < 0) {
|
||||
error = "Pass a non-negative number of milliseconds after --settle-ms.";
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!requested)
|
||||
return true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(directory)) {
|
||||
error = "Pass a directory after --screenshots.";
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
options = new ScreenshotTourOptions {
|
||||
Directory = directory,
|
||||
Directory = snapshot.Directory,
|
||||
Context = string.IsNullOrWhiteSpace(context) ? null : context,
|
||||
Views = views ?? DefaultViews,
|
||||
AllFeatures = views is null,
|
||||
SettleMilliseconds = settle
|
||||
Views = snapshot.Views.Count == 0 ? DefaultViews : snapshot.Views,
|
||||
AllFeatures = snapshot.Views.Count == 0,
|
||||
SettleMilliseconds = snapshot.SettleMilliseconds
|
||||
};
|
||||
|
||||
return true;
|
||||
|
||||
346
src/MaksIT.ClusterConsole.Tests/ClusterChatServiceTests.cs
Normal file
346
src/MaksIT.ClusterConsole.Tests/ClusterChatServiceTests.cs
Normal file
@ -0,0 +1,346 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
using MaksIT.ClusterConsole.Shared.Chat;
|
||||
using MaksIT.ClusterConsole.Client.Ollama;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.Tests;
|
||||
|
||||
public class ClusterChatServiceTests {
|
||||
[Fact]
|
||||
public async Task Ask_runs_a_tool_then_returns_the_diagnosis() {
|
||||
var session = new FakeClusterSession { LogsText = "bind: address already in use" };
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var ollama = new SequencedOllama([
|
||||
ToolReply("get_logs", """{"pod":"web-0","namespace":"apps","tailLines":12}"""),
|
||||
TextReply("The container cannot bind port 8080.")
|
||||
]);
|
||||
var chat = new ClusterChatService(ollama, workspace);
|
||||
|
||||
var answer = await chat.AskAsync(
|
||||
"http://127.0.0.1:11434",
|
||||
"qwen3:8b",
|
||||
[new OllamaChatMessage { Role = "user", Content = "why is it down?" }],
|
||||
Context(),
|
||||
_ => { },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(answer.IsSuccess, string.Join("; ", answer.Messages));
|
||||
Assert.Equal("The container cannot bind port 8080.", answer.Value);
|
||||
Assert.Equal(12, session.LastTail);
|
||||
Assert.Contains(ollama.Requests[1].Messages, message =>
|
||||
message.Role == "tool" && message.Content.Contains("address already in use", StringComparison.Ordinal));
|
||||
Assert.Equal(ClusterChatService.ReadOnlyContext, ollama.Requests[0].Options!.NumCtx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ask_reads_a_tool_call_embedded_in_the_answer() {
|
||||
var session = new FakeClusterSession { Gotten = JsonNode.Parse("""{ "kind": "Service", "metadata": { "name": "web" } }""") as JsonObject };
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var ollama = new SequencedOllama([
|
||||
TextReply("""
|
||||
<think>look it up</think>
|
||||
<tool_call>
|
||||
<function=get_resource>
|
||||
<parameter=kind>
|
||||
Service
|
||||
</parameter>
|
||||
<parameter=name>
|
||||
web
|
||||
</parameter>
|
||||
</function>
|
||||
</tool_call>
|
||||
"""),
|
||||
TextReply("The Service exists.")
|
||||
]);
|
||||
var chat = new ClusterChatService(ollama, workspace);
|
||||
var answer = await chat.AskAsync("http://127.0.0.1:11434", "qwen3:8b", [], Context(), null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("The Service exists.", answer.Value);
|
||||
Assert.Equal(("Service", "web", "apps"), session.LastGet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ask_reports_a_missing_model_an_empty_answer_and_a_chat_failure() {
|
||||
var workspace = new ClusterWorkspace();
|
||||
var missing = new SequencedOllama([]) {
|
||||
Models = Result<IReadOnlyList<string>>.Ok((IReadOnlyList<string>)["llama3"])
|
||||
};
|
||||
var notPulled = await new ClusterChatService(missing, workspace).AskAsync(
|
||||
"http://127.0.0.1:11434", "qwen3:8b", [], Context(), null, TestContext.Current.CancellationToken);
|
||||
Assert.False(notPulled.IsSuccess);
|
||||
Assert.Contains("not pulled", notPulled.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var empty = new SequencedOllama([TextReply(" ")]);
|
||||
var blank = await new ClusterChatService(empty, workspace).AskAsync(
|
||||
"http://127.0.0.1:11434", "qwen3:8b", [], Context(), null, TestContext.Current.CancellationToken);
|
||||
Assert.Contains("empty answer", blank.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var failed = new SequencedOllama([]) {
|
||||
ChatError = Result<OllamaChatResponse>.InternalServerError(null, "connection refused")
|
||||
};
|
||||
var down = await new ClusterChatService(failed, workspace).AskAsync(
|
||||
"http://127.0.0.1:11434", "qwen3:8b", [], Context(), null, TestContext.Current.CancellationToken);
|
||||
Assert.Contains("connection refused", down.Messages[0], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ask_maps_an_ollama_stall_to_the_timeout_message() {
|
||||
var ollama = new SequencedOllama([]) { CancelChat = true };
|
||||
var chat = new ClusterChatService(ollama, new ClusterWorkspace());
|
||||
var stalled = await chat.AskAsync(
|
||||
"http://127.0.0.1:11434", "qwen3:8b", [], Context(), null, TestContext.Current.CancellationToken, timeoutSeconds: 5);
|
||||
|
||||
Assert.Contains("did not answer within 5 seconds", stalled.Messages[0], StringComparison.Ordinal);
|
||||
|
||||
using var canceled = new CancellationTokenSource();
|
||||
await canceled.CancelAsync();
|
||||
ollama.HonorCallerCancel = true;
|
||||
ollama.CancelChat = false;
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => chat.AskAsync(
|
||||
"http://127.0.0.1:11434", "qwen3:8b", [], Context(), null, canceled.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_restart_runs_only_after_approval() {
|
||||
var session = new FakeClusterSession();
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var approved = new SequencedOllama([
|
||||
ToolReply("restart_workload", """{"kind":"Deployment","name":"web","namespace":"apps"}"""),
|
||||
TextReply("Restarted the deployment.")
|
||||
]);
|
||||
var chat = new ClusterChatService(approved, workspace);
|
||||
var answer = await chat.AskAsync(
|
||||
"http://127.0.0.1:11434",
|
||||
"qwen3:8b",
|
||||
[],
|
||||
Context(),
|
||||
null,
|
||||
TestContext.Current.CancellationToken,
|
||||
agent: true,
|
||||
approve: (_, _, _) => Task.FromResult(true));
|
||||
|
||||
Assert.Equal("Restarted the deployment.", answer.Value);
|
||||
Assert.Equal(1, session.Restarts);
|
||||
Assert.Equal(ClusterChatService.AgentContext, approved.Requests[0].Options!.NumCtx);
|
||||
|
||||
var rejected = new SequencedOllama([
|
||||
ToolReply("restart_workload", """{"kind":"Deployment","name":"web","namespace":"apps"}"""),
|
||||
TextReply("Left it alone.")
|
||||
]);
|
||||
answer = await new ClusterChatService(rejected, workspace).AskAsync(
|
||||
"http://127.0.0.1:11434",
|
||||
"qwen3:8b",
|
||||
[],
|
||||
Context(),
|
||||
null,
|
||||
TestContext.Current.CancellationToken,
|
||||
agent: true,
|
||||
approve: (_, _, _) => Task.FromResult(false));
|
||||
Assert.Equal("Left it alone.", answer.Value);
|
||||
Assert.Equal(1, session.Restarts);
|
||||
Assert.Contains(rejected.Requests[1].Messages, message =>
|
||||
message.Role == "tool" && message.Content.Contains(ClusterChatTools.RejectedByOperator, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssessDrain_rejects_an_empty_note() {
|
||||
var ollama = new SequencedOllama([TextReply("<think>only</think>")]);
|
||||
var note = await new ClusterChatService(ollama, new ClusterWorkspace()).AssessDrainAsync(
|
||||
"http://127.0.0.1:11434", "qwen3:8b", "node-a", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Contains("did not write a drain note", note.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tools_read_logs_events_and_resources_or_explain_why_not() {
|
||||
var tools = new ClusterChatTools(new ClusterWorkspace());
|
||||
var context = Context();
|
||||
Assert.Equal("Not connected to a cluster.", await tools.InvokeAsync("get_logs", new JsonObject(), context, true, TestContext.Current.CancellationToken));
|
||||
Assert.Equal("Unknown tool 'explode'.", await tools.InvokeAsync("explode", new JsonObject(), context, true, TestContext.Current.CancellationToken));
|
||||
|
||||
var session = new FakeClusterSession {
|
||||
LogsText = "ready",
|
||||
Gotten = JsonNode.Parse("""{ "kind": "Pod", "metadata": { "name": "web-0" } }""") as JsonObject
|
||||
};
|
||||
session.Set("events", JsonNode.Parse("""
|
||||
{
|
||||
"metadata": { "name": "ev1", "namespace": "apps" },
|
||||
"involvedObject": { "name": "web" },
|
||||
"type": "Warning",
|
||||
"reason": "BackOff",
|
||||
"message": "back-off restarting"
|
||||
}
|
||||
""") as JsonObject ?? throw new InvalidOperationException("expected object"));
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
tools = new ClusterChatTools(workspace);
|
||||
|
||||
var logs = await tools.InvokeAsync("get_logs", new JsonObject { ["pod"] = "web-0" }, context, true, TestContext.Current.CancellationToken);
|
||||
Assert.Equal("ready", logs);
|
||||
|
||||
var events = await tools.InvokeAsync("get_events", new JsonObject { ["name"] = "web" }, context, true, TestContext.Current.CancellationToken);
|
||||
Assert.Contains("BackOff", events, StringComparison.Ordinal);
|
||||
|
||||
var resource = await tools.InvokeAsync("get_resource", new JsonObject(), context, true, TestContext.Current.CancellationToken);
|
||||
Assert.Contains("kind: Pod", resource, StringComparison.Ordinal);
|
||||
|
||||
var deleted = await tools.InvokeAsync(
|
||||
"delete_pod",
|
||||
new JsonObject { ["name"] = "web-0", ["namespace"] = "apps" },
|
||||
context,
|
||||
true,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Contains("Deleted Pod/web-0", deleted, StringComparison.Ordinal);
|
||||
|
||||
var scaled = await tools.InvokeAsync(
|
||||
"scale_workload",
|
||||
new JsonObject { ["kind"] = "Deployment", ["name"] = "web", ["namespace"] = "apps", ["replicas"] = "2" },
|
||||
context,
|
||||
true,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Contains("Scaled Deployment/web to 2", scaled, StringComparison.Ordinal);
|
||||
Assert.Equal(2, session.LastReplicas);
|
||||
|
||||
Assert.Equal("get_logs needs a pod name. Select a pod in the UI or pass pod.", await tools.InvokeAsync(
|
||||
"get_logs", new JsonObject(), new ClusterChatContext("c", "apps", "Deployment", "web", null, null, "", "", ""), true, TestContext.Current.CancellationToken));
|
||||
Assert.Equal("No events found for missing.", await tools.InvokeAsync(
|
||||
"get_events", new JsonObject { ["name"] = "missing" }, context, true, TestContext.Current.CancellationToken));
|
||||
Assert.StartsWith("Unknown kind", await tools.InvokeAsync(
|
||||
"get_resource", new JsonObject { ["kind"] = "NoSuch", ["name"] = "x" }, context, true, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tools_describe_changes_and_truncate_long_results() {
|
||||
var tools = new ClusterChatTools(new ClusterWorkspace());
|
||||
Assert.Equal("Restart Deployment/web in apps.", tools.Describe("restart_workload", Args()));
|
||||
Assert.Equal("Delete Pod/web in apps. The controller can recreate it.", tools.Describe("delete_pod", Args()));
|
||||
Assert.Equal("Scale Deployment/web in apps to 2 replicas.", tools.Describe("scale_workload", new JsonObject {
|
||||
["kind"] = "Deployment", ["name"] = "web", ["namespace"] = "apps", ["replicas"] = 2
|
||||
}));
|
||||
Assert.Contains("Apply ConfigMap/app", tools.Describe("apply_manifest", new JsonObject { ["yaml"] = Manifest() }), StringComparison.Ordinal);
|
||||
Assert.Equal("Apply a manifest.", tools.Describe("apply_manifest", new JsonObject()));
|
||||
Assert.Equal("No YAML was provided. Nothing will be applied.", tools.ChangePreview("apply_manifest", new JsonObject()));
|
||||
Assert.Equal("The YAML could not be parsed. Nothing will be applied.", tools.ChangePreview("apply_manifest", new JsonObject { ["yaml"] = "[]" }));
|
||||
|
||||
var huge = new JsonObject { ["yaml"] = "key: " + new string('a', 7000) };
|
||||
Assert.Contains("truncated", tools.ChangePreview("apply_manifest", huge), StringComparison.Ordinal);
|
||||
|
||||
var session = new FakeClusterSession { LogsText = new string('x', ClusterChatTools.MaxResultChars + 20) };
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var text = await new ClusterChatTools(workspace).InvokeAsync(
|
||||
"get_logs",
|
||||
new JsonObject { ["pod"] = "web-0" },
|
||||
Context(),
|
||||
true,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Contains("truncated", text, StringComparison.Ordinal);
|
||||
|
||||
Assert.Empty(ClusterChatTools.ParseArguments(default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Issues_tool_prints_warnings_or_says_the_cluster_is_clear() {
|
||||
var session = new FakeClusterSession();
|
||||
session.Set("nodes", JsonNode.Parse("""
|
||||
{
|
||||
"metadata": { "name": "n1", "uid": "n1", "creationTimestamp": "2026-08-19T10:00:00Z" },
|
||||
"status": { "conditions": [
|
||||
{ "type": "Ready", "status": "True" },
|
||||
{ "type": "MemoryPressure", "status": "True", "message": "kubelet has memory pressure" }
|
||||
] }
|
||||
}
|
||||
""") as JsonObject ?? throw new InvalidOperationException("expected object"));
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var tools = new ClusterChatTools(workspace);
|
||||
var text = await tools.InvokeAsync("get_cluster_issues", new JsonObject(), Context(), true, TestContext.Current.CancellationToken);
|
||||
Assert.Contains("memory pressure", text, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
session.Set("nodes");
|
||||
text = await tools.InvokeAsync("get_cluster_issues", new JsonObject(), Context(), true, TestContext.Current.CancellationToken);
|
||||
Assert.Contains("No cluster warnings", text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static ClusterChatContext Context() =>
|
||||
new("homelab", "apps", "Pod", "web-0", "web-0", "app", "", "", "");
|
||||
|
||||
private static JsonObject Args() =>
|
||||
new() { ["kind"] = "Deployment", ["name"] = "web", ["namespace"] = "apps" };
|
||||
|
||||
private static string Manifest() => """
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app
|
||||
namespace: default
|
||||
data:
|
||||
key: value
|
||||
""";
|
||||
|
||||
private static OllamaChatResponse TextReply(string content) =>
|
||||
new() { Message = new OllamaChatMessage { Role = "assistant", Content = content } };
|
||||
|
||||
private static OllamaChatResponse ToolReply(string name, string arguments) =>
|
||||
new() {
|
||||
Message = new OllamaChatMessage {
|
||||
Role = "assistant",
|
||||
ToolCalls = [
|
||||
new OllamaToolCall {
|
||||
Function = new OllamaToolCallFunction {
|
||||
Name = name,
|
||||
Arguments = JsonDocument.Parse(arguments).RootElement.Clone()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
private sealed class SequencedOllama(IEnumerable<OllamaChatResponse> replies) : IOllamaChatClient {
|
||||
private readonly Queue<OllamaChatResponse> _replies = new(replies);
|
||||
|
||||
public List<OllamaChatRequest> Requests { get; } = [];
|
||||
|
||||
public Result<IReadOnlyList<string>> Models { get; set; } =
|
||||
Result<IReadOnlyList<string>>.Ok((IReadOnlyList<string>)["qwen3:8b"]);
|
||||
|
||||
public Result<OllamaChatResponse>? ChatError { get; set; }
|
||||
|
||||
public bool CancelChat { get; set; }
|
||||
|
||||
public bool HonorCallerCancel { get; set; }
|
||||
|
||||
public Task<Result<IReadOnlyList<string>>> ListModelsAsync(
|
||||
string endpoint,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (HonorCallerCancel)
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return Task.FromResult(Models);
|
||||
}
|
||||
|
||||
public Task<Result<OllamaChatResponse>> ChatAsync(
|
||||
string endpoint,
|
||||
OllamaChatRequest request,
|
||||
CancellationToken cancellationToken = default) {
|
||||
Requests.Add(request);
|
||||
|
||||
if (HonorCallerCancel)
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (CancelChat)
|
||||
throw new OperationCanceledException();
|
||||
|
||||
if (ChatError is not null)
|
||||
return Task.FromResult(ChatError);
|
||||
|
||||
return Task.FromResult(Result<OllamaChatResponse>.Ok(_replies.Dequeue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
556
src/MaksIT.ClusterConsole.Tests/ClusterWorkspaceTests.cs
Normal file
556
src/MaksIT.ClusterConsole.Tests/ClusterWorkspaceTests.cs
Normal file
@ -0,0 +1,556 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
using MaksIT.ClusterConsole.Client.Cluster;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.Tests;
|
||||
|
||||
public class ClusterWorkspaceTests {
|
||||
[Fact]
|
||||
public async Task Connect_adds_custom_resources_and_disconnect_drops_them() {
|
||||
var session = new FakeClusterSession { Crds = [WidgetCrd()] };
|
||||
var workspace = new ClusterWorkspace();
|
||||
var connected = await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(connected.IsSuccess);
|
||||
Assert.Same(session, workspace.Session);
|
||||
Assert.Contains(workspace.Navigator, item => item.Id == "crd:example.com/v1/widgets" && item.Title == "Widget");
|
||||
Assert.Equal("Widget", workspace.FindByGvk("example.com/v1", "Widget")!.Kind);
|
||||
Assert.Null(workspace.FindByGvk("example.com/v1", ""));
|
||||
|
||||
var replacement = new FakeClusterSession { CrdError = "forbidden" };
|
||||
var again = await workspace.ConnectAsync(replacement, TestContext.Current.CancellationToken);
|
||||
Assert.True(again.IsSuccess);
|
||||
Assert.True(session.Disposed);
|
||||
Assert.DoesNotContain(workspace.Navigator, item => item.Id.StartsWith("crd:", StringComparison.Ordinal));
|
||||
|
||||
workspace.Disconnect();
|
||||
Assert.True(replacement.Disposed);
|
||||
Assert.Null(workspace.Session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_requires_a_session_and_rejects_unknown_ids() {
|
||||
var workspace = new ClusterWorkspace();
|
||||
var missing = await workspace.ListAsync("pods", "default", null, TestContext.Current.CancellationToken);
|
||||
Assert.False(missing.IsSuccess);
|
||||
Assert.Contains("not connected", missing.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var session = new FakeClusterSession();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var unknown = await workspace.ListAsync("not-a-kind", null, null, TestContext.Current.CancellationToken);
|
||||
Assert.False(unknown.IsSuccess);
|
||||
Assert.Contains("unknown resource", unknown.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_pods_attaches_metrics_and_keeps_the_resource_version() {
|
||||
var session = new FakeClusterSession();
|
||||
session.Set("pods", Pod("web", "apps", "Running"));
|
||||
session.PodMetrics["apps/web"] = new ResourceMetrics("web", "apps", "100m", "512Mi");
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var listed = await workspace.ListAsync("pods", "apps", "web", TestContext.Current.CancellationToken);
|
||||
Assert.True(listed.IsSuccess, string.Join("; ", listed.Messages));
|
||||
var row = Assert.Single(listed.Value!);
|
||||
Assert.Equal("100m", row.Cells["CPU"]);
|
||||
Assert.Equal("512.0MiB", row.Cells["Memory"]);
|
||||
Assert.Equal("Running", row.Cells["Status"]);
|
||||
Assert.Equal("rv-1", workspace.LastResourceVersion);
|
||||
|
||||
var filtered = await workspace.ListAsync("pods", "apps", "missing", TestContext.Current.CancellationToken);
|
||||
Assert.Empty(filtered.Value!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_pods_keeps_rows_when_metrics_are_unavailable() {
|
||||
var session = new FakeClusterSession { PodMetricsFail = true };
|
||||
session.Set("pods", Pod("web", "apps", "Running"));
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var listed = await workspace.ListAsync("pods", null, null, TestContext.Current.CancellationToken);
|
||||
Assert.True(listed.IsSuccess, string.Join("; ", listed.Messages));
|
||||
Assert.Equal("", Assert.Single(listed.Value!).Cells["CPU"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_propagates_an_api_failure() {
|
||||
var session = new FakeClusterSession();
|
||||
session.FailList("pods", "forbidden");
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var listed = await workspace.ListAsync("pods", "apps", null, TestContext.Current.CancellationToken);
|
||||
Assert.False(listed.IsSuccess);
|
||||
Assert.Contains("forbidden", listed.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_deployments_sums_pod_metrics_for_the_owner() {
|
||||
var session = new FakeClusterSession();
|
||||
session.Set("deployments", Deployment("web", "apps"));
|
||||
session.Set(
|
||||
"pods",
|
||||
Pod("web-0", "apps", "Running", """{ "app": "web" }"""),
|
||||
Pod("done", "apps", "Succeeded", """{ "app": "web" }"""));
|
||||
session.PodMetrics["apps/web-0"] = new ResourceMetrics("web-0", "apps", "250m", "1Gi");
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var listed = await workspace.ListAsync("deployments", "apps", null, TestContext.Current.CancellationToken);
|
||||
Assert.True(listed.IsSuccess, string.Join("; ", listed.Messages));
|
||||
var row = Assert.Single(listed.Value!);
|
||||
Assert.Equal("250m", row.Cells["CPU"]);
|
||||
Assert.Equal("1.0GiB", row.Cells["Memory"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_applications_collapses_manifests_and_caches_allocatable_cpu() {
|
||||
var session = new FakeClusterSession { CpuAllocatable = 4 };
|
||||
session.Set("deployments", Deployment("web", "apps", manifest: true));
|
||||
session.Set("pods", Pod("web-0", "apps", "Running", """{ "app": "web" }"""));
|
||||
session.PodMetrics["apps/web-0"] = new ResourceMetrics("web-0", "apps", "200m", "256Mi");
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var listed = await workspace.ListAsync(ResourceCatalog.ApplicationsId, "apps", null, TestContext.Current.CancellationToken);
|
||||
Assert.True(listed.IsSuccess, string.Join("; ", listed.Messages));
|
||||
var row = Assert.Single(listed.Value!);
|
||||
Assert.Equal("web", row.Cell("Instance"));
|
||||
Assert.Equal("apps", row.Namespace);
|
||||
Assert.Equal(1, session.CpuCalls);
|
||||
|
||||
var again = await workspace.ListAsync(ResourceCatalog.ApplicationsId, "apps", "no-such", TestContext.Current.CancellationToken);
|
||||
Assert.Empty(again.Value!);
|
||||
Assert.Equal(1, session.CpuCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_applications_returns_the_first_workload_failure() {
|
||||
var session = new FakeClusterSession();
|
||||
session.FailList("deployments", "deployments forbidden");
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var listed = await workspace.ListAsync(ResourceCatalog.ApplicationsId, null, null, TestContext.Current.CancellationToken);
|
||||
Assert.False(listed.IsSuccess);
|
||||
Assert.Contains("deployments forbidden", listed.Messages[0], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_helm_keeps_the_latest_revision_and_groups_charts() {
|
||||
var session = new FakeClusterSession {
|
||||
HelmDocuments = [
|
||||
Helm("vault", "security", 1, "superseded"),
|
||||
Helm("vault", "security", 3, "deployed"),
|
||||
Helm("vault", "apps", 2, "failed")
|
||||
]
|
||||
};
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var releases = await workspace.ListAsync(ResourceCatalog.HelmReleasesId, null, null, TestContext.Current.CancellationToken);
|
||||
Assert.True(releases.IsSuccess, string.Join("; ", releases.Messages));
|
||||
Assert.Equal(2, releases.Value!.Count);
|
||||
Assert.Equal("3", releases.Value.Single(row => row.Namespace == "security").Cells["Revision"]);
|
||||
Assert.Equal("deployed", releases.Value.Single(row => row.Namespace == "security").Cells["Status"]);
|
||||
|
||||
var charts = await workspace.ListAsync(ResourceCatalog.HelmChartsId, null, "vault", TestContext.Current.CancellationToken);
|
||||
var chart = Assert.Single(charts.Value!);
|
||||
Assert.Equal("vault", chart.Cells["Chart"]);
|
||||
Assert.Equal("2", chart.Cells["Releases"]);
|
||||
|
||||
var history = await workspace.HelmHistoryAsync("vault", "security", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(2, history.Value!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Helm_history_reports_a_disconnected_session_and_list_errors() {
|
||||
var workspace = new ClusterWorkspace();
|
||||
var disconnected = await workspace.HelmHistoryAsync("vault", "security", TestContext.Current.CancellationToken);
|
||||
Assert.False(disconnected.IsSuccess);
|
||||
|
||||
var session = new FakeClusterSession { HelmError = "secrets forbidden" };
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var failed = await workspace.ListAsync(ResourceCatalog.HelmReleasesId, null, null, TestContext.Current.CancellationToken);
|
||||
Assert.False(failed.IsSuccess);
|
||||
Assert.Contains("secrets forbidden", failed.Messages[0], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_dapr_sidecars_and_control_plane() {
|
||||
var session = new FakeClusterSession();
|
||||
session.Set(
|
||||
"pods",
|
||||
DaprPod("orders", "apps", """{ "dapr.io/enabled": "true" }""", false),
|
||||
DaprPod("plain", "apps", "{}", false),
|
||||
DaprPod("operator", "dapr", "{}", true),
|
||||
DaprPod("placement", "dapr", "{}", false));
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var sidecars = await workspace.ListAsync(ResourceCatalog.DaprSidecarsId, null, null, TestContext.Current.CancellationToken);
|
||||
Assert.True(sidecars.IsSuccess, string.Join("; ", sidecars.Messages));
|
||||
Assert.Equal(["operator", "orders"], sidecars.Value!.Select(row => row.Name).Order(StringComparer.Ordinal).ToArray());
|
||||
|
||||
var plane = await workspace.ListAsync(ResourceCatalog.DaprControlPlaneId, null, "operator", TestContext.Current.CancellationToken);
|
||||
Assert.Equal("operator", Assert.Single(plane.Value!).Name);
|
||||
Assert.Equal("dapr", session.LastListNamespace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_definitions_uses_the_crd_endpoint() {
|
||||
var session = new FakeClusterSession { Crds = [WidgetCrd()] };
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var listed = await workspace.ListAsync("customresourcedefinitions", null, "Widget", TestContext.Current.CancellationToken);
|
||||
Assert.Equal("Widget", Assert.Single(listed.Value!).Cells["Resource"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_strips_status_and_resolves_the_builtin_ref() {
|
||||
var workspace = new ClusterWorkspace();
|
||||
var disconnected = await workspace.ApplyDocumentAsync(Doc("""{ "kind": "ConfigMap" }"""), TestContext.Current.CancellationToken);
|
||||
Assert.False(disconnected.IsSuccess);
|
||||
|
||||
var session = new FakeClusterSession();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var applied = await workspace.ApplyDocumentAsync(Doc("""
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ConfigMap",
|
||||
"metadata": { "name": "app", "namespace": "default", "generation": 4, "resourceVersion": "9" },
|
||||
"data": { "key": "value" },
|
||||
"status": { "phase": "Ready" }
|
||||
}
|
||||
"""), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(applied.IsSuccess, string.Join("; ", applied.Messages));
|
||||
Assert.Null(session.Applied!["status"]);
|
||||
Assert.Null(session.Applied["metadata"]?["generation"]);
|
||||
Assert.Equal("ConfigMap", session.AppliedRef!.Kind);
|
||||
Assert.Equal("configmaps", session.AppliedRef.Plural);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Related_pods_follow_the_owner_selector() {
|
||||
var session = new FakeClusterSession();
|
||||
session.Set(
|
||||
"pods",
|
||||
Pod("web-0", "apps", "Running", """{ "app": "web" }"""),
|
||||
Pod("other", "apps", "Running", """{ "app": "db" }"""));
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var owner = ResourceRow.From(Deployment("web", "apps"), ResourceCatalog.Find("deployments")!);
|
||||
|
||||
var related = await workspace.RelatedPodsAsync(owner, TestContext.Current.CancellationToken);
|
||||
Assert.Equal("web-0", Assert.Single(related.Value!).Name);
|
||||
|
||||
session.FailList("pods", "pods forbidden");
|
||||
var failed = await workspace.RelatedPodsAsync(owner, TestContext.Current.CancellationToken);
|
||||
Assert.False(failed.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Events_match_the_object_and_declared_workloads() {
|
||||
var session = new FakeClusterSession();
|
||||
session.Set("events", Event("web"), Event("worker"), Event("other"));
|
||||
var workspace = new ClusterWorkspace();
|
||||
var disconnected = await workspace.EventsForAsync(Row("web"), TestContext.Current.CancellationToken);
|
||||
Assert.False(disconnected.IsSuccess);
|
||||
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var direct = await workspace.EventsForAsync(Row("web"), TestContext.Current.CancellationToken);
|
||||
Assert.Equal("ev-web", Assert.Single(direct.Value!).Name);
|
||||
|
||||
var app = ResourceRow.From(Doc("""
|
||||
{
|
||||
"metadata": { "name": "store", "namespace": "apps", "uid": "store" },
|
||||
"spec": { "workloads": [{ "kind": "Deployment", "name": "worker" }] }
|
||||
}
|
||||
"""), ResourceCatalog.ApplicationsDescriptor);
|
||||
var declared = await workspace.EventsForAsync(app, TestContext.Current.CancellationToken);
|
||||
Assert.Equal("ev-worker", Assert.Single(declared.Value!).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cluster_issues_and_drain_preview_use_the_shared_lists() {
|
||||
var workspace = new ClusterWorkspace();
|
||||
Assert.False((await workspace.GetClusterIssuesAsync(TestContext.Current.CancellationToken)).IsSuccess);
|
||||
Assert.False((await workspace.PreviewDrainAsync(["node-a"], TestContext.Current.CancellationToken)).IsSuccess);
|
||||
|
||||
var session = new FakeClusterSession();
|
||||
session.Set("nodes", Doc("""
|
||||
{
|
||||
"metadata": { "name": "n1", "uid": "n1", "creationTimestamp": "2026-08-19T10:00:00Z" },
|
||||
"status": { "conditions": [
|
||||
{ "type": "Ready", "status": "True" },
|
||||
{ "type": "MemoryPressure", "status": "True", "message": "kubelet has memory pressure" }
|
||||
] }
|
||||
}
|
||||
"""));
|
||||
session.Set("pods", Pod("api-0", "apps", "Running", "{}", "ReplicaSet", "node-a"));
|
||||
session.FailList("events", "events forbidden");
|
||||
var workspaceConnected = new ClusterWorkspace();
|
||||
await workspaceConnected.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var issues = await workspaceConnected.GetClusterIssuesAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(issues.IsSuccess, string.Join("; ", issues.Messages));
|
||||
Assert.Contains(issues.Value!.Warnings, issue => issue.Message.Contains("memory pressure", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
session.FailList("nodes", "nodes forbidden");
|
||||
var bothFailed = await workspaceConnected.GetClusterIssuesAsync(TestContext.Current.CancellationToken);
|
||||
Assert.False(bothFailed.IsSuccess);
|
||||
|
||||
var drainSession = new FakeClusterSession();
|
||||
drainSession.Set("pods", Pod("api-0", "apps", "Running", "{}", "ReplicaSet", "node-a"));
|
||||
drainSession.Set("poddisruptionbudgets");
|
||||
var disconnectedDrain = new ClusterWorkspace();
|
||||
var preview = await disconnectedDrain.PreviewDrainAsync(["node-a"], TestContext.Current.CancellationToken);
|
||||
Assert.False(preview.IsSuccess);
|
||||
|
||||
var connected = new ClusterWorkspace();
|
||||
await connected.ConnectAsync(drainSession, TestContext.Current.CancellationToken);
|
||||
preview = await connected.PreviewDrainAsync(["node-a", "node-a"], TestContext.Current.CancellationToken);
|
||||
Assert.True(preview.IsSuccess, string.Join("; ", preview.Messages));
|
||||
var plan = Assert.Single(preview.Value!.Nodes);
|
||||
Assert.Equal(DrainPlan.Evict, Assert.Single(plan.Pods).Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Volume_files_list_read_and_write_through_the_mounted_pod() {
|
||||
var session = new FakeClusterSession {
|
||||
ExecText = "root\n0\n0\n",
|
||||
ExecBytes = new ExecBytesResult("conf\n"u8.ToArray(), """{"status":"Success"}""")
|
||||
};
|
||||
session.Set("pods", VolumePod("pg-0", "Running"));
|
||||
var workspace = new ClusterWorkspace();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var mounts = await workspace.ListVolumeMountsAsync(Pvc(), TestContext.Current.CancellationToken);
|
||||
var mount = Assert.Single(mounts.Value!);
|
||||
Assert.Equal("/var/lib/postgresql/data/pgdata", mount.Root);
|
||||
|
||||
var identity = await workspace.GetVolumeIdentityAsync(mount, TestContext.Current.CancellationToken);
|
||||
Assert.Equal("root (0:0)", identity.Value);
|
||||
|
||||
var entries = await workspace.ListVolumeEntriesAsync(mount, "pgdata", TestContext.Current.CancellationToken);
|
||||
Assert.Equal("conf", Assert.Single(entries.Value!).Name);
|
||||
|
||||
session.ExecBytes = new ExecBytesResult("listen = 1\n"u8.ToArray(), "");
|
||||
var file = await workspace.ReadVolumeFileAsync(mount, "pgdata/postgresql.conf", TestContext.Current.CancellationToken);
|
||||
Assert.Equal("listen = 1\n", Encoding.UTF8.GetString(file.Value!));
|
||||
|
||||
var written = await workspace.WriteVolumeFileAsync(mount, "pgdata/postgresql.conf", "x"u8.ToArray(), TestContext.Current.CancellationToken);
|
||||
Assert.True(written.IsSuccess, string.Join("; ", written.Messages));
|
||||
Assert.Equal("x"u8.ToArray(), session.LastStdin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Volume_files_explain_missing_mounts_shells_and_escapes() {
|
||||
var workspace = new ClusterWorkspace();
|
||||
var disconnected = await workspace.ListVolumeMountsAsync(Pvc(), TestContext.Current.CancellationToken);
|
||||
Assert.False(disconnected.IsSuccess);
|
||||
|
||||
var session = new FakeClusterSession();
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var unbound = await workspace.ListVolumeMountsAsync(Doc("""{ "kind": "PersistentVolume", "metadata": { "name": "pv" } }"""), TestContext.Current.CancellationToken);
|
||||
Assert.Contains("not bound", unbound.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
session.Set("pods", VolumePod("pg-0", "Pending"));
|
||||
var pending = await workspace.ListVolumeMountsAsync(Pvc(), TestContext.Current.CancellationToken);
|
||||
Assert.Contains("not Running", pending.Messages[0], StringComparison.Ordinal);
|
||||
|
||||
session.Set("pods");
|
||||
var none = await workspace.ListVolumeMountsAsync(Pvc(), TestContext.Current.CancellationToken);
|
||||
Assert.Contains("No running pod", none.Messages[0], StringComparison.Ordinal);
|
||||
|
||||
session.Set("pods", VolumePod("pg-0", "Running"));
|
||||
var mounts = await workspace.ListVolumeMountsAsync(Pvc(), TestContext.Current.CancellationToken);
|
||||
var mount = mounts.Value![0];
|
||||
var commands = session.Commands.Count;
|
||||
var escape = await workspace.ReadVolumeFileAsync(mount, "../etc/passwd", TestContext.Current.CancellationToken);
|
||||
Assert.False(escape.IsSuccess);
|
||||
Assert.Equal(commands, session.Commands.Count);
|
||||
|
||||
session.ExecBytes = new ExecBytesResult([], "cat: not found");
|
||||
var stderr = await workspace.ListVolumeEntriesAsync(mount, "", TestContext.Current.CancellationToken);
|
||||
Assert.False(stderr.IsSuccess);
|
||||
Assert.Contains("not found", stderr.Messages[0], StringComparison.Ordinal);
|
||||
|
||||
session.ExecError = "executable file not found in $PATH";
|
||||
var identity = await workspace.GetVolumeIdentityAsync(mount, TestContext.Current.CancellationToken);
|
||||
Assert.Contains("no shell", identity.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Node_images_classify_cached_images_and_survive_a_pod_list_error() {
|
||||
var workspace = new ClusterWorkspace();
|
||||
var node = Doc("""
|
||||
{
|
||||
"metadata": { "name": "node-a" },
|
||||
"status": { "images": [{ "names": ["nginx:1.27"], "sizeBytes": 1000 }] }
|
||||
}
|
||||
""");
|
||||
Assert.False((await workspace.NodeImagesAsync(node, TestContext.Current.CancellationToken)).IsSuccess);
|
||||
|
||||
var session = new FakeClusterSession();
|
||||
session.Set("pods", Pod("web", "default", "Running"));
|
||||
await workspace.ConnectAsync(session, TestContext.Current.CancellationToken);
|
||||
var report = await workspace.NodeImagesAsync(node, TestContext.Current.CancellationToken);
|
||||
Assert.True(report.IsSuccess, string.Join("; ", report.Messages));
|
||||
Assert.Equal("spec.nodeName=node-a", session.LastOptions!.FieldSelector);
|
||||
var image = Assert.Single(report.Value!.Images);
|
||||
Assert.Equal(NodeImages.Used, image.State);
|
||||
Assert.Equal("default/web", image.Pods);
|
||||
|
||||
session.FailList("pods", "pods is forbidden");
|
||||
report = await workspace.NodeImagesAsync(node, TestContext.Current.CancellationToken);
|
||||
Assert.Equal("—", report.Value!.Images[0].State);
|
||||
Assert.Contains("pods is forbidden", report.Value.Caption, StringComparison.Ordinal);
|
||||
|
||||
var nameless = await workspace.NodeImagesAsync(Doc("""{ "metadata": {} }"""), TestContext.Current.CancellationToken);
|
||||
Assert.False(nameless.IsSuccess);
|
||||
}
|
||||
|
||||
private static ResourceRow Row(string name) =>
|
||||
ResourceRow.From(Doc($$"""
|
||||
{ "metadata": { "name": "{{name}}", "namespace": "apps", "uid": "{{name}}" } }
|
||||
"""), ResourceCatalog.Find("configmaps")!);
|
||||
|
||||
private static JsonObject Pvc() =>
|
||||
Doc("""
|
||||
{ "kind": "PersistentVolumeClaim", "metadata": { "name": "pg-data", "namespace": "postgresql" } }
|
||||
""");
|
||||
|
||||
private static JsonObject VolumePod(string name, string phase) =>
|
||||
Doc($$"""
|
||||
{
|
||||
"metadata": { "name": "{{name}}", "namespace": "postgresql" },
|
||||
"spec": {
|
||||
"volumes": [{ "name": "data", "persistentVolumeClaim": { "claimName": "pg-data" } }],
|
||||
"containers": [{
|
||||
"name": "postgres",
|
||||
"volumeMounts": [{ "name": "data", "mountPath": "/var/lib/postgresql/data", "subPath": "pgdata" }]
|
||||
}]
|
||||
},
|
||||
"status": { "phase": "{{phase}}" }
|
||||
}
|
||||
""");
|
||||
|
||||
private static JsonObject WidgetCrd() =>
|
||||
Doc("""
|
||||
{
|
||||
"metadata": { "name": "widgets.example.com" },
|
||||
"spec": {
|
||||
"group": "example.com",
|
||||
"scope": "Namespaced",
|
||||
"names": { "kind": "Widget", "plural": "widgets" },
|
||||
"versions": [{ "name": "v1", "storage": true, "served": true }]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
private static JsonObject Deployment(string name, string ns, bool manifest = false) {
|
||||
var labels = manifest
|
||||
? """{ "app": "web", "app.kubernetes.io/name": "web", "app.kubernetes.io/instance": "web" }"""
|
||||
: """{ "app": "web" }""";
|
||||
|
||||
return Doc($$"""
|
||||
{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": { "name": "{{name}}", "namespace": "{{ns}}", "uid": "{{name}}", "labels": {{labels}} },
|
||||
"spec": {
|
||||
"replicas": 1,
|
||||
"selector": { "matchLabels": { "app": "web" } }
|
||||
},
|
||||
"status": { "replicas": 1, "readyReplicas": 1 }
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static JsonObject Pod(
|
||||
string name,
|
||||
string ns,
|
||||
string phase,
|
||||
string labels = """{ "app": "web" }""",
|
||||
string? ownerKind = null,
|
||||
string? node = null) {
|
||||
var metadata = new JsonObject {
|
||||
["name"] = name,
|
||||
["namespace"] = ns,
|
||||
["uid"] = name,
|
||||
["labels"] = JsonNode.Parse(labels) as JsonObject
|
||||
};
|
||||
|
||||
if (ownerKind is not null)
|
||||
metadata["ownerReferences"] = new JsonArray {
|
||||
new JsonObject { ["kind"] = ownerKind, ["name"] = "owner", ["controller"] = true }
|
||||
};
|
||||
|
||||
var spec = new JsonObject {
|
||||
["containers"] = new JsonArray {
|
||||
new JsonObject { ["name"] = "app", ["image"] = "nginx:1.27" }
|
||||
}
|
||||
};
|
||||
|
||||
if (node is not null)
|
||||
spec["nodeName"] = node;
|
||||
|
||||
return new JsonObject {
|
||||
["metadata"] = metadata,
|
||||
["spec"] = spec,
|
||||
["status"] = JsonNode.Parse($$"""
|
||||
{
|
||||
"phase": "{{phase}}",
|
||||
"conditions": [{ "type": "Ready", "status": "True" }],
|
||||
"containerStatuses": [{ "name": "app", "ready": true, "restartCount": 0, "state": { "running": {} } }]
|
||||
}
|
||||
""")
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject DaprPod(string name, string ns, string annotations, bool sidecar) {
|
||||
var containers = sidecar
|
||||
? """[{ "name": "daprd" }, { "name": "app" }]"""
|
||||
: """[{ "name": "app" }]""";
|
||||
|
||||
return Doc($$"""
|
||||
{
|
||||
"metadata": { "name": "{{name}}", "namespace": "{{ns}}", "uid": "{{name}}", "annotations": {{annotations}} },
|
||||
"spec": { "containers": {{containers}} },
|
||||
"status": { "phase": "Running" }
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static JsonObject Helm(string name, string ns, int revision, string status) =>
|
||||
Doc($$"""
|
||||
{
|
||||
"name": "{{name}}",
|
||||
"namespace": "{{ns}}",
|
||||
"version": {{revision}},
|
||||
"info": { "status": "{{status}}", "last_deployed": "2026-08-19T12:00:00Z" },
|
||||
"chart": { "metadata": { "name": "vault", "version": "1.2.3", "appVersion": "2.0.0" } }
|
||||
}
|
||||
""");
|
||||
|
||||
private static JsonObject Event(string name) =>
|
||||
Doc($$"""
|
||||
{
|
||||
"metadata": { "name": "ev-{{name}}", "namespace": "apps", "uid": "ev-{{name}}" },
|
||||
"involvedObject": { "name": "{{name}}", "kind": "ConfigMap" },
|
||||
"type": "Warning",
|
||||
"reason": "Failed",
|
||||
"message": "boom"
|
||||
}
|
||||
""");
|
||||
|
||||
private static JsonObject Doc(string json) =>
|
||||
JsonNode.Parse(json) as JsonObject ?? throw new InvalidOperationException("expected object");
|
||||
}
|
||||
@ -44,6 +44,71 @@ public class DrainPlanTests {
|
||||
Assert.Contains("not deleted", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForNode_skips_mirror_terminating_and_unmanaged_pods() {
|
||||
var pods = new[] {
|
||||
Mirror("static"),
|
||||
Terminating("gone"),
|
||||
Unmanaged("manual"),
|
||||
Pod("keep", "apps", "node-a", "Running", "ReplicaSet", new JsonObject { ["app"] = "api" }),
|
||||
Pod("drop", "apps", "node-a", "Running", "ReplicaSet", new JsonObject { ["app"] = "batch" })
|
||||
};
|
||||
var budgets = new[] {
|
||||
new JsonObject {
|
||||
["metadata"] = new JsonObject { ["name"] = "api", ["namespace"] = "apps" },
|
||||
["spec"] = new JsonObject {
|
||||
["selector"] = new JsonObject {
|
||||
["matchExpressions"] = new JsonArray {
|
||||
new JsonObject {
|
||||
["key"] = "app",
|
||||
["operator"] = "In",
|
||||
["values"] = new JsonArray("api")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["status"] = new JsonObject { ["disruptionsAllowed"] = 0 }
|
||||
},
|
||||
new JsonObject {
|
||||
["metadata"] = new JsonObject { ["name"] = "ignored", ["namespace"] = "apps" },
|
||||
["spec"] = new JsonObject { ["selector"] = new JsonObject() }
|
||||
}
|
||||
};
|
||||
|
||||
var plan = DrainPlan.ForNode("node-a", pods, budgets);
|
||||
|
||||
Assert.Equal("mirror pod", Reason(plan, "static"));
|
||||
Assert.Equal("terminating", Reason(plan, "gone"));
|
||||
Assert.Equal("no controller", Reason(plan, "manual"));
|
||||
Assert.Equal(DrainPlan.Blocked, Action(plan, "keep"));
|
||||
Assert.Equal(DrainPlan.Evict, Action(plan, "drop"));
|
||||
|
||||
var empty = DrainPlan.Format([new DrainNodePlan("idle", [])]);
|
||||
Assert.Contains("Will move (0)", empty);
|
||||
Assert.Contains("None", empty);
|
||||
}
|
||||
|
||||
private static JsonObject Mirror(string name) {
|
||||
var pod = Pod(name, "kube-system", "node-a", "Running", "Node", []);
|
||||
pod["metadata"]!["annotations"] = new JsonObject { ["kubernetes.io/config.mirror"] = "mirror" };
|
||||
|
||||
return pod;
|
||||
}
|
||||
|
||||
private static JsonObject Terminating(string name) {
|
||||
var pod = Pod(name, "apps", "node-a", "Running", "ReplicaSet", []);
|
||||
pod["metadata"]!["deletionTimestamp"] = "2026-08-19T10:00:00Z";
|
||||
|
||||
return pod;
|
||||
}
|
||||
|
||||
private static JsonObject Unmanaged(string name) =>
|
||||
new() {
|
||||
["metadata"] = new JsonObject { ["name"] = name, ["namespace"] = "apps" },
|
||||
["spec"] = new JsonObject { ["nodeName"] = "node-a" },
|
||||
["status"] = new JsonObject { ["phase"] = "Running" }
|
||||
};
|
||||
|
||||
private static string Action(DrainNodePlan plan, string name) =>
|
||||
plan.Pods.Single(pod => pod.Name == name).Action;
|
||||
|
||||
|
||||
@ -40,4 +40,20 @@ public class ErrorReportTests {
|
||||
|
||||
Assert.False(ErrorReport.IsAbandonedTransportRead(aggregate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_includes_the_product_and_the_exception_message() {
|
||||
var text = ErrorReport.Format(new InvalidOperationException("boom"));
|
||||
|
||||
Assert.Contains("boom", text, StringComparison.Ordinal);
|
||||
Assert.Contains(AppInfo.ProductName, text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repeated_inner_exceptions_are_walked_once() {
|
||||
var inner = new InvalidOperationException("inner");
|
||||
var aggregate = new AggregateException(inner, inner);
|
||||
|
||||
Assert.False(ErrorReport.IsAbandonedTransportRead(aggregate));
|
||||
}
|
||||
}
|
||||
|
||||
422
src/MaksIT.ClusterConsole.Tests/FakeClusterSession.cs
Normal file
422
src/MaksIT.ClusterConsole.Tests/FakeClusterSession.cs
Normal file
@ -0,0 +1,422 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using k8s;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
using MaksIT.ClusterConsole.Client.Cluster;
|
||||
using MaksIT.ClusterConsole.Client.Terminal;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.Tests;
|
||||
|
||||
internal sealed class FakeClusterSession : IClusterSession {
|
||||
private readonly Dictionary<string, IReadOnlyList<JsonObject>> _lists = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, Result<IReadOnlyList<JsonObject>>> _listFailures = new(StringComparer.Ordinal);
|
||||
|
||||
public string ContextName => "test";
|
||||
|
||||
public IKubernetes Kubernetes =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public IReadOnlyList<JsonObject> Crds { get; set; } = [];
|
||||
|
||||
public string? CrdError { get; set; }
|
||||
|
||||
public IReadOnlyList<JsonObject> HelmDocuments { get; set; } = [];
|
||||
|
||||
public string? HelmError { get; set; }
|
||||
|
||||
public Dictionary<string, ResourceMetrics> PodMetrics { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public Dictionary<string, ResourceMetrics> NodeMetrics { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public bool PodMetricsFail { get; set; }
|
||||
|
||||
public bool NodeMetricsFail { get; set; }
|
||||
|
||||
public double CpuAllocatable { get; set; } = 8;
|
||||
|
||||
public bool CpuFails { get; set; }
|
||||
|
||||
public int CpuCalls { get; private set; }
|
||||
|
||||
public JsonObject? Gotten { get; set; }
|
||||
|
||||
public string? GetError { get; set; }
|
||||
|
||||
public (string Kind, string Name, string? Namespace)? LastGet { get; private set; }
|
||||
|
||||
public string LogsText { get; set; } = "";
|
||||
|
||||
public string? LogsError { get; set; }
|
||||
|
||||
public int LastTail { get; private set; }
|
||||
|
||||
public ExecBytesResult ExecBytes { get; set; } = new([], "");
|
||||
|
||||
public Result<ExecBytesResult>? ExecBytesResult { get; set; }
|
||||
|
||||
public string ExecText { get; set; } = "";
|
||||
|
||||
public string? ExecError { get; set; }
|
||||
|
||||
public List<IReadOnlyList<string>> Commands { get; } = [];
|
||||
|
||||
public byte[]? LastStdin { get; private set; }
|
||||
|
||||
public ResourceListOptions? LastOptions { get; private set; }
|
||||
|
||||
public string? LastPlural { get; private set; }
|
||||
|
||||
public string? LastListNamespace { get; private set; }
|
||||
|
||||
public int Deletes { get; private set; }
|
||||
|
||||
public int Scales { get; private set; }
|
||||
|
||||
public int Restarts { get; private set; }
|
||||
|
||||
public int Applies { get; private set; }
|
||||
|
||||
public int LastReplicas { get; private set; }
|
||||
|
||||
public JsonObject? Applied { get; private set; }
|
||||
|
||||
public ResourceRef? AppliedRef { get; private set; }
|
||||
|
||||
public bool ApplyFails { get; set; }
|
||||
|
||||
public bool DeleteFails { get; set; }
|
||||
|
||||
public void Set(string id, params JsonObject[] items) {
|
||||
var descriptor = ResourceCatalog.Find(id) ?? throw new InvalidOperationException(id);
|
||||
_lists[Key(descriptor.ToRef())] = items;
|
||||
}
|
||||
|
||||
public void FailList(string id, string message) {
|
||||
var descriptor = ResourceCatalog.Find(id) ?? throw new InvalidOperationException(id);
|
||||
_listFailures[Key(descriptor.ToRef())] = Result<IReadOnlyList<JsonObject>>.NotFound(null, message);
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Disposed = true;
|
||||
|
||||
public Task<Result<IReadOnlyList<JsonObject>>> ListAsync(
|
||||
ResourceRef resource,
|
||||
string? @namespace,
|
||||
CancellationToken cancellationToken = default,
|
||||
ResourceListOptions? options = null) {
|
||||
LastPlural = resource.Plural;
|
||||
LastListNamespace = @namespace;
|
||||
LastOptions = options;
|
||||
|
||||
if (options is not null && string.IsNullOrEmpty(options.ResourceVersion))
|
||||
options.ResourceVersion = "rv-1";
|
||||
|
||||
var key = Key(resource);
|
||||
|
||||
if (_listFailures.TryGetValue(key, out var failed))
|
||||
return Task.FromResult(failed);
|
||||
|
||||
_lists.TryGetValue(key, out var items);
|
||||
items ??= [];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(@namespace) && @namespace != Configuration.AllNamespaces)
|
||||
items = items.Where(item => JsonPath.Namespace(item) == @namespace).ToList();
|
||||
|
||||
return Task.FromResult(Result<IReadOnlyList<JsonObject>>.Ok(items));
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<ClusterWatchEvent> WatchAsync(
|
||||
ResourceRef resource,
|
||||
string? @namespace,
|
||||
string? resourceVersion,
|
||||
string? labelSelector,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Result<JsonObject>> GetAsync(
|
||||
ResourceRef resource,
|
||||
string name,
|
||||
string? @namespace,
|
||||
CancellationToken cancellationToken = default) {
|
||||
LastGet = (resource.Kind, name, @namespace);
|
||||
|
||||
if (GetError is not null)
|
||||
return Task.FromResult(Result<JsonObject>.NotFound(null, GetError));
|
||||
|
||||
return Task.FromResult(Result<JsonObject>.Ok(Gotten ?? new JsonObject {
|
||||
["kind"] = resource.Kind,
|
||||
["metadata"] = new JsonObject { ["name"] = name, ["namespace"] = @namespace }
|
||||
}));
|
||||
}
|
||||
|
||||
public Task<Result<JsonObject>> ApplyAsync(
|
||||
JsonObject document,
|
||||
ResourceRef? resource = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
Applies++;
|
||||
Applied = document;
|
||||
AppliedRef = resource;
|
||||
|
||||
if (ApplyFails)
|
||||
return Task.FromResult(Result<JsonObject>.Conflict(null, "conflict"));
|
||||
|
||||
return Task.FromResult(Result<JsonObject>.Ok(document));
|
||||
}
|
||||
|
||||
public Task<Result> DeleteAsync(
|
||||
ResourceRef resource,
|
||||
string name,
|
||||
string? @namespace,
|
||||
bool force = false,
|
||||
CancellationToken cancellationToken = default) {
|
||||
Deletes++;
|
||||
|
||||
return Task.FromResult(DeleteFails ? Result.NotFound("missing") : Result.Ok());
|
||||
}
|
||||
|
||||
public Task<Result> ForceDeleteNamespaceAsync(string name, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result.NotFound("unused"));
|
||||
|
||||
public Task<Result> ScaleAsync(
|
||||
ResourceRef resource,
|
||||
string name,
|
||||
string? @namespace,
|
||||
int replicas,
|
||||
CancellationToken cancellationToken = default) {
|
||||
Scales++;
|
||||
LastReplicas = replicas;
|
||||
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
|
||||
public Task<Result> RestartAsync(
|
||||
ResourceRef resource,
|
||||
string name,
|
||||
string? @namespace,
|
||||
CancellationToken cancellationToken = default) {
|
||||
Restarts++;
|
||||
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
|
||||
public Task<Result<string>> GetLogsAsync(
|
||||
string podName,
|
||||
string @namespace,
|
||||
string? container,
|
||||
bool previous,
|
||||
int tailLines,
|
||||
CancellationToken cancellationToken = default) {
|
||||
LastTail = tailLines;
|
||||
|
||||
if (LogsError is not null)
|
||||
return Task.FromResult(Result<string>.NotFound(null, LogsError));
|
||||
|
||||
return Task.FromResult(Result<string>.Ok(LogsText));
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<string> FollowLogsAsync(
|
||||
string podName,
|
||||
string @namespace,
|
||||
string? container,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Result<PortForwardHandle>> PortForwardAsync(
|
||||
string podName,
|
||||
string @namespace,
|
||||
int containerPort,
|
||||
int localPort,
|
||||
int requestedPort = 0,
|
||||
Func<CancellationToken, Task<Result<PortForwardEndpoint>>>? resolveTarget = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<PortForwardHandle>.NotFound(null, "unused"));
|
||||
|
||||
public Task<Result<IReadOnlyList<JsonObject>>> ListCustomResourceDefinitionsAsync(
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (CrdError is not null)
|
||||
return Task.FromResult(Result<IReadOnlyList<JsonObject>>.NotFound(null, CrdError));
|
||||
|
||||
return Task.FromResult(Result<IReadOnlyList<JsonObject>>.Ok(Crds));
|
||||
}
|
||||
|
||||
public Task<Result<bool>> HasApiGroupAsync(string group, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<bool>.Ok(false));
|
||||
|
||||
public Task<Result<ClusterSummary>> GetSummaryAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<ClusterSummary>.NotFound(null, "unused"));
|
||||
|
||||
public Task<Result<ClusterUsage>> GetClusterUsageAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<ClusterUsage>.NotFound(null, "unused"));
|
||||
|
||||
public Task<Result<double>> GetClusterCpuAllocatableAsync(CancellationToken cancellationToken = default) {
|
||||
CpuCalls++;
|
||||
|
||||
if (CpuFails)
|
||||
return Task.FromResult(Result<double>.ServiceUnavailable(0, "no metrics"));
|
||||
|
||||
return Task.FromResult(Result<double>.Ok(CpuAllocatable));
|
||||
}
|
||||
|
||||
public Task<Result> PatchContainerResourcesAsync(
|
||||
WorkloadContainerLimit row,
|
||||
string cpuLimit,
|
||||
string memoryLimit,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result.NotFound("unused"));
|
||||
|
||||
public Task<Result<IReadOnlyDictionary<string, ResourceMetrics>>> GetPodMetricsAsync(
|
||||
string? @namespace,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (PodMetricsFail)
|
||||
return Task.FromResult(Result<IReadOnlyDictionary<string, ResourceMetrics>>.NotFound(null, "metrics unavailable"));
|
||||
|
||||
return Task.FromResult(Result<IReadOnlyDictionary<string, ResourceMetrics>>.Ok(PodMetrics));
|
||||
}
|
||||
|
||||
public Task<Result<IReadOnlyDictionary<string, ResourceMetrics>>> GetNodeMetricsAsync(
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (NodeMetricsFail)
|
||||
return Task.FromResult(Result<IReadOnlyDictionary<string, ResourceMetrics>>.NotFound(null, "metrics unavailable"));
|
||||
|
||||
return Task.FromResult(Result<IReadOnlyDictionary<string, ResourceMetrics>>.Ok(NodeMetrics));
|
||||
}
|
||||
|
||||
public Task<Result<IReadOnlyList<JsonObject>>> ListHelmReleaseDocumentsAsync(
|
||||
string? @namespace,
|
||||
string? releaseName,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (HelmError is not null)
|
||||
return Task.FromResult(Result<IReadOnlyList<JsonObject>>.NotFound(null, HelmError));
|
||||
|
||||
IReadOnlyList<JsonObject> documents = HelmDocuments;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(@namespace) && @namespace != Configuration.AllNamespaces)
|
||||
documents = documents.Where(doc => doc["namespace"]?.GetValue<string>() == @namespace).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(releaseName))
|
||||
documents = documents.Where(doc => doc["name"]?.GetValue<string>() == releaseName).ToList();
|
||||
|
||||
return Task.FromResult(Result<IReadOnlyList<JsonObject>>.Ok(documents));
|
||||
}
|
||||
|
||||
public Task<Result<string>> ExecAsync(
|
||||
string podName,
|
||||
string @namespace,
|
||||
string? container,
|
||||
IReadOnlyList<string> command,
|
||||
CancellationToken cancellationToken = default) {
|
||||
Commands.Add(command);
|
||||
|
||||
if (ExecError is not null)
|
||||
return Task.FromResult(Result<string>.NotFound(null, ExecError));
|
||||
|
||||
return Task.FromResult(Result<string>.Ok(ExecText));
|
||||
}
|
||||
|
||||
public Task<Result<ExecBytesResult>> ExecBytesAsync(
|
||||
string podName,
|
||||
string @namespace,
|
||||
string? container,
|
||||
IReadOnlyList<string> command,
|
||||
byte[]? stdin = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
Commands.Add(command);
|
||||
LastStdin = stdin;
|
||||
|
||||
return Task.FromResult(ExecBytesResult ?? Result<ExecBytesResult>.Ok(ExecBytes));
|
||||
}
|
||||
|
||||
public Task<Result<PodTerminalConnection>> OpenTerminalAsync(
|
||||
string podName,
|
||||
string @namespace,
|
||||
string? container,
|
||||
string? shell,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<PodTerminalConnection>.NotFound(null, "unused"));
|
||||
|
||||
public Task<Result> CordonAsync(string nodeName, bool unschedulable, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result.NotFound("unused"));
|
||||
|
||||
public Task<Result> DrainAsync(string nodeName, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result.NotFound("unused"));
|
||||
|
||||
public Task<Result> TriggerCronJobAsync(string name, string @namespace, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result.NotFound("unused"));
|
||||
|
||||
public Task<Result> ResizePersistentVolumeClaimAsync(
|
||||
string name,
|
||||
string @namespace,
|
||||
string storage,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result.NotFound("unused"));
|
||||
|
||||
public Task<Result> PauseRolloutAsync(
|
||||
string name,
|
||||
string @namespace,
|
||||
bool paused,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result.NotFound("unused"));
|
||||
|
||||
public Task<Result<IReadOnlyList<string>>> RolloutHistoryAsync(
|
||||
string name,
|
||||
string @namespace,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<IReadOnlyList<string>>.NotFound(null, "unused"));
|
||||
|
||||
public Task<Result> UndoRolloutAsync(string name, string @namespace, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result.NotFound("unused"));
|
||||
|
||||
public Task<Result> SetCertificateApprovalAsync(string name, bool approved, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result.NotFound("unused"));
|
||||
|
||||
public Task<Result<string>> CreateServiceAccountTokenAsync(
|
||||
string name,
|
||||
string @namespace,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<string>.NotFound(null, "unused"));
|
||||
|
||||
public Task<Result<string>> AttachAsync(
|
||||
string podName,
|
||||
string @namespace,
|
||||
string? container,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<string>.NotFound(null, "unused"));
|
||||
|
||||
public Task<Result> AddEphemeralContainerAsync(
|
||||
string podName,
|
||||
string @namespace,
|
||||
string image,
|
||||
string? targetContainer,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result.NotFound("unused"));
|
||||
|
||||
public Task<Result<StorageReclaimPreview>> PreviewStorageClassReclaimAsync(
|
||||
string name,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<StorageReclaimPreview>.NotFound(null, "unused"));
|
||||
|
||||
public Task<Result<StorageReclaimPreview>> PreviewPersistentVolumeReclaimAsync(
|
||||
IReadOnlyList<string> names,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<StorageReclaimPreview>.NotFound(null, "unused"));
|
||||
|
||||
public Task<Result<StorageReclaimOutcome>> ApplyStorageClassReclaimAsync(
|
||||
string name,
|
||||
string policy,
|
||||
bool updateVolumes,
|
||||
bool updateClass,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<StorageReclaimOutcome>.NotFound(null, "unused"));
|
||||
|
||||
public Task<Result<StorageReclaimOutcome>> ApplyPersistentVolumeReclaimAsync(
|
||||
IReadOnlyList<string> names,
|
||||
string policy,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Result<StorageReclaimOutcome>.NotFound(null, "unused"));
|
||||
|
||||
private static string Key(ResourceRef resource) =>
|
||||
resource.Group + "/" + resource.Plural;
|
||||
}
|
||||
70
src/MaksIT.ClusterConsole.Tests/JsonPathTests.cs
Normal file
70
src/MaksIT.ClusterConsole.Tests/JsonPathTests.cs
Normal file
@ -0,0 +1,70 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.Tests;
|
||||
|
||||
public class JsonPathTests {
|
||||
[Fact]
|
||||
public void Volume_claim_and_service_addresses_follow_the_bound_object() {
|
||||
Assert.Equal("", JsonPath.VolumeClaim(null));
|
||||
Assert.Equal("pg-data", JsonPath.Read(Doc("""
|
||||
{ "spec": { "claimRef": { "name": "pg-data" } } }
|
||||
"""), "pv.claim"));
|
||||
Assert.Equal("postgresql/pg-data", JsonPath.VolumeClaim(Doc("""
|
||||
{ "spec": { "claimRef": { "namespace": "postgresql", "name": "pg-data" } } }
|
||||
""")));
|
||||
|
||||
var cluster = Doc("""{ "spec": { "type": "ClusterIP" } }""");
|
||||
Assert.Equal("Active", JsonPath.ServiceStatus(cluster));
|
||||
|
||||
var pending = Doc("""{ "spec": { "type": "LoadBalancer" } }""");
|
||||
Assert.Equal("Pending", JsonPath.ServiceStatus(pending));
|
||||
|
||||
var unreachable = Doc("""
|
||||
{
|
||||
"spec": { "type": "LoadBalancer", "loadBalancerIP": "172.16.0.11" },
|
||||
"metadata": { "annotations": { "lbipam.cilium.io/ips": "172.16.0.11, 172.16.0.12" } },
|
||||
"status": {
|
||||
"loadBalancer": { "ingress": [{ "ip": "172.16.0.11" }, { "hostname": "lb.example" }] },
|
||||
"conditions": [{ "type": "cilium.io/ipam-satisfied", "status": "False" }]
|
||||
}
|
||||
}
|
||||
""");
|
||||
Assert.Equal("Unreachable", JsonPath.ServiceStatus(unreachable));
|
||||
Assert.Equal("172.16.0.11,lb.example,172.16.0.12", JsonPath.ServiceExternalIp(unreachable));
|
||||
Assert.Equal("", JsonPath.ServiceStatus(null));
|
||||
Assert.Equal("", JsonPath.ServiceExternalIp(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Age_rounds_down_and_keeps_unparsed_text() {
|
||||
var now = DateTimeOffset.Parse("2026-08-19T15:00:00Z");
|
||||
Assert.Equal("2d", JsonPath.Age(now.AddDays(-2).AddHours(-3), now));
|
||||
Assert.Equal("3h", JsonPath.Age(now.AddHours(-3), now));
|
||||
Assert.Equal("3h5m", JsonPath.Age(now.AddHours(-3).AddMinutes(-5), now));
|
||||
Assert.Equal("4m", JsonPath.Age(now.AddMinutes(-4), now));
|
||||
Assert.Equal("12s", JsonPath.Age(now.AddSeconds(-12), now));
|
||||
Assert.Equal("0s", JsonPath.Age(now.AddMinutes(5), now));
|
||||
Assert.Equal("not-a-time", JsonPath.Age("not-a-time"));
|
||||
Assert.Equal("", JsonPath.Age((string?)null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Storage_version_falls_back_when_no_version_is_marked_storage() {
|
||||
var served = Doc("""
|
||||
{ "spec": { "versions": [{ "name": "v1alpha1", "served": true }, { "name": "v1beta1" }] } }
|
||||
""");
|
||||
Assert.Equal("v1alpha1", JsonPath.CrdStorageVersion(served));
|
||||
|
||||
var first = Doc("""{ "spec": { "versions": [{ "name": "v1" }] } }""");
|
||||
Assert.Equal("v1", JsonPath.CrdStorageVersion(first));
|
||||
Assert.Equal("", JsonPath.CrdStorageVersion(null));
|
||||
Assert.Equal("0/0", JsonPath.PodReady(Doc("""{ "status": {} }""")));
|
||||
Assert.Equal("", JsonPath.Read(null, "metadata.name"));
|
||||
Assert.Equal("", JsonPath.Read(Doc("""{ "metadata": { "name": "web" } }"""), " "));
|
||||
}
|
||||
|
||||
private static JsonObject Doc(string json) =>
|
||||
JsonNode.Parse(json) as JsonObject ?? throw new InvalidOperationException("expected object");
|
||||
}
|
||||
@ -220,6 +220,116 @@ public class KubeConfigServiceTests {
|
||||
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, Path.GetFileName(path) + ".bak.*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseContext_rejects_blank_missing_and_already_selected() {
|
||||
var service = new KubeConfigService();
|
||||
Assert.False(service.UseContext(" ", CopyFixture()).IsSuccess);
|
||||
|
||||
var missing = service.UseContext("lab", Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
|
||||
Assert.False(missing.IsSuccess);
|
||||
Assert.Contains("not found", missing.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var path = CopyFixture();
|
||||
var current = service.UseContext("lab", path);
|
||||
Assert.True(current.IsSuccess, string.Join("; ", current.Messages));
|
||||
Assert.Contains("Already using", current.Messages[0], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_and_current_context_report_missing_files_and_names() {
|
||||
var service = new KubeConfigService();
|
||||
Assert.False(service.DeleteContext("", true, CopyFixture()).IsSuccess);
|
||||
|
||||
var missingFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Assert.False(service.DeleteContext("lab", true, missingFile).IsSuccess);
|
||||
Assert.False(service.GetCurrentContext(missingFile).IsSuccess);
|
||||
Assert.False(service.DeleteContext("gone", false, CopyFixture()).IsSuccess);
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), "maksit-cluster-console-" + Guid.NewGuid().ToString("N") + ".yaml");
|
||||
File.WriteAllText(path, """
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
contexts: []
|
||||
""");
|
||||
var current = service.GetCurrentContext(path);
|
||||
Assert.False(current.IsSuccess);
|
||||
|
||||
var broken = Path.Combine(Path.GetTempPath(), "maksit-cluster-console-" + Guid.NewGuid().ToString("N") + ".yaml");
|
||||
File.WriteAllText(broken, ":\n - [");
|
||||
Assert.False(service.ListContexts(broken).IsSuccess);
|
||||
Assert.False(service.Build("missing", CopyFixture()).IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertConnection_validates_each_auth_kind() {
|
||||
var service = new KubeConfigService();
|
||||
var path = CopyFixture();
|
||||
Assert.False(service.UpsertConnection(new KubeConnectionRequest {
|
||||
ContextName = " ",
|
||||
Server = "https://127.0.0.1:6443"
|
||||
}, path).IsSuccess);
|
||||
Assert.False(service.UpsertConnection(new KubeConnectionRequest {
|
||||
ContextName = "bad",
|
||||
Server = " "
|
||||
}, path).IsSuccess);
|
||||
Assert.False(service.UpsertConnection(new KubeConnectionRequest {
|
||||
ContextName = "bad",
|
||||
Server = "https://127.0.0.1:6443",
|
||||
CaFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))
|
||||
}, path).IsSuccess);
|
||||
Assert.False(service.UpsertConnection(new KubeConnectionRequest {
|
||||
ContextName = "bad",
|
||||
Server = "https://127.0.0.1:6443",
|
||||
AuthKind = KubeAuthKind.Cert
|
||||
}, path).IsSuccess);
|
||||
Assert.False(service.UpsertConnection(new KubeConnectionRequest {
|
||||
ContextName = "bad",
|
||||
Server = "https://127.0.0.1:6443",
|
||||
AuthKind = KubeAuthKind.Basic,
|
||||
BasicUser = "admin"
|
||||
}, path).IsSuccess);
|
||||
|
||||
var cert = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".crt");
|
||||
var key = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".key");
|
||||
File.WriteAllText(cert, "cert");
|
||||
File.WriteAllText(key, "key");
|
||||
var added = service.UpsertConnection(new KubeConnectionRequest {
|
||||
ContextName = "cert",
|
||||
ClusterName = "cert-cluster",
|
||||
UserName = "cert-user",
|
||||
Namespace = "ops",
|
||||
Server = "https://10.0.0.3:6443",
|
||||
AuthKind = KubeAuthKind.Cert,
|
||||
ClientCertFile = cert,
|
||||
ClientKeyFile = key,
|
||||
CaFile = cert,
|
||||
EmbedClusterCa = true,
|
||||
EmbedClientCerts = true,
|
||||
UseAfterAdd = false
|
||||
}, path);
|
||||
Assert.True(added.IsSuccess, string.Join("; ", added.Messages));
|
||||
var details = service.ListContextDetails(path).Value!.Single(item => item.Name == "cert");
|
||||
Assert.Equal("ops", details.Namespace);
|
||||
Assert.Equal("CA data: present", details.CaSummary);
|
||||
|
||||
var basic = service.UpsertConnection(new KubeConnectionRequest {
|
||||
ContextName = "basic",
|
||||
Server = "https://10.0.0.4:6443",
|
||||
AuthKind = KubeAuthKind.Basic,
|
||||
BasicUser = "admin",
|
||||
BasicPassword = "secret",
|
||||
InsecureSkipTlsVerify = true,
|
||||
UseAfterAdd = false
|
||||
}, path);
|
||||
Assert.True(basic.IsSuccess, string.Join("; ", basic.Messages));
|
||||
Assert.Contains("admin", service.ListContextDetails(path).Value!.Single(item => item.Name == "basic").AuthSummary, StringComparison.Ordinal);
|
||||
|
||||
Assert.False(KubeAuthKind.TryParse("nope", out _));
|
||||
Assert.True(KubeAuthKind.TryParse("TOKEN", out var kind));
|
||||
Assert.Equal(KubeAuthKind.Token, kind);
|
||||
Assert.Equal(path, KubeConfigService.ResolveWritablePath(path));
|
||||
}
|
||||
|
||||
private static string CopyFixture() {
|
||||
var source = Path.Combine(AppContext.BaseDirectory, "Fixtures", "kubeconfig.yaml");
|
||||
var path = Path.Combine(Path.GetTempPath(), "maksit-cluster-console-" + Guid.NewGuid().ToString("N") + ".yaml");
|
||||
|
||||
@ -11,7 +11,13 @@ public class KubeQuantityTests {
|
||||
[InlineData("1", 1)]
|
||||
[InlineData("1000n", 0.000001)]
|
||||
[InlineData("4", 4)]
|
||||
public void ToCores_parses_cpu(string raw, double expected) {
|
||||
[InlineData("2u", 0.000002)]
|
||||
[InlineData("2k", 2000)]
|
||||
[InlineData("2K", 2000)]
|
||||
[InlineData("nope", 0)]
|
||||
[InlineData("-", 0)]
|
||||
[InlineData(null, 0)]
|
||||
public void ToCores_parses_cpu(string? raw, double expected) {
|
||||
Assert.Equal(expected, KubeQuantity.ToCores(raw), 9);
|
||||
}
|
||||
|
||||
@ -19,12 +25,41 @@ public class KubeQuantityTests {
|
||||
[InlineData("512Mi", 536870912)]
|
||||
[InlineData("1Gi", 1073741824)]
|
||||
[InlineData("1000Ki", 1024000)]
|
||||
[InlineData("1KiB", 1024)]
|
||||
[InlineData("256.0MiB", 268435456)]
|
||||
[InlineData("1Ti", 1099511627776)]
|
||||
[InlineData("1M", 1000000)]
|
||||
[InlineData("1G", 1000000000)]
|
||||
[InlineData("1000m", 1)]
|
||||
[InlineData("100", 100)]
|
||||
[InlineData("nope", 0)]
|
||||
public void ToBytes_parses_memory(string raw, long expected) {
|
||||
Assert.Equal(expected, KubeQuantity.ToBytes(raw));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.25, "250m")]
|
||||
[InlineData(2.5, "2.5")]
|
||||
public void FormatCores_uses_millicores_below_one(double cores, string expected) =>
|
||||
Assert.Equal(expected, KubeQuantity.FormatCores(cores));
|
||||
|
||||
[Fact]
|
||||
public void FormatBytes_uses_binary_units() {
|
||||
Assert.Equal("1 GiB", KubeQuantity.FormatBytes(1024L * 1024 * 1024));
|
||||
Assert.Equal("1 MiB", KubeQuantity.FormatBytes(1024 * 1024));
|
||||
Assert.Equal("100 B", KubeQuantity.FormatBytes(100));
|
||||
Assert.Equal("1.0GiB", KubeQuantity.FormatBytesCompact(1024L * 1024 * 1024));
|
||||
Assert.Equal("100B", KubeQuantity.FormatBytesCompact(100));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "0")]
|
||||
[InlineData(100, "100")]
|
||||
[InlineData(1536, "1.5Ki")]
|
||||
[InlineData(2L * 1024 * 1024 * 1024, "2Gi")]
|
||||
public void FormatMemoryQuantity_picks_the_largest_exact_unit(long bytes, string expected) =>
|
||||
Assert.Equal(expected, KubeQuantity.FormatMemoryQuantity(bytes));
|
||||
|
||||
[Theory]
|
||||
[InlineData(536_870_912, "512Mi")]
|
||||
[InlineData(805_306_368, "768Mi")]
|
||||
@ -74,6 +109,25 @@ public class KubeQuantityTests {
|
||||
Assert.Equal("Usage: 1.00", usage.Cpu.UsageLine);
|
||||
Assert.Equal("Limits: 17.10", usage.Cpu.LimitsLine);
|
||||
Assert.Equal("Specified limits are higher than node capacity!", usage.Cpu.LimitsWarning);
|
||||
|
||||
var empty = ResourceSlice.Empty("pods");
|
||||
Assert.Equal(1, empty.Scale);
|
||||
Assert.Equal("0", empty.Caption.Split('/')[0].Trim());
|
||||
Assert.Equal("", empty.LimitsWarning);
|
||||
var zero = new ClusterUsage("v1", "linux", 0, empty, empty, empty, [], [], false, "metrics down");
|
||||
Assert.Equal(0, zero.CpuPercent);
|
||||
Assert.Equal("metrics down", zero.MetricsMessage);
|
||||
|
||||
var memory = new ResourceSlice(1024d * 1024 * 1024, 0, 0, 2d * 1024 * 1024 * 1024, 2d * 1024 * 1024 * 1024, "memory");
|
||||
Assert.Equal("1.0GiB / 2.0GiB", memory.Caption);
|
||||
var closed = 0;
|
||||
var handle = new PortForwardHandle("web", "apps", 8080, 18080, new MemoryStream(), () => closed++, 80);
|
||||
Assert.Equal(80, handle.RequestedPort);
|
||||
handle.Retarget("web-2", "apps", 9090);
|
||||
Assert.Equal("web-2", handle.PodName);
|
||||
Assert.Equal(9090, handle.ContainerPort);
|
||||
handle.Dispose();
|
||||
Assert.Equal(1, closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MaksIT.ClusterConsole.Client.Internal;
|
||||
|
||||
@ -43,4 +44,54 @@ public class KubernetesResultTests {
|
||||
Assert.Contains("still preparing", mapped.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("response body", mapped.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("403 Forbidden", "403 Forbidden")]
|
||||
[InlineData("no", "no")]
|
||||
public void Map_keeps_the_api_message_for_auth_and_generic_failures(string message, string expected) {
|
||||
var mapped = message.Contains("403", StringComparison.Ordinal)
|
||||
? KubernetesResult.Map(new Exception(message))
|
||||
: KubernetesResult.Map(new InvalidOperationException(message));
|
||||
|
||||
Assert.Equal(expected, mapped.Messages[0]);
|
||||
Assert.False(mapped.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_classifies_status_codes_and_typed_results() {
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, KubernetesResult.Map(new UnauthorizedAccessException("denied")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, KubernetesResult.Map(new Exception("403 Forbidden")).StatusCode);
|
||||
|
||||
var missing = KubernetesResult.Map<string>(new Exception("404 missing"));
|
||||
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
|
||||
Assert.Null(missing.Value);
|
||||
Assert.Equal("404 missing", missing.Messages[0]);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, KubernetesResult.Map(new Exception("409 Conflict")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, KubernetesResult.Map(new Exception("422 invalid")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, KubernetesResult.Map(new Exception("boom")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToObject_and_items_accept_objects_elements_and_single_documents() {
|
||||
Assert.Null(KubernetesResult.ToObject(null));
|
||||
var passthrough = new JsonObject { ["kind"] = "Pod" };
|
||||
Assert.Same(passthrough, KubernetesResult.ToObject(passthrough));
|
||||
|
||||
using var document = JsonDocument.Parse("""{"metadata":{"name":"from-element","resourceVersion":"12"}}""");
|
||||
var fromElement = KubernetesResult.ToObject(document.RootElement);
|
||||
Assert.Equal("from-element", fromElement!["metadata"]!["name"]!.GetValue<string>());
|
||||
Assert.Equal("12", KubernetesResult.ResourceVersion(fromElement));
|
||||
|
||||
var fromAnonymous = KubernetesResult.ToObject(new { metadata = new { name = "anon" } });
|
||||
Assert.Equal("anon", fromAnonymous!["metadata"]!["name"]!.GetValue<string>());
|
||||
|
||||
Assert.Empty(KubernetesResult.Items(null));
|
||||
Assert.Equal("solo", KubernetesResult.Items("""{"metadata":{"name":"solo"}}""")[0]["metadata"]!["name"]!.GetValue<string>());
|
||||
|
||||
var blank = JsonNode.Parse("""{"metadata":{"continue":" "}}""") as JsonObject;
|
||||
Assert.Null(KubernetesResult.ContinueToken(blank));
|
||||
var typed = JsonNode.Parse("""{"metadata":{"Continue":"next"}}""") as JsonObject;
|
||||
Assert.Equal("next", KubernetesResult.ContinueToken(typed));
|
||||
}
|
||||
}
|
||||
|
||||
94
src/MaksIT.ClusterConsole.Tests/OllamaChatClientTests.cs
Normal file
94
src/MaksIT.ClusterConsole.Tests/OllamaChatClientTests.cs
Normal file
@ -0,0 +1,94 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using MaksIT.ClusterConsole.Client.Ollama;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.Tests;
|
||||
|
||||
public class OllamaChatClientTests {
|
||||
[Fact]
|
||||
public async Task ListModels_reads_names_and_skips_blanks() {
|
||||
var handler = new ScriptHandler(_ => Json(HttpStatusCode.OK, """
|
||||
{ "models": [{ "name": "qwen3:8b" }, { "name": " " }, { "name": "llama3" }] }
|
||||
"""));
|
||||
var client = new OllamaChatClient(new HttpClient(handler));
|
||||
|
||||
var listed = await client.ListModelsAsync("http://127.0.0.1:11434/", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(["qwen3:8b", "llama3"], listed.Value);
|
||||
Assert.Equal("http://127.0.0.1:11434/api/tags", handler.LastRequest!.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListModels_surfaces_http_errors_and_unreachable_hosts() {
|
||||
var handler = new ScriptHandler(_ => Json(HttpStatusCode.NotFound, """{ "error": "model runner stopped" }"""));
|
||||
var client = new OllamaChatClient(new HttpClient(handler));
|
||||
var missing = await client.ListModelsAsync("", TestContext.Current.CancellationToken);
|
||||
Assert.Equal("model runner stopped", missing.Messages[0]);
|
||||
Assert.StartsWith("http://127.0.0.1:11434/api/tags", handler.LastRequest!.RequestUri!.ToString(), StringComparison.Ordinal);
|
||||
|
||||
handler.Respond = _ => new HttpResponseMessage(HttpStatusCode.BadGateway) { Content = new StringContent("") };
|
||||
var empty = await client.ListModelsAsync("http://ollama", TestContext.Current.CancellationToken);
|
||||
Assert.Contains("HTTP 502", empty.Messages[0], StringComparison.Ordinal);
|
||||
|
||||
handler.Respond = _ => Json(HttpStatusCode.InternalServerError, new string('x', 500));
|
||||
var clipped = await client.ListModelsAsync("http://ollama", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(400, clipped.Messages[0].Length);
|
||||
|
||||
handler.Respond = _ => throw new HttpRequestException("refused");
|
||||
var down = await client.ListModelsAsync("http://ollama", TestContext.Current.CancellationToken);
|
||||
Assert.Contains("Cannot reach Ollama", down.Messages[0], StringComparison.Ordinal);
|
||||
Assert.Contains("refused", down.Messages[0], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chat_returns_the_message_or_the_ollama_error() {
|
||||
var handler = new ScriptHandler(_ => Json(HttpStatusCode.OK, """
|
||||
{ "message": { "role": "assistant", "content": "the pod is pending" } }
|
||||
"""));
|
||||
var client = new OllamaChatClient(new HttpClient(handler));
|
||||
var request = new OllamaChatRequest {
|
||||
Model = "qwen3:8b",
|
||||
Messages = [new OllamaChatMessage { Role = "user", Content = "status?" }]
|
||||
};
|
||||
|
||||
var chat = await client.ChatAsync("http://127.0.0.1:11434", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal("the pod is pending", chat.Value!.Message!.Content);
|
||||
Assert.Equal("http://127.0.0.1:11434/api/chat", handler.LastRequest!.RequestUri!.ToString());
|
||||
|
||||
handler.Respond = _ => Json(HttpStatusCode.OK, """{ "error": "model not found" }""");
|
||||
var failed = await client.ChatAsync("http://127.0.0.1:11434", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal("model not found", failed.Messages[0]);
|
||||
|
||||
handler.Respond = _ => Json(HttpStatusCode.OK, "null");
|
||||
var empty = await client.ChatAsync("http://127.0.0.1:11434", request, TestContext.Current.CancellationToken);
|
||||
Assert.Contains("empty chat response", empty.Messages[0], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_is_not_turned_into_an_error_result() {
|
||||
var handler = new ScriptHandler(_ => Json(HttpStatusCode.OK, "{}"));
|
||||
var client = new OllamaChatClient(new HttpClient(handler));
|
||||
using var canceled = new CancellationTokenSource();
|
||||
await canceled.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
client.ListModelsAsync("http://127.0.0.1:11434", canceled.Token));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
|
||||
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||
|
||||
private sealed class ScriptHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler {
|
||||
public Func<HttpRequestMessage, HttpResponseMessage> Respond { get; set; } = respond;
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
|
||||
LastRequest = request;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return Task.FromResult(Respond(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/MaksIT.ClusterConsole.Tests/OperatorSettingsTests.cs
Normal file
62
src/MaksIT.ClusterConsole.Tests/OperatorSettingsTests.cs
Normal file
@ -0,0 +1,62 @@
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
using MaksIT.ClusterConsole.Shared.Chat;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.Tests;
|
||||
|
||||
public class OperatorSettingsTests {
|
||||
[Fact]
|
||||
public void Ai_settings_fill_blanks_and_disable_the_agent_when_chat_is_off() {
|
||||
var settings = new AiSettings {
|
||||
Enabled = false,
|
||||
AgentEnabled = true,
|
||||
Endpoint = " ",
|
||||
Model = "",
|
||||
TimeoutSeconds = 1
|
||||
};
|
||||
settings.Ensure();
|
||||
|
||||
Assert.False(settings.AgentEnabled);
|
||||
Assert.Equal(ClusterChatService.DefaultEndpoint, settings.Endpoint);
|
||||
Assert.Equal(ClusterChatService.DefaultModel, settings.Model);
|
||||
Assert.Equal(ClusterChatService.DefaultTimeoutSeconds, settings.TimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Port_forward_directory_replaces_the_same_local_port() {
|
||||
var directory = new PortForwardDirectory { Items = null! };
|
||||
directory.Upsert(Forward("homelab", 8080, "web"));
|
||||
directory.Upsert(Forward("homelab", 8080, "web-2"));
|
||||
directory.Upsert(Forward("dev", 8080, "api"));
|
||||
|
||||
Assert.Equal("web-2", Assert.Single(directory.For("homelab")).PodName);
|
||||
Assert.Equal("api", Assert.Single(directory.For("dev")).PodName);
|
||||
|
||||
directory.Remove("dev", 8080);
|
||||
Assert.Empty(directory.For("dev"));
|
||||
Assert.Equal("mailto:security@maks-it.com", AppInfo.Security.Uri);
|
||||
Assert.Contains(DateTime.UtcNow.Year.ToString(), AppInfo.Copyright, StringComparison.Ordinal);
|
||||
Assert.False(string.IsNullOrWhiteSpace(AppInfo.Version));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Persisted_port_forward_row_uses_the_saved_remote_port() {
|
||||
var row = PortForwardRow.FromPersisted(Forward("homelab", 18080, "web"), "Stopped");
|
||||
|
||||
Assert.Equal("Stopped", row.Cells["Status"]);
|
||||
Assert.Equal("18080", row.Cells["Local"]);
|
||||
Assert.Equal("web", row.Cells["Pod"]);
|
||||
Assert.True(PortForwardRow.TryLocalPort(row, out var port));
|
||||
Assert.Equal(18080, port);
|
||||
}
|
||||
|
||||
private static PersistedPortForward Forward(string context, int port, string pod) =>
|
||||
new() {
|
||||
Context = context,
|
||||
Name = pod,
|
||||
Namespace = "apps",
|
||||
PodName = pod,
|
||||
LocalPort = port,
|
||||
RemotePort = 80
|
||||
};
|
||||
}
|
||||
@ -141,6 +141,105 @@ public class PodStatusTests {
|
||||
Assert.Equal("Running", PodStatus.Of(pod));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Init_progress_scheduling_and_termination_have_their_own_reasons() {
|
||||
Assert.Equal("", PodStatus.Of(null));
|
||||
|
||||
var gated = Pod("""
|
||||
{
|
||||
"status": {
|
||||
"phase": "Pending",
|
||||
"conditions": [{ "type": "PodScheduled", "status": "False", "reason": "SchedulingGated" }]
|
||||
}
|
||||
}
|
||||
""");
|
||||
Assert.Equal("SchedulingGated", PodStatus.Of(gated));
|
||||
|
||||
var initDone = Pod("""
|
||||
{
|
||||
"spec": {
|
||||
"initContainers": [
|
||||
{ "name": "setup" },
|
||||
{ "name": "sidecar", "restartPolicy": "Always" }
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"phase": "Running",
|
||||
"conditions": [{ "type": "Ready", "status": true }],
|
||||
"initContainerStatuses": [
|
||||
{ "name": "setup", "started": true, "state": { "terminated": { "exitCode": 0, "reason": "Completed" } } },
|
||||
{ "name": "sidecar", "started": true, "state": { "running": {} } }
|
||||
],
|
||||
"containerStatuses": [{ "name": "app", "ready": true, "state": { "running": {} } }]
|
||||
}
|
||||
}
|
||||
""");
|
||||
Assert.Equal("Running", PodStatus.Of(initDone));
|
||||
|
||||
var initializing = Pod("""
|
||||
{
|
||||
"spec": { "initContainers": [{ "name": "setup" }] },
|
||||
"status": {
|
||||
"phase": "Pending",
|
||||
"initContainerStatuses": [{ "name": "setup", "state": { "waiting": { "reason": "PodInitializing" } } }]
|
||||
}
|
||||
}
|
||||
""");
|
||||
Assert.Equal("Init:0/1", PodStatus.Of(initializing));
|
||||
|
||||
var signaled = Pod("""
|
||||
{
|
||||
"status": {
|
||||
"phase": "Failed",
|
||||
"containerStatuses": [{ "name": "app", "state": { "terminated": { "exitCode": 0, "signal": 9 } } }]
|
||||
}
|
||||
}
|
||||
""");
|
||||
Assert.Equal("Signal:9", PodStatus.Of(signaled));
|
||||
|
||||
var completed = Pod("""
|
||||
{
|
||||
"status": {
|
||||
"phase": "Succeeded",
|
||||
"reason": "Completed",
|
||||
"conditions": [{ "type": "Ready", "status": "False" }],
|
||||
"containerStatuses": [
|
||||
{ "name": "job", "ready": false, "state": { "terminated": { "exitCode": 0, "reason": "Completed" } } },
|
||||
{ "name": "app", "ready": false, "state": { "terminated": { "exitCode": 1, "reason": "Error" } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
Assert.Equal("Error", PodStatus.Of(completed));
|
||||
|
||||
var lost = Pod("""
|
||||
{
|
||||
"metadata": { "deletionTimestamp": "2026-08-19T10:00:00Z" },
|
||||
"status": { "phase": "Running", "reason": "NodeLost" }
|
||||
}
|
||||
""");
|
||||
Assert.Equal("Unknown", PodStatus.Of(lost));
|
||||
|
||||
var finished = Pod("""
|
||||
{
|
||||
"metadata": { "deletionTimestamp": "2026-08-19T10:00:00Z" },
|
||||
"status": { "phase": "Succeeded" }
|
||||
}
|
||||
""");
|
||||
Assert.Equal("Succeeded", PodStatus.Of(finished));
|
||||
|
||||
var notReady = Pod("""
|
||||
{
|
||||
"status": {
|
||||
"phase": "Running",
|
||||
"conditions": [{ "type": "Ready", "status": "False" }],
|
||||
"containerStatuses": [{ "name": "app", "ready": true, "restartCount": 0, "state": { "running": {} } }]
|
||||
}
|
||||
}
|
||||
""");
|
||||
Assert.Equal("NotReady", PodStatus.Of(notReady));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deleting_pod_is_terminating() {
|
||||
var pod = Pod("""
|
||||
|
||||
@ -53,13 +53,14 @@ public class ReleaseNotesTests {
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shipped_notes_keep_older_work_out_of_the_current_version() {
|
||||
var notes = ReleaseNotes.AddedSince(ReleaseNotes.Text(), seenVersion: null, currentVersion: "0.8.8");
|
||||
public void Current_release_lists_notes_from_0_8_7_through_0_8_9() {
|
||||
var notes = ReleaseNotes.AddedSince(ReleaseNotes.Text(), seenVersion: null, currentVersion: "0.8.9");
|
||||
|
||||
var note = Assert.Single(notes);
|
||||
Assert.Equal("0.8.8", note.Version);
|
||||
Assert.Equal("0.8.9", note.Version);
|
||||
Assert.Contains(note.Added, line => line.Contains("Analyze issues", StringComparison.Ordinal));
|
||||
Assert.Contains(note.Added, line => line.Contains("ControllerRevision", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(note.Added, line => line.Contains("Analyze issues", StringComparison.Ordinal));
|
||||
Assert.Contains(note.Added, line => line.Contains("shared windows", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(note.Added, line => line.Contains("drain dialog", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(note.Added, line => line.Contains("Helm Charts", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(note.Added, line => line.Contains("footer", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
86
src/MaksIT.ClusterConsole.Tests/ResourceDocumentTests.cs
Normal file
86
src/MaksIT.ClusterConsole.Tests/ResourceDocumentTests.cs
Normal file
@ -0,0 +1,86 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.Tests;
|
||||
|
||||
public class ResourceDocumentTests {
|
||||
[Fact]
|
||||
public void NewTemplate_seeds_namespaced_data_and_cluster_scope() {
|
||||
var config = ResourceDocument.NewTemplate(ResourceCatalog.Find("configmaps")!, "all");
|
||||
Assert.Contains("namespace: default", config, StringComparison.Ordinal);
|
||||
Assert.Contains("key: value", config, StringComparison.Ordinal);
|
||||
|
||||
var secret = ResourceDocument.NewTemplate(ResourceCatalog.Find("secrets")!, "vault");
|
||||
Assert.Contains("namespace: vault", secret, StringComparison.Ordinal);
|
||||
Assert.Contains("Opaque", secret, StringComparison.Ordinal);
|
||||
Assert.Contains("stringData:", secret, StringComparison.Ordinal);
|
||||
|
||||
var node = ResourceDocument.NewTemplate(ResourceCatalog.Find("nodes")!, "kube-system");
|
||||
Assert.DoesNotContain("namespace:", node, StringComparison.Ordinal);
|
||||
Assert.Contains("kind: Node", node, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrepareForApply_drops_server_managed_metadata() {
|
||||
var prepared = ResourceDocument.PrepareForApply(Doc("""
|
||||
{
|
||||
"kind": "ConfigMap",
|
||||
"metadata": {
|
||||
"name": "app",
|
||||
"generation": 3,
|
||||
"creationTimestamp": "2026-01-01T00:00:00Z",
|
||||
"deletionTimestamp": "2026-01-02T00:00:00Z",
|
||||
"selfLink": "/api/v1/configmaps/app",
|
||||
"managedFields": [{ "manager": "kubectl" }]
|
||||
},
|
||||
"status": { "phase": "Ready" }
|
||||
}
|
||||
"""));
|
||||
|
||||
var meta = prepared["metadata"] as JsonObject;
|
||||
Assert.Equal("app", meta!["name"]!.GetValue<string>());
|
||||
Assert.Null(meta["generation"]);
|
||||
Assert.Null(meta["managedFields"]);
|
||||
Assert.Null(prepared["status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_entries_prefer_text_skip_duplicates_and_keep_binary() {
|
||||
var secret = Doc("""
|
||||
{
|
||||
"kind": "Secret",
|
||||
"stringData": { "password": "plain" },
|
||||
"data": {
|
||||
"password": "aWdub3JlZA==",
|
||||
"token": "dG9rZW4=",
|
||||
"blob": "AAEC"
|
||||
}
|
||||
}
|
||||
""");
|
||||
var entries = ResourceDocument.ReadDataEntries(secret);
|
||||
Assert.Equal("plain", entries.Single(entry => entry.Key == "password").Value);
|
||||
Assert.False(entries.Single(entry => entry.Key == "password").IsBinary);
|
||||
Assert.Equal("token", entries.Single(entry => entry.Key == "token").Value);
|
||||
Assert.True(entries.Single(entry => entry.Key == "blob").IsBinary);
|
||||
|
||||
var config = Doc("""{ "kind": "ConfigMap", "data": { "note": { "nested": true } }, "binaryData": {} }""");
|
||||
var nested = Assert.Single(ResourceDocument.ReadDataEntries(config));
|
||||
Assert.Contains("nested", nested.Value, StringComparison.Ordinal);
|
||||
|
||||
ResourceDocument.WriteDataEntries(config, [
|
||||
new ResourceDataEntry("", "skip", false),
|
||||
new ResourceDataEntry("note", "hello", false),
|
||||
new ResourceDataEntry("blob", "not-base64", true)
|
||||
]);
|
||||
Assert.Equal("hello", config["data"]!["note"]!.GetValue<string>());
|
||||
Assert.Null(config["data"]![""]);
|
||||
Assert.Equal(
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes("not-base64")),
|
||||
config["binaryData"]!["blob"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
private static JsonObject Doc(string json) =>
|
||||
JsonNode.Parse(json) as JsonObject ?? throw new InvalidOperationException("expected object");
|
||||
}
|
||||
@ -1,159 +1,33 @@
|
||||
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;
|
||||
using MaksIT.Core.UI.Errors;
|
||||
using CoreErrorDialog = MaksIT.Core.UI.Errors.ErrorDialog;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI.Dialogs;
|
||||
|
||||
|
||||
internal static class ErrorDialog {
|
||||
private static int _open;
|
||||
private static int _ready;
|
||||
|
||||
public static void Report(Exception? exception) =>
|
||||
Present(exception, wait: false);
|
||||
public static void Report(Exception? exception) {
|
||||
Ensure();
|
||||
CoreErrorDialog.Report(exception);
|
||||
}
|
||||
|
||||
public static void ReportBlocking(Exception? exception) =>
|
||||
Present(exception, wait: true);
|
||||
public static void ReportBlocking(Exception? exception) {
|
||||
Ensure();
|
||||
CoreErrorDialog.ReportBlocking(exception);
|
||||
}
|
||||
|
||||
private static void Present(Exception? exception, bool wait) {
|
||||
if (exception is null)
|
||||
private static void Ensure() {
|
||||
if (Interlocked.Exchange(ref _ready, 1) != 0)
|
||||
return;
|
||||
|
||||
var report = ErrorReport.Capture(exception);
|
||||
var dispatcher = TryDispatcher();
|
||||
|
||||
if (dispatcher is null) {
|
||||
if (wait)
|
||||
ShowStandalone(report);
|
||||
|
||||
return;
|
||||
CoreErrorDialog.Use(new DesktopErrorOptions {
|
||||
Capture = ErrorReport.Capture,
|
||||
Text = () => new DesktopErrorText {
|
||||
Title = AppInfo.ProductName,
|
||||
Hint = "Something went wrong. The details below are also saved under Help → Logs."
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.8" />
|
||||
<PackageReference Include="MaksIT.Core.UI" Version="0.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.LogicalTree;
|
||||
using Avalonia.Media.Imaging;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
using MaksIT.Core.UI.Snapshots;
|
||||
using MaksIT.ClusterConsole.UI.Windows;
|
||||
using MaksIT.ClusterConsole.UI.ViewModels.Shell;
|
||||
using MaksIT.ClusterConsole.UI.ViewModels.Cluster;
|
||||
@ -32,7 +30,7 @@ internal static class ScreenshotTour {
|
||||
|
||||
foreach (var view in options.Views) {
|
||||
if (string.Equals(view, ScreenshotTourOptions.WelcomeView, StringComparison.OrdinalIgnoreCase)) {
|
||||
await SettleAsync(options.SettleMilliseconds);
|
||||
await AppViewSnapshot.SettleAsync(options.SettleMilliseconds);
|
||||
manifest.Shots.Add(Capture(window, options, ScreenshotTourOptions.WelcomeView, manifest, saveCatalog: !connected));
|
||||
|
||||
continue;
|
||||
@ -89,7 +87,7 @@ internal static class ScreenshotTour {
|
||||
page.EnsureRowSelected();
|
||||
});
|
||||
await page.WaitUntilQuietAsync(20_000);
|
||||
await SettleAsync(settleMilliseconds);
|
||||
await AppViewSnapshot.SettleAsync(settleMilliseconds);
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -208,7 +206,7 @@ internal static class ScreenshotTour {
|
||||
await WaitForTerminalAsync(page);
|
||||
|
||||
await page.WaitUntilQuietAsync(20_000);
|
||||
await SettleAsync(options.SettleMilliseconds);
|
||||
await AppViewSnapshot.SettleAsync(options.SettleMilliseconds);
|
||||
|
||||
PixelRect? redact = null;
|
||||
|
||||
@ -282,16 +280,7 @@ internal static class ScreenshotTour {
|
||||
if (ShoulderTabs(window)?.SelectedItem is not TabItem { Content: Control body })
|
||||
return null;
|
||||
|
||||
var origin = body.TranslatePoint(new Point(0, 0), window);
|
||||
|
||||
if (origin is null || body.Bounds.Width < 2 || body.Bounds.Height < 2)
|
||||
return null;
|
||||
|
||||
return new PixelRect(
|
||||
(int)Math.Floor(origin.Value.X),
|
||||
(int)Math.Floor(origin.Value.Y),
|
||||
Math.Max(1, (int)Math.Ceiling(body.Bounds.Width)),
|
||||
Math.Max(1, (int)Math.Ceiling(body.Bounds.Height)));
|
||||
return AppViewSnapshot.BoundsIn(body, window);
|
||||
}
|
||||
|
||||
private static PixelRect RightShoulder(Window window) {
|
||||
@ -324,7 +313,7 @@ internal static class ScreenshotTour {
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
await SettleAsync(options.SettleMilliseconds);
|
||||
await AppViewSnapshot.SettleAsync(options.SettleMilliseconds);
|
||||
|
||||
if (files is null) {
|
||||
manifest.Skipped.Add(new SkippedRecord { Id = "volume-files", Reason = "Volume files did not open." });
|
||||
@ -343,7 +332,7 @@ internal static class ScreenshotTour {
|
||||
string id,
|
||||
Window dialog) {
|
||||
dialog.Show(owner);
|
||||
await SettleAsync(options.SettleMilliseconds);
|
||||
await AppViewSnapshot.SettleAsync(options.SettleMilliseconds);
|
||||
manifest.Shots.Add(CaptureWindow(dialog, options, id));
|
||||
dialog.Close();
|
||||
}
|
||||
@ -351,19 +340,12 @@ internal static class ScreenshotTour {
|
||||
private static ShotRecord CaptureWindow(Window window, ScreenshotTourOptions options, string id) =>
|
||||
new() {
|
||||
Id = id,
|
||||
Window = Save(window, Path.Combine(options.Directory, Token(id) + ".png"))
|
||||
Window = AppViewSnapshot.Save(window, Path.Combine(options.Directory, AppViewSnapshot.FileToken(id) + ".png"))
|
||||
};
|
||||
|
||||
private static async Task SaveCatalogAsync(Window window, ScreenshotTourOptions options, Manifest manifest) {
|
||||
await SettleAsync(options.SettleMilliseconds);
|
||||
manifest.Catalog = Save(window.FindControl<Control>("CatalogPane"), Path.Combine(options.Directory, "catalog.png"));
|
||||
}
|
||||
|
||||
private static async Task SettleAsync(int milliseconds) {
|
||||
if (milliseconds > 0)
|
||||
await Task.Delay(milliseconds);
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Render);
|
||||
await AppViewSnapshot.SettleAsync(options.SettleMilliseconds);
|
||||
manifest.Catalog = AppViewSnapshot.Save(window.FindControl<Control>("CatalogPane"), Path.Combine(options.Directory, "catalog.png"));
|
||||
}
|
||||
|
||||
private static ShotRecord Capture(
|
||||
@ -373,206 +355,17 @@ internal static class ScreenshotTour {
|
||||
Manifest manifest,
|
||||
bool saveCatalog,
|
||||
PixelRect? redact = null) {
|
||||
var token = Token(id);
|
||||
var shot = new ShotRecord {
|
||||
Id = id,
|
||||
Window = Save(window, Path.Combine(options.Directory, token + ".png"), redact)
|
||||
Window = AppViewSnapshot.Save(window, Path.Combine(options.Directory, AppViewSnapshot.FileToken(id) + ".png"), redact)
|
||||
};
|
||||
|
||||
if (saveCatalog && manifest.Catalog is null)
|
||||
manifest.Catalog = Save(window.FindControl<Control>("CatalogPane"), Path.Combine(options.Directory, "catalog.png"));
|
||||
manifest.Catalog = AppViewSnapshot.Save(window.FindControl<Control>("CatalogPane"), Path.Combine(options.Directory, "catalog.png"));
|
||||
|
||||
return shot;
|
||||
}
|
||||
|
||||
private static string? Save(Control? control, string path, PixelRect? redact = null) {
|
||||
if (control is not { IsEffectivelyVisible: true })
|
||||
return null;
|
||||
|
||||
var width = control is Window host ? host.ClientSize.Width : control.Bounds.Width;
|
||||
var height = control is Window hostWindow ? hostWindow.ClientSize.Height : control.Bounds.Height;
|
||||
|
||||
if (width < 2)
|
||||
width = control.Bounds.Width;
|
||||
|
||||
if (height < 2)
|
||||
height = control.Bounds.Height;
|
||||
|
||||
if (width < 2 || height < 2)
|
||||
return null;
|
||||
|
||||
var size = new PixelSize(
|
||||
Math.Max(1, (int)Math.Ceiling(width)),
|
||||
Math.Max(1, (int)Math.Ceiling(height)));
|
||||
|
||||
using var bitmap = new RenderTargetBitmap(size, new Vector(96, 96));
|
||||
bitmap.Render(control);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
if (redact is { } region)
|
||||
SaveRedacted(bitmap, size, region, path);
|
||||
else
|
||||
bitmap.Save(path, new PngBitmapEncoderOptions());
|
||||
|
||||
return Path.GetFileName(path);
|
||||
}
|
||||
|
||||
private static void SaveRedacted(RenderTargetBitmap bitmap, PixelSize size, PixelRect region, string path) {
|
||||
using var output = new WriteableBitmap(size, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Premul);
|
||||
|
||||
using (var frame = output.Lock()) {
|
||||
bitmap.CopyPixels(frame);
|
||||
Redact(frame, region);
|
||||
}
|
||||
|
||||
output.Save(path, new PngBitmapEncoderOptions());
|
||||
}
|
||||
|
||||
private static void Redact(ILockedFramebuffer frame, PixelRect region) {
|
||||
var width = frame.Size.Width;
|
||||
var height = frame.Size.Height;
|
||||
var stride = frame.RowBytes;
|
||||
var length = stride * height;
|
||||
|
||||
if (length <= 0 || frame.Address == IntPtr.Zero)
|
||||
return;
|
||||
|
||||
var pixels = new byte[length];
|
||||
Marshal.Copy(frame.Address, pixels, 0, length);
|
||||
|
||||
if (frame.Format.BitsPerPixel == 32 && stride >= width * 4)
|
||||
BlurRegion(pixels, width, height, stride, region);
|
||||
else
|
||||
FillRegion(pixels, stride, region, width, height);
|
||||
|
||||
Marshal.Copy(pixels, 0, frame.Address, length);
|
||||
}
|
||||
|
||||
private static void BlurRegion(byte[] pixels, int width, int height, int stride, PixelRect region) {
|
||||
var left = Math.Clamp(region.X, 0, Math.Max(0, width - 1));
|
||||
var top = Math.Clamp(region.Y, 0, Math.Max(0, height - 1));
|
||||
var right = Math.Clamp(region.X + region.Width, left + 1, width);
|
||||
var bottom = Math.Clamp(region.Y + region.Height, top + 1, height);
|
||||
|
||||
if (right - left < 2 || bottom - top < 2)
|
||||
return;
|
||||
|
||||
const int radius = 16;
|
||||
const int passes = 3;
|
||||
var scratch = new byte[pixels.Length];
|
||||
|
||||
for (var pass = 0; pass < passes; pass++) {
|
||||
BlurHorizontal(pixels, scratch, stride, left, right, top, bottom, radius);
|
||||
BlurVertical(scratch, pixels, stride, left, right, top, bottom, radius);
|
||||
}
|
||||
}
|
||||
|
||||
private static void BlurHorizontal(
|
||||
byte[] source,
|
||||
byte[] dest,
|
||||
int stride,
|
||||
int left,
|
||||
int right,
|
||||
int top,
|
||||
int bottom,
|
||||
int radius) {
|
||||
var span = radius * 2 + 1;
|
||||
|
||||
for (var y = top; y < bottom; y++) {
|
||||
var row = y * stride;
|
||||
|
||||
for (var x = left; x < right; x++) {
|
||||
var b = 0;
|
||||
var g = 0;
|
||||
var r = 0;
|
||||
var a = 0;
|
||||
|
||||
for (var dx = -radius; dx <= radius; dx++) {
|
||||
var sample = row + (Math.Clamp(x + dx, left, right - 1) * 4);
|
||||
b += source[sample];
|
||||
g += source[sample + 1];
|
||||
r += source[sample + 2];
|
||||
a += source[sample + 3];
|
||||
}
|
||||
|
||||
var pixel = row + (x * 4);
|
||||
dest[pixel] = (byte)(b / span);
|
||||
dest[pixel + 1] = (byte)(g / span);
|
||||
dest[pixel + 2] = (byte)(r / span);
|
||||
dest[pixel + 3] = (byte)(a / span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BlurVertical(
|
||||
byte[] source,
|
||||
byte[] dest,
|
||||
int stride,
|
||||
int left,
|
||||
int right,
|
||||
int top,
|
||||
int bottom,
|
||||
int radius) {
|
||||
var span = radius * 2 + 1;
|
||||
|
||||
for (var y = top; y < bottom; y++) {
|
||||
for (var x = left; x < right; x++) {
|
||||
var b = 0;
|
||||
var g = 0;
|
||||
var r = 0;
|
||||
var a = 0;
|
||||
|
||||
for (var dy = -radius; dy <= radius; dy++) {
|
||||
var sample = (Math.Clamp(y + dy, top, bottom - 1) * stride) + (x * 4);
|
||||
b += source[sample];
|
||||
g += source[sample + 1];
|
||||
r += source[sample + 2];
|
||||
a += source[sample + 3];
|
||||
}
|
||||
|
||||
var pixel = (y * stride) + (x * 4);
|
||||
dest[pixel] = (byte)(b / span);
|
||||
dest[pixel + 1] = (byte)(g / span);
|
||||
dest[pixel + 2] = (byte)(r / span);
|
||||
dest[pixel + 3] = (byte)(a / span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void FillRegion(byte[] pixels, int stride, PixelRect region, int width, int height) {
|
||||
var left = Math.Clamp(region.X, 0, width);
|
||||
var top = Math.Clamp(region.Y, 0, height);
|
||||
var right = Math.Clamp(region.X + region.Width, left, width);
|
||||
var bottom = Math.Clamp(region.Y + region.Height, top, height);
|
||||
|
||||
for (var y = top; y < bottom; y++) {
|
||||
for (var x = left; x < right; x++) {
|
||||
var pixel = (y * stride) + (x * 4);
|
||||
|
||||
if (pixel + 3 >= pixels.Length)
|
||||
return;
|
||||
|
||||
pixels[pixel] = 32;
|
||||
pixels[pixel + 1] = 36;
|
||||
pixels[pixel + 2] = 40;
|
||||
pixels[pixel + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string Token(string id) {
|
||||
var buffer = new char[id.Length];
|
||||
|
||||
for (var i = 0; i < id.Length; i++) {
|
||||
var c = id[i];
|
||||
buffer[i] = char.IsAsciiLetterOrDigit(c) || c is '-' or '_' ? c : '-';
|
||||
}
|
||||
|
||||
var token = new string(buffer).Trim('-');
|
||||
|
||||
return token.Length == 0 ? "view" : token;
|
||||
}
|
||||
|
||||
private sealed class Manifest {
|
||||
public string? Context { get; set; }
|
||||
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI.ViewModels;
|
||||
|
||||
|
||||
public sealed partial class AboutViewModel {
|
||||
public string Brand => AppInfo.Brand;
|
||||
|
||||
public string ProductName => AppInfo.ProductName;
|
||||
|
||||
public string Summary => AppInfo.Summary;
|
||||
|
||||
public string Version => AppInfo.Version;
|
||||
|
||||
public string Credits => AppInfo.Credits;
|
||||
|
||||
public AppContact SecurityContact => AppInfo.Security;
|
||||
|
||||
public AppContact SupportContact => AppInfo.Support;
|
||||
|
||||
public string License => AppInfo.License;
|
||||
|
||||
public string Copyright => AppInfo.Copyright;
|
||||
|
||||
public string Site => AppInfo.Site;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenContact(string? uri) {
|
||||
if (string.IsNullOrWhiteSpace(uri))
|
||||
return;
|
||||
|
||||
OpenUrl(uri);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenSite() =>
|
||||
OpenUrl(AppInfo.SiteUri);
|
||||
|
||||
private static void OpenUrl(string url) {
|
||||
try {
|
||||
Process.Start(new ProcessStartInfo {
|
||||
FileName = url,
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI.ViewModels;
|
||||
|
||||
|
||||
public sealed partial class WhatsNewViewModel : ObservableObject {
|
||||
public WhatsNewViewModel(IReadOnlyList<ReleaseNote> notes) {
|
||||
Notes = notes;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ReleaseNote> Notes { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool doNotShowAgain;
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:footer="using:MaksIT.ClusterConsole.UI.Controls.Footer"
|
||||
xmlns:vm="using:MaksIT.ClusterConsole.UI.ViewModels"
|
||||
xmlns:shared="using:MaksIT.ClusterConsole.Shared"
|
||||
x:Class="MaksIT.ClusterConsole.UI.Windows.AboutWindow"
|
||||
x:DataType="vm:AboutViewModel"
|
||||
Width="480"
|
||||
SizeToContent="Height"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ShowInTaskbar="False"
|
||||
Icon="/Assets/icon.ico"
|
||||
Title="{x:Static shared:AppInfo.AboutTitle}">
|
||||
<StackPanel Margin="16" Spacing="10">
|
||||
<TextBlock Text="{Binding Brand}" Classes="hint" Margin="0" />
|
||||
<TextBlock Text="{Binding ProductName}" FontSize="20" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Summary}" Classes="hint" TextWrapping="Wrap" />
|
||||
|
||||
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"
|
||||
Margin="0,8,0,0">
|
||||
<TextBlock Text="Version" Classes="hint" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Version}" FontWeight="SemiBold" />
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Credits" Classes="hint" Margin="0,8,0,0" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding Credits}" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Text="{Binding SecurityContact.Role}" Classes="hint" Margin="0,8,0,0" />
|
||||
<Button Grid.Row="2" Grid.Column="1" Content="{Binding SecurityContact.Address}"
|
||||
Command="{Binding OpenContactCommand}" CommandParameter="{Binding SecurityContact.Uri}"
|
||||
HorizontalAlignment="Left" Padding="8,4" Margin="0,6,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Text="{Binding SupportContact.Role}" Classes="hint" Margin="0,8,0,0" />
|
||||
<Button Grid.Row="3" Grid.Column="1" Content="{Binding SupportContact.Address}"
|
||||
Command="{Binding OpenContactCommand}" CommandParameter="{Binding SupportContact.Uri}"
|
||||
HorizontalAlignment="Left" Padding="8,4" Margin="0,6,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="4" Text="License" Classes="hint" Margin="0,8,0,0" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding License}" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="5" Text="Copyright" Classes="hint" Margin="0,8,0,0" />
|
||||
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding Copyright}" Classes="hint" Margin="0,8,0,0" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
|
||||
<footer:WindowFooter x:Name="Actions" Margin="0,12,0,0" Spacing="8" />
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@ -1,46 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MaksIT.ClusterConsole.UI.ViewModels;
|
||||
using MaksIT.ClusterConsole.UI.Controls.Footer;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI.Windows;
|
||||
|
||||
|
||||
public partial class AboutWindow : Window {
|
||||
private bool _footerReady;
|
||||
|
||||
public AboutWindow() {
|
||||
InitializeComponent();
|
||||
DataContextChanged += (_, _) => WireFooter();
|
||||
}
|
||||
|
||||
public static Task ShowAsync(Window owner) {
|
||||
var window = new AboutWindow {
|
||||
DataContext = new AboutViewModel(),
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
|
||||
return window.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private void WireFooter() {
|
||||
if (_footerReady || DataContext is not AboutViewModel about)
|
||||
return;
|
||||
|
||||
_footerReady = true;
|
||||
Actions.Items = [
|
||||
new FooterButton(about.Site, about.OpenSiteCommand) {
|
||||
Edge = FooterEdge.Trailing,
|
||||
Padding = new Thickness(12, 7)
|
||||
},
|
||||
new FooterButton("Close", new RelayCommand(Close)) {
|
||||
Edge = FooterEdge.Trailing,
|
||||
IsDefault = true,
|
||||
IsCancel = true,
|
||||
Padding = new Thickness(12, 7)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
28
src/MaksIT.ClusterConsole.UI/Windows/AboutWindow.cs
Normal file
28
src/MaksIT.ClusterConsole.UI/Windows/AboutWindow.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using Avalonia.Controls;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
using CoreAbout = MaksIT.Core.UI.About.AboutWindow;
|
||||
using CoreContact = MaksIT.Core.UI.About.AppContact;
|
||||
using CoreInfo = MaksIT.Core.UI.About.DesktopAppInfo;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI.Windows;
|
||||
|
||||
public static class AboutWindow {
|
||||
public static Task ShowAsync(Window owner) =>
|
||||
CoreAbout.ShowAsync(owner, new CoreInfo {
|
||||
Brand = AppInfo.Brand,
|
||||
ProductName = AppInfo.ProductName,
|
||||
Summary = AppInfo.Summary,
|
||||
Version = AppInfo.Version,
|
||||
Credits = AppInfo.Credits,
|
||||
License = AppInfo.License,
|
||||
Copyright = AppInfo.Copyright,
|
||||
Site = AppInfo.Site,
|
||||
SiteUri = AppInfo.SiteUri,
|
||||
Title = AppInfo.AboutTitle,
|
||||
Contacts = [
|
||||
new CoreContact(AppInfo.Security.Role, AppInfo.Security.Address),
|
||||
new CoreContact(AppInfo.Support.Role, AppInfo.Support.Address)
|
||||
]
|
||||
});
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:footer="using:MaksIT.ClusterConsole.UI.Controls.Footer"
|
||||
xmlns:vm="using:MaksIT.ClusterConsole.UI.ViewModels"
|
||||
xmlns:shared="using:MaksIT.ClusterConsole.Shared"
|
||||
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="{x:Static shared:AppInfo.ProductName}">
|
||||
<DockPanel Margin="16">
|
||||
<footer:WindowFooter x:Name="Actions"
|
||||
DockPanel.Dock="Bottom"
|
||||
Margin="0,12,0,0"
|
||||
Spacing="8" />
|
||||
<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>
|
||||
@ -1,64 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input.Platform;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MaksIT.ClusterConsole.UI.ViewModels;
|
||||
using MaksIT.ClusterConsole.UI.Controls.Footer;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI.Windows;
|
||||
|
||||
|
||||
public partial class ErrorWindow : Window {
|
||||
private bool _footerReady;
|
||||
|
||||
public ErrorWindow() {
|
||||
InitializeComponent();
|
||||
DataContextChanged += (_, _) => WireFooter();
|
||||
}
|
||||
|
||||
private void WireFooter() {
|
||||
if (_footerReady || DataContext is not ErrorReportViewModel report)
|
||||
return;
|
||||
|
||||
_footerReady = true;
|
||||
var status = new FooterLabel("") {
|
||||
Edge = FooterEdge.Trailing,
|
||||
IsVisible = false
|
||||
};
|
||||
report.PropertyChanged += (_, e) => {
|
||||
if (e.PropertyName != nameof(ErrorReportViewModel.CopyStatus))
|
||||
return;
|
||||
|
||||
status.Text = report.CopyStatus;
|
||||
status.IsVisible = report.CopyStatus.Length > 0;
|
||||
};
|
||||
Actions.Items = [
|
||||
status,
|
||||
new FooterButton("Copy", new AsyncRelayCommand(CopyAsync)) { Edge = FooterEdge.Trailing },
|
||||
new FooterButton("Close", new RelayCommand(Close)) {
|
||||
Edge = FooterEdge.Trailing,
|
||||
IsDefault = true,
|
||||
IsCancel = true,
|
||||
Padding = new Thickness(16, 8)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private async Task CopyAsync() {
|
||||
if (DataContext is not ErrorReportViewModel report)
|
||||
return;
|
||||
|
||||
var clipboard = Clipboard;
|
||||
|
||||
if (clipboard is null)
|
||||
return;
|
||||
|
||||
try {
|
||||
await clipboard.SetTextAsync(report.Report);
|
||||
report.MarkCopied();
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:footer="using:MaksIT.ClusterConsole.UI.Controls.Footer"
|
||||
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">
|
||||
<footer:WindowFooter x:Name="Actions"
|
||||
DockPanel.Dock="Bottom"
|
||||
Margin="0,12,0,0"
|
||||
Spacing="8" />
|
||||
<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>
|
||||
@ -1,73 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input.Platform;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MaksIT.ClusterConsole.UI.ViewModels;
|
||||
using MaksIT.ClusterConsole.UI.Controls.Footer;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI.Windows;
|
||||
|
||||
|
||||
public partial class LogWindow : Window {
|
||||
private bool _footerReady;
|
||||
|
||||
public LogWindow() {
|
||||
InitializeComponent();
|
||||
DataContextChanged += (_, _) => WireFooter();
|
||||
}
|
||||
|
||||
public static Task ShowAsync(Window owner) {
|
||||
var window = new LogWindow {
|
||||
DataContext = new LogViewModel(),
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
|
||||
return window.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private void WireFooter() {
|
||||
if (_footerReady || DataContext is not LogViewModel logs)
|
||||
return;
|
||||
|
||||
_footerReady = true;
|
||||
var status = new FooterLabel("") {
|
||||
Edge = FooterEdge.Trailing,
|
||||
IsVisible = false
|
||||
};
|
||||
logs.PropertyChanged += (_, e) => {
|
||||
if (e.PropertyName != nameof(LogViewModel.CopyStatus))
|
||||
return;
|
||||
|
||||
status.Text = logs.CopyStatus;
|
||||
status.IsVisible = logs.CopyStatus.Length > 0;
|
||||
};
|
||||
Actions.Items = [
|
||||
status,
|
||||
new FooterButton("Copy", new AsyncRelayCommand(CopyAsync)) { Edge = FooterEdge.Trailing },
|
||||
new FooterButton("Close", new RelayCommand(Close)) {
|
||||
Edge = FooterEdge.Trailing,
|
||||
IsDefault = true,
|
||||
IsCancel = true,
|
||||
Padding = new Thickness(16, 8)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private async Task CopyAsync() {
|
||||
if (DataContext is not LogViewModel logs)
|
||||
return;
|
||||
|
||||
var clipboard = Clipboard;
|
||||
|
||||
if (clipboard is null)
|
||||
return;
|
||||
|
||||
try {
|
||||
await clipboard.SetTextAsync(logs.Report);
|
||||
logs.MarkCopied();
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/MaksIT.ClusterConsole.UI/Windows/LogWindow.cs
Normal file
14
src/MaksIT.ClusterConsole.UI/Windows/LogWindow.cs
Normal file
@ -0,0 +1,14 @@
|
||||
using Avalonia.Controls;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
using MaksIT.Core.UI.Logs;
|
||||
using CoreLog = MaksIT.Core.UI.Logs.LogWindow;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI.Windows;
|
||||
|
||||
public static class LogWindow {
|
||||
public static Task ShowAsync(Window owner) =>
|
||||
CoreLog.ShowAsync(owner, new DesktopLogOptions {
|
||||
Directory = AppLog.Directory
|
||||
});
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:footer="using:MaksIT.ClusterConsole.UI.Controls.Footer"
|
||||
xmlns:vm="using:MaksIT.ClusterConsole.UI.ViewModels"
|
||||
xmlns:shared="using:MaksIT.ClusterConsole.Shared"
|
||||
x:Class="MaksIT.ClusterConsole.UI.Windows.WhatsNewWindow"
|
||||
x:DataType="vm:WhatsNewViewModel"
|
||||
Title="What's new"
|
||||
Icon="/Assets/icon.ico"
|
||||
Width="520"
|
||||
MaxHeight="640"
|
||||
SizeToContent="Height"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ShowInTaskbar="False">
|
||||
<DockPanel Margin="16">
|
||||
<footer:WindowFooter x:Name="Actions"
|
||||
DockPanel.Dock="Bottom"
|
||||
Margin="0,16,0,0" />
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="What's new" FontSize="20" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding Notes}" Margin="0,8,0,0">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="shared:ReleaseNote">
|
||||
<StackPanel Margin="0,8,0,0" Spacing="4">
|
||||
<TextBlock Text="{Binding Version}" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding Added}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:CompileBindings="False">
|
||||
<TextBlock Text="{Binding StringFormat='• {0}'}" TextWrapping="Wrap" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@ -1,78 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
using MaksIT.ClusterConsole.UI.ViewModels;
|
||||
using MaksIT.ClusterConsole.UI.Controls.Footer;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI.Windows;
|
||||
|
||||
|
||||
public partial class WhatsNewWindow : Window {
|
||||
private bool _footerReady;
|
||||
|
||||
public WhatsNewWindow() {
|
||||
InitializeComponent();
|
||||
DataContextChanged += (_, _) => WireFooter();
|
||||
}
|
||||
|
||||
public static async Task ShowIfNeededAsync(Window owner, ConfigurationFileService configuration) {
|
||||
var current = AppInfo.Version;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(current))
|
||||
return;
|
||||
|
||||
if (!configuration.FileExisted) {
|
||||
Remember(configuration, current);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var notes = ReleaseNotes.AddedSince(ReleaseNotes.Text(), configuration.Current.WhatsNewSeenVersion, current);
|
||||
|
||||
if (notes.Count == 0)
|
||||
return;
|
||||
|
||||
var model = new WhatsNewViewModel(notes);
|
||||
var window = new WhatsNewWindow {
|
||||
DataContext = model,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
await window.ShowDialog(owner);
|
||||
|
||||
if (model.DoNotShowAgain)
|
||||
Remember(configuration, current);
|
||||
}
|
||||
|
||||
private static void Remember(ConfigurationFileService configuration, string version) {
|
||||
var cfg = configuration.Current;
|
||||
|
||||
if (string.Equals(cfg.WhatsNewSeenVersion, version, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
cfg.WhatsNewSeenVersion = version;
|
||||
configuration.Save(cfg);
|
||||
}
|
||||
|
||||
private void WireFooter() {
|
||||
if (_footerReady || DataContext is not WhatsNewViewModel model)
|
||||
return;
|
||||
|
||||
_footerReady = true;
|
||||
var remember = new FooterCheck("Do not show again");
|
||||
remember.PropertyChanged += (_, e) => {
|
||||
if (e.PropertyName == nameof(FooterCheck.IsChecked))
|
||||
model.DoNotShowAgain = remember.IsChecked;
|
||||
};
|
||||
Actions.Items = [
|
||||
remember,
|
||||
new FooterButton("Close", new RelayCommand(Close)) {
|
||||
Edge = FooterEdge.Trailing,
|
||||
IsDefault = true,
|
||||
IsCancel = true,
|
||||
Padding = new Thickness(12, 7)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
30
src/MaksIT.ClusterConsole.UI/Windows/WhatsNewWindow.cs
Normal file
30
src/MaksIT.ClusterConsole.UI/Windows/WhatsNewWindow.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using Avalonia.Controls;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
using CoreWhatsNew = MaksIT.Core.UI.WhatsNew.WhatsNewWindow;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.UI.Windows;
|
||||
|
||||
public static class WhatsNewWindow {
|
||||
public static Task ShowIfNeededAsync(Window owner, ConfigurationFileService configuration) {
|
||||
var cfg = configuration.Current;
|
||||
|
||||
return CoreWhatsNew.ShowIfNeededAsync(
|
||||
owner,
|
||||
ReleaseNotes.Text(),
|
||||
AppInfo.Version,
|
||||
cfg.WhatsNewSeenVersion,
|
||||
configuration.FileExisted,
|
||||
version => Remember(configuration, version));
|
||||
}
|
||||
|
||||
private static void Remember(ConfigurationFileService configuration, string version) {
|
||||
var cfg = configuration.Current;
|
||||
|
||||
if (string.Equals(cfg.WhatsNewSeenVersion, version, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
cfg.WhatsNewSeenVersion = version;
|
||||
configuration.Save(cfg);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user