(feature): persist kubectl-compatible service port-forwards

This commit is contained in:
Maksym Sadovnychyy 2026-08-20 12:16:23 +02:00
parent bf63cf1730
commit 1ed5962d21
20 changed files with 1804 additions and 167 deletions

View File

@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
## [0.2.0] - 2026-08-20
### Added
- Status-bar messages for port-forward start, stop, restore, and failure.
- Local / Remote labels on the port-forward bar so the two port fields are explicit.
- Rebind the host port of an existing forward from **Network → Port Forwarding** (Local field + Rebind).
- Double-click a live port-forward in **Network → Port Forwarding** to open `http://127.0.0.1:{local}` in the default browser.
### Changed
- Active port-forwards are listed in **Network → Port Forwarding** (stop from the table). They are no longer shown in the status bar.
- Resource table footer, YAML/Data edit bars, and workload limit apply buttons show only for a selected row that can use them. Empty toolbars are hidden.
- Enabled port-forwards are saved in `appsettings.json` (`Configuration:PortForwards`) and restored when a cluster reconnects.
### Fixed
- Service port-forward resolves a backend pod from `spec.selector` and maps the service port to `targetPort`, matching kubectl.
- Service related-pods follow `spec.selector` only, so Helm siblings (for example Longhorn CSI vs UI) are not mixed; named `targetPort` is resolved on a pod that actually declares it.
- Port-forward tunnels use the Kubernetes port-forward multiplex protocol (`StreamType.PortForward`, channel 0, one stream per local connection) instead of exec stdin/stdout.
- Port Forwarding **Remote** is the requested service/pod port (for example `80`), not the mapped container port (`8000` for Longhorn UI). Open `http://127.0.0.1:{local}`.
- Port-forward listens on both IPv4 and IPv6 loopback so `localhost` works on Windows. The IPv6 listener is IPv6-only so it does not collide with IPv4 on Windows.
- Port-forward opens multiplex streams before starting the demuxer, then copies with blocking socket I/O, matching the Kubernetes C# client port-forward example.
- Persisted port-forwards re-resolve a Running pod by Service/workload owner or stable labels after replica recreation, including on each new local connection.
## [0.1.1] - 2026-08-19
### Fixed

109
README.md
View File

@ -3,30 +3,79 @@
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-50.9%25-yellowgreen)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-41.5%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-56.7%25-yellowgreen)
![.NET](https://img.shields.io/badge/.NET-10-512BD4)
![License](https://img.shields.io/badge/License-Apache%202.0-blue)
![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-0078D6)
Cross-platform Kubernetes desktop console (Avalonia). Lens-style catalog, navigator, resource tables, YAML apply, logs, exec, port-forward, Helm releases, and Dapr CRDs. Talks to the cluster through the official **KubernetesClient** NuGet package and the same kubeconfig/RBAC as kubectl.
Desktop console for Kubernetes clusters. Native Avalonia app on Windows and Linux: browse resources, apply YAML, follow logs, exec into pods, and reach workload UIs through localhost.
Cluster access uses the official Kubernetes .NET client and the same kubeconfig and RBAC as any other API client. Contexts are edited in-process; the kubeconfig file on disk is the source of truth.
See [LICENSE.md](LICENSE.md) (Apache 2.0). Changes: [CHANGELOG.md](CHANGELOG.md). Contributing: [CONTRIBUTING.md](CONTRIBUTING.md).
If you find this project useful, please consider supporting its development:
[<img src="https://cdn.buymeacoffee.com/buttons/v2/default-blue.png" alt="Buy Me A Coffee" style="height: 60px; width: 217px;">](https://www.buymeacoffee.com/maksitcom)
## Highlights
Capabilities that are first-class in ClusterConsole, not afterthoughts:
- **Port-forward, then open the UI** — forwards live under **Network → Port Forwarding**. Double-click a live row to open `http://127.0.0.1:{port}` in the default browser. Enabled forwards persist, restore on reconnect, survive pod recreation (owner or stable labels), and can rebind the local port without recreating the tunnel.
- **Local Ollama chat on the selection** — diagnose the highlighted resource with an on-machine model. The assistant can read cluster issues, YAML, logs, and events. Nothing is sent to a cloud AI API. Chat cannot apply, restart, or delete.
- **Dapr in the navigator** — Components, Configurations, Subscriptions, Resiliency, HTTPEndpoints, sidecars, and control-plane pods as catalog views, not a generic CRD dump.
- **Volume files** — browse, edit, download, and upload files on persistent volumes and claims from the desktop.
- **Limits you can fix** — overview shows container CPU and memory against node capacity and can patch limits that oversubscribe the node.
- **Connections stay in the app** — wizard to add or update a context (token, client certificate, or basic auth) and switch current-context without a separate kubeconfig editor.
## Features
- **Contexts** — catalog of kubeconfig contexts; mark and switch the current context
- **Navigator** — Cluster, Nodes, Applications, Workloads, Config, Network, Storage, Namespaces, Events, Helm, Dapr, Access Control, Custom Resources
- **Resource tables** — list and refresh any catalogued type; per-column filters; namespace scope persisted per context
- **Inspect and apply** — YAML view, apply, create, delete; force-delete (grace period 0 and strip finalizers), including namespaces whose objects are already gone
- **Workloads** — scale, restart, CronJob trigger; node cordon and drain
- **Pods** — follow logs, exec
- **Applications** — one row per instance and namespace from standard application labels
- **Helm** — releases discovered from cluster secrets
- **Metrics** — CPU and memory columns when the metrics API is available
## Requirements
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
- A kubeconfig (`KUBECONFIG` or `~/.kube/config`)
- A kubeconfig (`KUBECONFIG` or `~/.kube/config`) with permission to the target cluster
- Windows or Linux
- Optional: a local [Ollama](https://ollama.com) daemon for Chat
## Build
## Getting started
From `src/` so `global.json` applies:
```powershell
cd src
dotnet build MaksIT.ClusterConsole.slnx
dotnet run --project MaksIT.ClusterConsole.UI
```
Run the UI:
Connect a context from the catalog, pick a navigator item, then use the table, details pane, and footer actions for the selected row.
```powershell
dotnet run --project MaksIT.ClusterConsole.UI
## Configuration
Defaults live in `src/MaksIT.ClusterConsole.Shared/appsettings.json` (copied next to the UI). Notable keys under `Configuration`:
| Key | Role |
|-----|------|
| `OllamaEndpoint` | Chat API, default `http://127.0.0.1:11434` |
| `OllamaModel` | Chat model, default `qwen3:8b` |
| `PortForwards` | Enabled localhost forwards; restored when the cluster reconnects |
| `Layout` | Window and pane sizes, last navigator item, column state |
Port-forwards are saved when you start them in the UI. Chat cannot apply, restart, or delete.
Pull the default Chat model once:
```bash
ollama pull qwen3:8b
```
## Tests
@ -35,23 +84,13 @@ dotnet run --project MaksIT.ClusterConsole.UI
utils\Invoke-TestEngine.bat
```
Or from `src/`:
From `src/`:
```powershell
dotnet test MaksIT.ClusterConsole.Tests
```
## Local Ollama chat
Default model is **`qwen3:8b`** (~5.2GB) — the strongest Qwen chat/reasoning tag that still fits an RTX 3060 12GB with KV cache headroom. Endpoint `http://127.0.0.1:11434` (override `Configuration:OllamaEndpoint` / `OllamaModel` in `appsettings.json`).
```bash
ollama pull qwen3:8b
```
Fallback if tool calling is weak: `qwen2.5:7b`. Do not use `qwen3:14b` or `deepseek-r1:14b` as the default on 12GB (stretch only).
Open a resource table, pick a row, open **Chat**, then ask e.g. `What is wrong with this pod?`. The assistant can read issues, YAML, logs, and events. It cannot apply, restart, or delete.
Tests use kubeconfig fixtures and do not require a live cluster. Coverage shields at the top of this file are maintained by the test engine (**CoverageBadges**).
## Release
@ -59,22 +98,24 @@ Open a resource table, pick a row, open **Chat**, then ask e.g. `What is wrong w
2. Tag `v{version}` on `main`.
3. Run `utils\Invoke-ReleasePackage.bat`.
## What it is
## Solution layout
- Catalog of kubeconfig contexts (`*` marks kubectl `current-context`)
- Connections editor and wizard to add/upsert a context (token, cert, k3s data, or basic auth) and switch kubectl `current-context`
- Navigator: icon categories with collapsible sub-items (Cluster, Nodes, Applications, Workloads, Config, Network, Storage, Namespaces, Events, Helm, Dapr, Access Control, Custom Resources)
- Generic GVR browser (list/watch-by-refresh, YAML, apply, delete) with per-column header filters (type to filter rows and the value list; checkboxes; double-click a value to keep only that one). Namespace scope is the Namespace column filter (persisted per context).
- Workload scale/restart, node cordon/drain, CronJob trigger
- Force delete (grace period 0 + strip finalizers); force-delete namespace from the Namespaces view, including orphaned sandboxes whose Namespace object is already gone
- Pod logs (follow), exec, port-forward dock
- Helm releases from secrets (`owner=helm`)
- Applications view: one row per instance/namespace from `app.kubernetes.io/instance` (or `name` if instance is missing)
- Dapr Components/Configurations/Subscriptions/Resiliency/HTTPEndpoints, sidecars, control-plane pods
- CPU/MEM columns when `metrics.k8s.io` is available
- Overview resource-limits table: inspect and patch container CPU/memory limits when they exceed node capacity
- **Chat** tab (local Ollama): diagnose the selected resource with read-only cluster tools
```text
utils/ # RepoUtils test and release engines
src/
MaksIT.ClusterConsole.slnx
MaksIT.ClusterConsole.Client/ # Kubernetes API client
MaksIT.ClusterConsole.Shared/ # catalog, workspace, configuration
MaksIT.ClusterConsole.UI/ # Avalonia desktop host
MaksIT.ClusterConsole.Tests/
```
## What it is not
## Scope
kubectl CLI wrapper, Lens extensions, Vault users/scopes, k9s plugins, Dapr state/JetStream admin. Kubeconfig editing is in-process (same file kubectl uses), not a kubectl.exe wrapper.
ClusterConsole is a desktop operator console for the Kubernetes API. It is not a CLI, a cluster installer, or a replacement for admission, GitOps, or secret-management systems. Helm listing and Dapr views cover objects in the cluster; they do not install charts or administer Dapr building blocks.
## License
Apache 2.0 — see [LICENSE.md](LICENSE.md).
© Maksym Sadovnychyy (MAKS-IT)

View File

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

View File

@ -100,27 +100,45 @@ public sealed record HelmReleaseInfo(
string AppVersion,
DateTimeOffset? Updated);
public sealed record PortForwardEndpoint(string PodName, string Namespace, int ContainerPort);
public sealed class PortForwardHandle : IDisposable {
private readonly IDisposable _inner;
private readonly Action? _onDispose;
public PortForwardHandle(string podName, string @namespace, int containerPort, int localPort, IDisposable inner, Action? onDispose = null) {
public PortForwardHandle(
string podName,
string @namespace,
int containerPort,
int localPort,
IDisposable inner,
Action? onDispose = null,
int requestedPort = 0) {
PodName = podName;
Namespace = @namespace;
ContainerPort = containerPort;
LocalPort = localPort;
RequestedPort = requestedPort > 0 ? requestedPort : containerPort;
_inner = inner;
_onDispose = onDispose;
}
public string PodName { get; }
public string PodName { get; private set; }
public string Namespace { get; }
public string Namespace { get; private set; }
public int ContainerPort { get; }
public int ContainerPort { get; private set; }
public int RequestedPort { get; }
public int LocalPort { get; }
public void Retarget(string podName, string @namespace, int containerPort) {
PodName = podName;
Namespace = @namespace;
ContainerPort = containerPort;
}
public void Dispose() {
_onDispose?.Invoke();
_inner.Dispose();

View File

@ -371,32 +371,28 @@ public sealed class ClusterSession : IClusterSession {
string @namespace,
int containerPort,
int localPort,
int requestedPort = 0,
Func<CancellationToken, Task<Result<PortForwardEndpoint>>>? resolveTarget = null,
CancellationToken cancellationToken = default) {
try {
var webSocket = await _client.WebSocketNamespacedPodPortForwardAsync(
podName,
@namespace,
[containerPort],
cancellationToken: cancellationToken).ConfigureAwait(false);
var demux = new StreamDemuxer(webSocket);
demux.Start();
var listener = new TcpListener(IPAddress.Loopback, localPort);
listener.Start();
var listeners = BindLoopback(localPort);
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_ = AcceptAsync(listener, demux, containerPort, cts.Token);
return Result<PortForwardHandle>.Ok(new PortForwardHandle(
var handle = new PortForwardHandle(
podName,
@namespace,
containerPort,
localPort,
demux,
cts,
() => {
cts.Cancel();
listener.Stop();
webSocket.Dispose();
}));
foreach (var listener in listeners)
listener.Stop();
},
requestedPort);
foreach (var listener in listeners)
_ = AcceptAsync(listener, handle, resolveTarget, cts.Token);
return Result<PortForwardHandle>.Ok(handle);
}
catch (Exception ex) {
return KubernetesResult.Map<PortForwardHandle>(ex);
@ -1024,31 +1020,149 @@ public sealed class ClusterSession : IClusterSession {
private static string? Label(IDictionary<string, string>? labels, string key) =>
labels is not null && labels.TryGetValue(key, out var value) ? value : null;
private static async Task AcceptAsync(TcpListener listener, StreamDemuxer demux, int port, CancellationToken cancellationToken) {
private static List<TcpListener> BindLoopback(int port) {
SocketException? last = null;
var listeners = new List<TcpListener>(2);
foreach (var address in new[] { IPAddress.Loopback, IPAddress.IPv6Loopback }) {
try {
var listener = new TcpListener(address, port);
if (address.AddressFamily == AddressFamily.InterNetworkV6)
listener.Server.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true);
listener.Start();
listeners.Add(listener);
}
catch (SocketException ex) {
last = ex;
}
}
if (listeners.Count == 0)
throw last ?? new SocketException((int)SocketError.AddressNotAvailable);
return listeners;
}
private async Task AcceptAsync(
TcpListener listener,
PortForwardHandle handle,
Func<CancellationToken, Task<Result<PortForwardEndpoint>>>? resolveTarget,
CancellationToken cancellationToken) {
try {
while (!cancellationToken.IsCancellationRequested) {
var client = await listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false);
_ = Task.Run(() => PumpAsync(client, demux, port, cancellationToken), cancellationToken);
client.NoDelay = true;
_ = PumpConnectionAsync(client, handle, resolveTarget, cancellationToken);
}
}
catch (OperationCanceledException) {
}
catch (ObjectDisposedException) {
}
catch (SocketException) {
}
}
private static async Task PumpAsync(TcpClient tcp, StreamDemuxer demux, int port, CancellationToken cancellationToken) {
private async Task PumpConnectionAsync(
TcpClient tcp,
PortForwardHandle handle,
Func<CancellationToken, Task<Result<PortForwardEndpoint>>>? resolveTarget,
CancellationToken cancellationToken) {
StreamDemuxer? demux = null;
try {
var remote = demux.GetStream(ChannelIndex.StdOut, ChannelIndex.StdIn);
var local = tcp.GetStream();
var podName = handle.PodName;
var @namespace = handle.Namespace;
var containerPort = handle.ContainerPort;
if (resolveTarget is not null) {
var resolved = await resolveTarget(cancellationToken).ConfigureAwait(false);
if (!resolved.IsSuccess || resolved.Value is null) {
tcp.Dispose();
return;
}
podName = resolved.Value.PodName;
@namespace = resolved.Value.Namespace;
containerPort = resolved.Value.ContainerPort;
handle.Retarget(podName, @namespace, containerPort);
}
var webSocket = await _client.WebSocketNamespacedPodPortForwardAsync(
podName,
@namespace,
[containerPort],
WebSocketProtocol.V4BinaryWebsocketProtocol,
cancellationToken: cancellationToken).ConfigureAwait(false);
demux = new StreamDemuxer(webSocket, StreamType.PortForward, ownsSocket: true);
var stream = demux.GetStream((byte?)0, (byte?)0);
var errors = demux.GetStream((byte?)1, null);
demux.Start();
_ = Task.Run(() => Drain(errors), cancellationToken);
var socket = tcp.Client;
using var copyCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
await Task.WhenAny(
local.CopyToAsync(remote, cancellationToken),
remote.CopyToAsync(local, cancellationToken)).ConfigureAwait(false);
Task.Run(() => CopySocketToStream(socket, stream, copyCts.Token), copyCts.Token),
Task.Run(() => CopyStreamToSocket(stream, socket, copyCts.Token), copyCts.Token)).ConfigureAwait(false);
copyCts.Cancel();
}
catch (OperationCanceledException) {
}
catch {
}
finally {
tcp.Dispose();
demux?.Dispose();
}
}
private static void CopySocketToStream(Socket socket, Stream stream, CancellationToken cancellationToken) {
var buffer = new byte[16 * 1024];
try {
while (!cancellationToken.IsCancellationRequested && socket.Connected) {
var read = socket.Receive(buffer);
if (read == 0)
break;
stream.Write(buffer, 0, read);
}
}
catch (SocketException) {
}
catch (ObjectDisposedException) {
}
catch (IOException) {
}
}
private static void CopyStreamToSocket(Stream stream, Socket socket, CancellationToken cancellationToken) {
var buffer = new byte[16 * 1024];
try {
while (!cancellationToken.IsCancellationRequested && socket.Connected) {
var read = stream.Read(buffer, 0, buffer.Length);
if (read == 0)
break;
var sent = 0;
while (sent < read)
sent += socket.Send(buffer, sent, read - sent, SocketFlags.None);
}
}
catch (SocketException) {
}
catch (ObjectDisposedException) {
}
catch (IOException) {
}
}
private static void Drain(Stream stream) {
var buffer = new byte[256];
try {
while (stream.Read(buffer, 0, buffer.Length) > 0) {
}
}
catch (ObjectDisposedException) {
}
catch (IOException) {
}
}
}

View File

@ -67,6 +67,8 @@ public interface IClusterSession : IDisposable {
string @namespace,
int containerPort,
int localPort,
int requestedPort = 0,
Func<CancellationToken, Task<Result<PortForwardEndpoint>>>? resolveTarget = null,
CancellationToken cancellationToken = default);
Task<Result<IReadOnlyList<JsonObject>>> ListCustomResourceDefinitionsAsync(

View File

@ -143,7 +143,7 @@ public sealed partial class ClusterWorkspace {
return new Result<IReadOnlyList<ResourceRow>>(null, false, listed.Messages, listed.StatusCode);
var related = (listed.Value ?? [])
.Where(p => Owns(p, owner.Document))
.Where(p => ResourceOwnership.Owns(p, owner.Document))
.Select(p => ResourceRow.From(p, pods))
.ToList();
return Result<IReadOnlyList<ResourceRow>>.Ok(related);
@ -367,34 +367,6 @@ public sealed partial class ClusterWorkspace {
return ApplicationManifest.WorkloadNames(row.Document).Contains(name, StringComparer.Ordinal);
}
private static bool Owns(JsonObject pod, JsonObject owner) {
var ownerName = JsonPath.Name(owner);
var refs = pod["metadata"]?["ownerReferences"] as JsonArray;
if (refs?.OfType<JsonObject>().Any(r => r["name"]?.ToString() == ownerName) == true)
return true;
var matchLabels = owner["spec"]?["selector"]?["matchLabels"] as JsonObject;
if (LabelsMatch(pod["metadata"]?["labels"] as JsonObject, matchLabels))
return true;
var labels = pod["metadata"]?["labels"] as JsonObject;
return labels?["app"]?.ToString() == ownerName
|| labels?[ApplicationManifest.NameKey]?.ToString() == ownerName
|| ApplicationManifest.SameInstance(pod, owner);
}
private static bool LabelsMatch(JsonObject? podLabels, JsonObject? required) {
if (podLabels is null || required is null || required.Count == 0)
return false;
foreach (var pair in required) {
if (podLabels[pair.Key]?.ToString() != pair.Value?.ToString())
return false;
}
return true;
}
private static bool Matches(ResourceRow row, string? filter) {
if (string.IsNullOrWhiteSpace(filter))
return true;
@ -412,7 +384,11 @@ public sealed partial class ClusterWorkspace {
"Applications",
ResourceCatalog.Applications,
ResourceCatalog.ApplicationsDescriptor),
Special(ResourceCatalog.PortForwardingId, "Port Forwarding", ResourceCatalog.Network),
Special(
ResourceCatalog.PortForwardingId,
"Port Forwarding",
ResourceCatalog.Network,
ResourceCatalog.PortForwardingDescriptor),
Special(ResourceCatalog.HelmChartsId, "Charts", ResourceCatalog.Helm),
Special(ResourceCatalog.HelmReleasesId, "Releases", ResourceCatalog.Helm),
Special(ResourceCatalog.DaprSidecarsId, "Sidecars", ResourceCatalog.Dapr),

View File

@ -21,6 +21,8 @@ public sealed class Configuration {
public LayoutSettings Layout { get; set; } = new();
public List<PersistedPortForward> PortForwards { get; set; } = [];
public void EnsureDefaults() {
OpenContexts ??= [];
NamespacesByContext ??= new Dictionary<string, string>(StringComparer.Ordinal);
@ -29,6 +31,7 @@ public sealed class Configuration {
Layout.ColumnWidths ??= new Dictionary<string, Dictionary<string, double>>(StringComparer.Ordinal);
Layout.ColumnFilters ??= new Dictionary<string, Dictionary<string, SavedColumnFilter>>(StringComparer.Ordinal);
Layout.SearchByResource ??= new Dictionary<string, string>(StringComparer.Ordinal);
PortForwards ??= [];
if (string.IsNullOrWhiteSpace(OllamaEndpoint))
OllamaEndpoint = ClusterChatService.DefaultEndpoint;
if (string.IsNullOrWhiteSpace(OllamaModel))
@ -64,4 +67,41 @@ public sealed class Configuration {
SelectedNamespace = ns;
ActiveContext = contextName;
}
public IReadOnlyList<PersistedPortForward> PortForwardsFor(string context) =>
(PortForwards ?? [])
.Where(p => string.Equals(p.Context, context, StringComparison.Ordinal))
.ToList();
public void UpsertPortForward(PersistedPortForward forward) {
ArgumentNullException.ThrowIfNull(forward);
PortForwards ??= [];
PortForwards.RemoveAll(p => SamePortForward(p, forward.Context, forward.LocalPort));
PortForwards.Add(forward);
}
public void RemovePortForward(string context, int localPort) {
PortForwards?.RemoveAll(p => SamePortForward(p, context, localPort));
}
private static bool SamePortForward(PersistedPortForward item, string context, int localPort) =>
string.Equals(item.Context, context, StringComparison.Ordinal) && item.LocalPort == localPort;
}
public sealed class PersistedPortForward {
public string Context { get; set; } = "";
public string Kind { get; set; } = "Pod";
public string Name { get; set; } = "";
public string Namespace { get; set; } = "default";
public string PodName { get; set; } = "";
public int LocalPort { get; set; }
public int RemotePort { get; set; }
public Dictionary<string, string>? MatchLabels { get; set; }
}

View File

@ -0,0 +1,104 @@
using System.Globalization;
using System.Text.Json.Nodes;
using MaksIT.ClusterConsole.Client;
namespace MaksIT.ClusterConsole.Shared;
public sealed record PortForwardRestoreSummary(int Restored, IReadOnlyList<string> Failures) {
public int Total => Restored + Failures.Count;
public string Format() {
if (Failures.Count == 0) {
if (Restored == 1)
return "Restored 1 port-forward.";
return $"Restored {Restored} port-forwards.";
}
var failed = string.Join("; ", Failures);
if (Restored == 0)
return $"Port-forward restore failed: {failed}";
return $"Restored {Restored} port-forward(s); {Failures.Count} failed: {failed}";
}
}
public static class PortForwardRow {
public static string Uid(int localPort) =>
$"pf:{localPort.ToString(CultureInfo.InvariantCulture)}";
public static string StartedMessage(PortForwardHandle handle) =>
$"Port-forward started: http://127.0.0.1:{handle.LocalPort} → {handle.Namespace}/{handle.PodName}:{handle.RequestedPort}.";
public static string FailedMessage(IEnumerable<string> messages) =>
$"Port-forward failed: {string.Join("; ", messages)}";
public static string ReboundMessage(int previousLocalPort, PortForwardHandle handle) =>
$"Port-forward rebound: localhost:{previousLocalPort} → http://127.0.0.1:{handle.LocalPort} → {handle.Namespace}/{handle.PodName}:{handle.RequestedPort}.";
public static string LocalUrl(int localPort) =>
$"http://127.0.0.1:{localPort.ToString(CultureInfo.InvariantCulture)}/";
public static bool TryLocalUrl(ResourceRow row, out string url) {
if (!TryLocalPort(row, out var localPort)) {
url = "";
return false;
}
url = LocalUrl(localPort);
return true;
}
public static bool TryLocalPort(ResourceRow row, out int localPort) {
localPort = 0;
if (row.Document["localPort"] is JsonValue value && value.TryGetValue<int>(out localPort) && localPort > 0)
return true;
var uid = row.Uid;
return uid.StartsWith("pf:", StringComparison.Ordinal)
&& int.TryParse(uid[3..], CultureInfo.InvariantCulture, out localPort)
&& localPort > 0;
}
public static ResourceRow From(PortForwardHandle handle, string uid, string status = "Active") =>
From(
uid,
handle.PodName,
handle.Namespace,
handle.LocalPort,
handle.RequestedPort,
status);
public static ResourceRow FromPersisted(PersistedPortForward saved, string status) =>
From(
Uid(saved.LocalPort),
saved.PodName,
saved.Namespace,
saved.LocalPort,
saved.RemotePort,
status);
private static ResourceRow From(
string uid,
string podName,
string? @namespace,
int localPort,
int remotePort,
string status) {
var local = localPort.ToString(CultureInfo.InvariantCulture);
var document = new JsonObject {
["kind"] = "PortForward",
["metadata"] = new JsonObject {
["name"] = $"localhost:{local}",
["namespace"] = @namespace,
["uid"] = uid
},
["pod"] = podName,
["localPort"] = localPort,
["containerPort"] = remotePort,
["status"] = status
};
return ResourceRow.From(document, ResourceCatalog.PortForwardingDescriptor);
}
}

View File

@ -66,10 +66,32 @@ public static class ResourceCatalog {
new ResourceActions(CanScale: false, CanRestart: false, CanApply: false),
["Overview", "YAML", "Events", "Pods", "Logs", "Terminal"]);
public static ResourceDescriptor PortForwardingDescriptor { get; } = new(
PortForwardingId,
"Port Forwarding",
Network,
"",
"v1",
"portforwards",
"PortForward",
true,
[
new("Name", "metadata.name"),
new("Namespace", "metadata.namespace"),
new("Pod", "pod"),
new("Local", "localPort"),
new("Remote", "containerPort"),
new("Status", "status")
],
new ResourceActions(CanDelete: false, CanApply: false),
["Overview"]);
public static ResourceDescriptor? Find(string id) =>
id == ApplicationsId
? ApplicationsDescriptor
: BuiltIns.FirstOrDefault(d => d.Id == id);
id switch {
ApplicationsId => ApplicationsDescriptor,
PortForwardingId => PortForwardingDescriptor,
_ => BuiltIns.FirstOrDefault(d => d.Id == id)
};
public static ResourceDescriptor? FindByGvk(string? apiVersion, string? kind) {
if (string.IsNullOrWhiteSpace(kind))
@ -137,6 +159,7 @@ public static class ResourceCatalog {
var yamlTabs = new[] { "Overview", "YAML", "Events" };
var podTabs = new[] { "Overview", "YAML", "Events", "Logs", "Terminal" };
var workloadTabs = new[] { "Overview", "YAML", "Events", "Pods", "Logs", "Terminal" };
var serviceTabs = new[] { "Overview", "YAML", "Events", "Pods" };
var crud = new ResourceActions();
var scale = new ResourceActions(CanScale: true, CanRestart: true);
var logs = new ResourceActions(CanLogs: true, CanExec: true, CanPortForward: true);
@ -193,7 +216,7 @@ public static class ResourceCatalog {
D("validatingwebhookconfigurations", "Validating Webhooks", Config, "admissionregistration.k8s.io", "v1", "validatingwebhookconfigurations", "ValidatingWebhookConfiguration", false, named, crud, yamlTabs),
D("services", "Services", Network, "", "v1", "services", "Service", true,
[..std, new("Type", "spec.type"), new("Cluster IP", "spec.clusterIP"), new("External IP", "service.externalIP"), new("Ports", "spec.ports")],
new ResourceActions(CanPortForward: true), yamlTabs),
new ResourceActions(CanPortForward: true), serviceTabs),
D("endpoints", "Endpoints", Network, "", "v1", "endpoints", "Endpoints", true, std, crud, yamlTabs),
D("endpointslices", "Endpoint Slices", Network, "discovery.k8s.io", "v1", "endpointslices", "EndpointSlice", true, std, crud, yamlTabs),
D("ingresses", "Ingresses", Network, "networking.k8s.io", "v1", "ingresses", "Ingress", true,

View File

@ -0,0 +1,46 @@
using System.Text.Json.Nodes;
namespace MaksIT.ClusterConsole.Shared;
public static class ResourceOwnership {
public static bool Owns(JsonObject pod, JsonObject owner) {
var ownerName = JsonPath.Name(owner);
var refs = pod["metadata"]?["ownerReferences"] as JsonArray;
if (refs?.OfType<JsonObject>().Any(r => r["name"]?.ToString() == ownerName) == true)
return true;
var matchLabels = SelectorLabels(owner);
if (matchLabels is not null && matchLabels.Count > 0)
return LabelsMatch(pod["metadata"]?["labels"] as JsonObject, matchLabels);
var labels = pod["metadata"]?["labels"] as JsonObject;
return labels?["app"]?.ToString() == ownerName
|| labels?[ApplicationManifest.NameKey]?.ToString() == ownerName
|| ApplicationManifest.SameInstance(pod, owner);
}
public static JsonObject? SelectorLabels(JsonObject? owner) {
var selector = owner?["spec"]?["selector"];
if (selector is not JsonObject obj)
return null;
if (obj["matchLabels"] is JsonObject match)
return match;
if (obj.ContainsKey("matchExpressions"))
return null;
return obj;
}
private static bool LabelsMatch(JsonObject? podLabels, JsonObject? required) {
if (podLabels is null || required is null || required.Count == 0)
return false;
foreach (var pair in required) {
if (podLabels[pair.Key]?.ToString() != pair.Value?.ToString())
return false;
}
return true;
}
}

View File

@ -0,0 +1,231 @@
using System.Globalization;
using System.Text.Json.Nodes;
using MaksIT.Results;
namespace MaksIT.ClusterConsole.Shared;
public sealed record PortForwardTarget(string PodName, string Namespace, int ContainerPort, int RequestedPort) {
public PortForwardTarget(string podName, string @namespace, int containerPort)
: this(podName, @namespace, containerPort, containerPort) {
}
}
public static class ServicePortForward {
public static bool IsService(JsonObject? document) =>
string.Equals(document?["kind"]?.GetValue<string>(), "Service", StringComparison.OrdinalIgnoreCase);
public static int? DefaultPort(JsonObject? service) {
var first = (service?["spec"]?["ports"] as JsonArray)?.OfType<JsonObject>().FirstOrDefault();
return first is not null && TryNumber(first["port"], out var port) ? port : null;
}
private static readonly HashSet<string> VolatileLabelKeys = new(StringComparer.Ordinal) {
"pod-template-hash",
"controller-revision-hash",
"statefulset.kubernetes.io/pod-name",
"apps.kubernetes.io/pod-index",
"batch.kubernetes.io/job-completion-index"
};
public static ResourceRow? PickPod(IReadOnlyList<ResourceRow> pods, ResourceRow? preferred) {
if (pods.Count == 0)
return null;
if (preferred is not null
&& IsRunning(preferred)
&& pods.Any(p => IsSamePod(p, preferred)))
return preferred;
return pods.FirstOrDefault(IsRunning) ?? pods[0];
}
public static ResourceRow? PickRunning(
IReadOnlyList<ResourceRow> pods,
string? preferredName,
IReadOnlyDictionary<string, string>? labels = null) {
var pool = labels is { Count: > 0 }
? pods.Where(p => HasLabels(p.Document, labels)).ToList()
: pods.ToList();
if (pool.Count == 0)
return null;
if (!string.IsNullOrWhiteSpace(preferredName)) {
var preferred = pool.FirstOrDefault(p =>
string.Equals(p.Name, preferredName, StringComparison.Ordinal) && IsRunning(p));
if (preferred is not null)
return preferred;
}
return pool.FirstOrDefault(IsRunning);
}
public static Dictionary<string, string>? StableLabels(JsonObject? document) {
var selector = ResourceOwnership.SelectorLabels(document);
if (selector is not null && selector.Count > 0)
return ToLabels(selector, stripVolatile: false);
var labels = document?["metadata"]?["labels"] as JsonObject;
return ToLabels(labels, stripVolatile: true);
}
public static bool HasLabels(JsonObject? document, IReadOnlyDictionary<string, string> required) {
if (required.Count == 0)
return true;
var labels = document?["metadata"]?["labels"] as JsonObject;
if (labels is null)
return false;
foreach (var pair in required) {
if (!string.Equals(JsonPath.Text(labels[pair.Key]), pair.Value, StringComparison.Ordinal))
return false;
}
return true;
}
public static Result<int> MapPort(JsonObject service, JsonObject? pod, int remotePort) {
var ports = service["spec"]?["ports"] as JsonArray;
var match = ports?.OfType<JsonObject>().FirstOrDefault(p =>
TryNumber(p["port"], out var port) && port == remotePort);
if (match is null)
return Result<int>.Ok(remotePort);
var target = match["targetPort"];
if (target is null)
return Result<int>.Ok(remotePort);
if (TryNumber(target, out var number))
return Result<int>.Ok(number);
var name = JsonPath.Text(target);
if (string.IsNullOrWhiteSpace(name))
return Result<int>.Ok(remotePort);
var containerPort = FindNamedContainerPort(pod, name);
if (containerPort is null)
return Result<int>.BadRequest(0, $"service targetPort '{name}' was not found on the selected pod");
return Result<int>.Ok(containerPort.Value);
}
public static Result<PortForwardTarget> Resolve(
JsonObject service,
IReadOnlyList<ResourceRow> pods,
ResourceRow? preferred,
int remotePort) {
var selector = ResourceOwnership.SelectorLabels(service);
if (selector is null || selector.Count == 0)
return Result<PortForwardTarget>.BadRequest(null, "Cannot port-forward a Service without a selector.");
var pod = PickForwardPod(pods, preferred, service, remotePort);
if (pod is null)
return Result<PortForwardTarget>.NotFound(null, "No pods match this Service selector.");
var mapped = MapPort(service, pod.Document, remotePort);
if (!mapped.IsSuccess)
return new Result<PortForwardTarget>(null, false, mapped.Messages, mapped.StatusCode);
var ns = pod.Namespace ?? JsonPath.Namespace(service) ?? "default";
return Result<PortForwardTarget>.Ok(new PortForwardTarget(pod.Name, ns, mapped.Value, remotePort));
}
private static ResourceRow? PickForwardPod(
IReadOnlyList<ResourceRow> pods,
ResourceRow? preferred,
JsonObject service,
int remotePort) {
ResourceRow? mappedFallback = null;
foreach (var candidate in OrderedPods(pods, preferred)) {
if (!MapPort(service, candidate.Document, remotePort).IsSuccess)
continue;
if (IsSamePod(candidate, preferred) && IsRunning(candidate))
return candidate;
if (IsRunning(candidate))
return candidate;
mappedFallback ??= candidate;
}
return mappedFallback ?? PickPod(pods, preferred);
}
private static IEnumerable<ResourceRow> OrderedPods(IReadOnlyList<ResourceRow> pods, ResourceRow? preferred) {
if (preferred is not null && pods.Any(p => IsSamePod(p, preferred)))
yield return preferred;
foreach (var pod in pods) {
if (IsSamePod(pod, preferred))
continue;
yield return pod;
}
}
private static bool IsRunning(ResourceRow pod) =>
PodStatus.Of(pod.Document).Equals("Running", StringComparison.OrdinalIgnoreCase);
private static bool IsSamePod(ResourceRow pod, ResourceRow? other) =>
other is not null
&& (string.Equals(pod.Uid, other.Uid, StringComparison.Ordinal)
|| (pod.Name == other.Name && pod.Namespace == other.Namespace));
private static Dictionary<string, string>? ToLabels(JsonObject? labels, bool stripVolatile) {
if (labels is null || labels.Count == 0)
return null;
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var pair in labels) {
if (stripVolatile && VolatileLabelKeys.Contains(pair.Key))
continue;
var value = JsonPath.Text(pair.Value);
if (!string.IsNullOrEmpty(value))
result[pair.Key] = value;
}
return result.Count == 0 ? null : result;
}
private static int? FindNamedContainerPort(JsonObject? pod, string name) {
if (pod?["spec"] is not JsonObject spec)
return null;
foreach (var field in new[] { "containers", "initContainers", "ephemeralContainers" }) {
if (spec[field] is not JsonArray containers)
continue;
foreach (var container in containers.OfType<JsonObject>()) {
if (container["ports"] is not JsonArray ports)
continue;
foreach (var port in ports.OfType<JsonObject>()) {
if (!string.Equals(JsonPath.Text(port["name"]), name, StringComparison.Ordinal))
continue;
if (TryNumber(port["containerPort"], out var number))
return number;
}
}
}
return null;
}
private static bool TryNumber(JsonNode? node, out int value) {
value = 0;
if (node is not JsonValue json)
return false;
if (json.TryGetValue<int>(out value) && value > 0)
return true;
if (json.TryGetValue<long>(out var longer) && longer > 0 && longer <= 65535) {
value = (int)longer;
return true;
}
return json.TryGetValue<string>(out var text)
&& int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)
&& value > 0;
}
}

View File

@ -22,6 +22,7 @@
"ColumnWidths": {},
"ColumnFilters": {},
"SearchByResource": {}
}
},
"PortForwards": []
}
}

View File

@ -172,6 +172,80 @@ public class ConfigurationFileServiceTests {
}
}
[Fact]
public void Save_round_trips_port_forwards() {
var path = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}.json");
File.WriteAllText(path, """
{
"Logging": { "LogLevel": { "Default": "Information" } },
"Configuration": { "SelectedNamespace": "all" }
}
""");
try {
var service = new ConfigurationFileService(path);
var cfg = service.Current;
cfg.UpsertPortForward(new PersistedPortForward {
Context = "homelab",
Kind = "Service",
Name = "postgres",
Namespace = "postgresql",
PodName = "postgres-0",
LocalPort = 5432,
RemotePort = 5432
});
cfg.UpsertPortForward(new PersistedPortForward {
Context = "homelab",
Kind = "Service",
Name = "postgres",
Namespace = "postgresql",
PodName = "postgres-1",
LocalPort = 5432,
RemotePort = 5432
});
cfg.UpsertPortForward(new PersistedPortForward {
Context = "dev",
Kind = "Pod",
Name = "web",
Namespace = "apps",
PodName = "web",
LocalPort = 8080,
RemotePort = 80,
MatchLabels = new Dictionary<string, string> { ["app"] = "web" }
});
service.Save(cfg);
var reloaded = new ConfigurationFileService(path);
var homelab = reloaded.Current.PortForwardsFor("homelab");
Assert.Single(homelab);
Assert.Equal("postgres-1", homelab[0].PodName);
Assert.Equal(5432, homelab[0].LocalPort);
Assert.Equal("Service", homelab[0].Kind);
reloaded.Current.RemovePortForward("homelab", 5432);
reloaded.Save(reloaded.Current);
Assert.Empty(new ConfigurationFileService(path).Current.PortForwardsFor("homelab"));
var dev = new ConfigurationFileService(path).Current.PortForwardsFor("dev");
Assert.Single(dev);
Assert.Equal("web", dev[0].MatchLabels!["app"]);
}
finally {
File.Delete(path);
}
}
[Fact]
public void PortForwardRestoreSummary_formats_success_and_failure() {
Assert.Equal("Restored 1 port-forward.", new PortForwardRestoreSummary(1, []).Format());
Assert.Equal("Restored 3 port-forwards.", new PortForwardRestoreSummary(3, []).Format());
Assert.Equal(
"Port-forward restore failed: localhost:8080 (pod not found)",
new PortForwardRestoreSummary(0, ["localhost:8080 (pod not found)"]).Format());
Assert.Equal(
"Restored 2 port-forward(s); 1 failed: localhost:80 (address in use)",
new PortForwardRestoreSummary(2, ["localhost:80 (address in use)"]).Format());
}
[Fact]
public void Default_path_is_appsettings_beside_the_executable() {
var service = new ConfigurationFileService();

View File

@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using MaksIT.ClusterConsole.Client;
using MaksIT.ClusterConsole.Shared;
@ -41,6 +42,68 @@ public class ResourceCatalogTests {
Assert.True(sections.IndexOf(ResourceCatalog.Applications) < sections.IndexOf(ResourceCatalog.Workloads));
}
[Fact]
public void Port_forwarding_is_a_navigator_table_not_a_builtin_gvr() {
Assert.Same(ResourceCatalog.PortForwardingDescriptor, ResourceCatalog.Find(ResourceCatalog.PortForwardingId));
Assert.DoesNotContain(ResourceCatalog.BuiltIns, d => d.Id == ResourceCatalog.PortForwardingId);
Assert.Null(ResourceCatalog.FindByGvk("v1", "PortForward"));
Assert.Contains("Pod", ResourceCatalog.PortForwardingDescriptor.Columns.Select(c => c.Header));
Assert.Contains("Local", ResourceCatalog.PortForwardingDescriptor.Columns.Select(c => c.Header));
Assert.Contains("Remote", ResourceCatalog.PortForwardingDescriptor.Columns.Select(c => c.Header));
Assert.False(ResourceCatalog.PortForwardingDescriptor.Actions.CanApply);
Assert.False(ResourceCatalog.PortForwardingDescriptor.Actions.CanDelete);
var workspace = new ClusterWorkspace();
var item = Assert.Single(workspace.Navigator, n => n.Id == ResourceCatalog.PortForwardingId);
Assert.True(item.IsSpecial);
Assert.Same(ResourceCatalog.PortForwardingDescriptor, item.Descriptor);
Assert.Equal(ResourceCatalog.Network, item.Section);
}
[Fact]
public void PortForwardRow_maps_handle_into_table_cells() {
using var handle = new PortForwardHandle("web-1", "apps", 8080, 18080, Stream.Null);
var row = PortForwardRow.From(handle, "pf-1");
Assert.Equal("pf-1", row.Uid);
Assert.Equal("localhost:18080", row.Name);
Assert.Equal("apps", row.Namespace);
Assert.Equal("web-1", row.Cells["Pod"]);
Assert.Equal("18080", row.Cells["Local"]);
Assert.Equal("8080", row.Cells["Remote"]);
Assert.Equal("Active", row.Cells["Status"]);
Assert.True(PortForwardRow.TryLocalPort(row, out var localPort));
Assert.Equal(18080, localPort);
Assert.True(PortForwardRow.TryLocalUrl(row, out var url));
Assert.Equal("http://127.0.0.1:18080/", url);
Assert.Equal("http://127.0.0.1:18080/", PortForwardRow.LocalUrl(18080));
Assert.Equal(
"Port-forward started: http://127.0.0.1:18080 → apps/web-1:8080.",
PortForwardRow.StartedMessage(handle));
using var rebound = new PortForwardHandle("web-1", "apps", 8080, 18081, Stream.Null);
Assert.Equal(
"Port-forward rebound: localhost:18080 → http://127.0.0.1:18081 → apps/web-1:8080.",
PortForwardRow.ReboundMessage(18080, rebound));
Assert.Equal("Port-forward failed: bind failed", PortForwardRow.FailedMessage(["bind failed"]));
}
[Fact]
public void PortForwardRow_shows_requested_service_port_not_mapped_container_port() {
using var handle = new PortForwardHandle(
"longhorn-ui-1",
"longhorn-system",
8000,
80,
Stream.Null,
requestedPort: 80);
var row = PortForwardRow.From(handle, "pf-80");
Assert.Equal("localhost:80", row.Name);
Assert.Equal("80", row.Cells["Local"]);
Assert.Equal("80", row.Cells["Remote"]);
Assert.Equal(
"Port-forward started: http://127.0.0.1:80 → longhorn-system/longhorn-ui-1:80.",
PortForwardRow.StartedMessage(handle));
}
[Fact]
public void Every_section_has_a_distinct_icon_path() {
var paths = ResourceCatalog.Sections.Select(NavigatorIcons.Path).ToList();

View File

@ -0,0 +1,287 @@
using System.Text.Json.Nodes;
using MaksIT.ClusterConsole.Shared;
namespace MaksIT.ClusterConsole.Tests;
public class ServicePortForwardTests {
[Fact]
public void ResourceOwnership_matches_service_selector_not_matchLabels() {
var service = Service("""{ "app": "web" }""");
var matching = Pod("web-1", """{ "app": "web", "pod-template-hash": "abc" }""");
var other = Pod("db-1", """{ "app": "db" }""");
Assert.True(ResourceOwnership.Owns(matching, service));
Assert.False(ResourceOwnership.Owns(other, service));
Assert.NotNull(ResourceOwnership.SelectorLabels(service));
Assert.Equal("web", ResourceOwnership.SelectorLabels(service)!["app"]?.GetValue<string>());
}
[Fact]
public void ResourceOwnership_still_matches_workload_matchLabels() {
var deployment = JsonNode.Parse("""
{
"kind": "Deployment",
"metadata": { "name": "web" },
"spec": { "selector": { "matchLabels": { "app": "web" } } }
}
""") as JsonObject;
var pod = Pod("web-1", """{ "app": "web" }""");
Assert.NotNull(deployment);
Assert.True(ResourceOwnership.Owns(pod, deployment));
}
[Fact]
public void ResourceOwnership_service_selector_does_not_match_same_helm_instance() {
var service = JsonNode.Parse("""
{
"kind": "Service",
"metadata": {
"name": "longhorn-frontend",
"namespace": "longhorn-system",
"labels": {
"app": "longhorn-ui",
"app.kubernetes.io/instance": "longhorn",
"app.kubernetes.io/name": "longhorn"
}
},
"spec": {
"selector": { "app": "longhorn-ui" },
"ports": [{ "name": "http", "port": 80, "targetPort": "http" }]
}
}
""") as JsonObject;
var ui = Pod(
"longhorn-ui-1",
"""{ "app": "longhorn-ui", "app.kubernetes.io/instance": "longhorn", "app.kubernetes.io/name": "longhorn" }""");
var csi = Pod(
"csi-attacher-1",
"""{ "app": "csi-attacher", "app.kubernetes.io/instance": "longhorn", "app.kubernetes.io/name": "longhorn" }""");
Assert.NotNull(service);
Assert.True(ResourceOwnership.Owns(ui, service));
Assert.False(ResourceOwnership.Owns(csi, service));
}
[Fact]
public void Resolve_skips_pods_that_lack_the_named_targetPort() {
var service = JsonNode.Parse("""
{
"kind": "Service",
"metadata": { "name": "longhorn-frontend", "namespace": "longhorn-system" },
"spec": {
"selector": { "app": "longhorn-ui" },
"ports": [{ "name": "http", "port": 80, "targetPort": "http" }]
}
}
""") as JsonObject;
var csi = JsonNode.Parse("""
{
"kind": "Pod",
"metadata": { "name": "csi-attacher-1", "namespace": "longhorn-system", "labels": { "app": "csi-attacher" } },
"spec": { "containers": [{ "name": "attacher", "ports": [{ "containerPort": 8443 }] }] },
"status": { "phase": "Running", "conditions": [{ "type": "Ready", "status": "True" }] }
}
""") as JsonObject;
var ui = JsonNode.Parse("""
{
"kind": "Pod",
"metadata": { "name": "longhorn-ui-1", "namespace": "longhorn-system", "labels": { "app": "longhorn-ui" } },
"spec": { "containers": [{ "name": "longhorn-ui", "ports": [{ "name": "http", "containerPort": 8000 }] }] },
"status": { "phase": "Running", "conditions": [{ "type": "Ready", "status": "True" }] }
}
""") as JsonObject;
Assert.NotNull(service);
Assert.NotNull(csi);
Assert.NotNull(ui);
var pods = ResourceCatalog.Find("pods")!;
var resolved = ServicePortForward.Resolve(
service,
[ResourceRow.From(csi, pods), ResourceRow.From(ui, pods)],
null,
80);
Assert.True(resolved.IsSuccess);
Assert.Equal("longhorn-ui-1", resolved.Value!.PodName);
Assert.Equal(8000, resolved.Value.ContainerPort);
Assert.Equal(80, resolved.Value.RequestedPort);
}
[Fact]
public void MapPort_translates_service_port_to_numeric_targetPort() {
var service = Service(
"""{ "app": "web" }""",
"""[{ "port": 80, "targetPort": 8080 }]""");
var mapped = ServicePortForward.MapPort(service, Pod("web-1", """{ "app": "web" }"""), 80);
Assert.True(mapped.IsSuccess);
Assert.Equal(8080, mapped.Value);
}
[Fact]
public void MapPort_resolves_named_targetPort_on_the_pod() {
var service = Service(
"""{ "app": "web" }""",
"""[{ "port": 80, "targetPort": "http" }]""");
var pod = JsonNode.Parse("""
{
"kind": "Pod",
"metadata": { "name": "web-1", "namespace": "apps", "labels": { "app": "web" } },
"spec": {
"containers": [
{ "name": "app", "ports": [{ "name": "http", "containerPort": 9090 }] }
]
},
"status": { "phase": "Running", "conditions": [{ "type": "Ready", "status": "True" }] }
}
""") as JsonObject;
Assert.NotNull(pod);
var mapped = ServicePortForward.MapPort(service, pod, 80);
Assert.True(mapped.IsSuccess);
Assert.Equal(9090, mapped.Value);
}
[Fact]
public void MapPort_keeps_unmatched_remote_port_as_container_port() {
var service = Service(
"""{ "app": "web" }""",
"""[{ "port": 80, "targetPort": 8080 }]""");
var mapped = ServicePortForward.MapPort(service, Pod("web-1", """{ "app": "web" }"""), 8080);
Assert.True(mapped.IsSuccess);
Assert.Equal(8080, mapped.Value);
}
[Fact]
public void Resolve_picks_a_running_pod_and_maps_the_service_port() {
var service = Service(
"""{ "app": "web" }""",
"""[{ "port": 5432, "targetPort": 5432 }]""");
var pending = ResourceRow.From(Pod("web-0", """{ "app": "web" }""", "Pending"), ResourceCatalog.Find("pods")!);
var running = ResourceRow.From(
Pod("web-1", """{ "app": "web" }""", "Running", ready: true),
ResourceCatalog.Find("pods")!);
var resolved = ServicePortForward.Resolve(service, [pending, running], null, 5432);
Assert.True(resolved.IsSuccess);
Assert.Equal("web-1", resolved.Value!.PodName);
Assert.Equal("apps", resolved.Value.Namespace);
Assert.Equal(5432, resolved.Value.ContainerPort);
}
[Fact]
public void Resolve_rejects_a_service_without_selector() {
var service = JsonNode.Parse("""
{
"kind": "Service",
"metadata": { "name": "external", "namespace": "apps" },
"spec": { "type": "ExternalName", "externalName": "db.example.com", "ports": [{ "port": 5432 }] }
}
""") as JsonObject;
Assert.NotNull(service);
var resolved = ServicePortForward.Resolve(service, [], null, 5432);
Assert.False(resolved.IsSuccess);
Assert.Contains("selector", string.Join(' ', resolved.Messages), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PickPod_skips_a_preferred_pod_that_is_not_running() {
var pods = ResourceCatalog.Find("pods")!;
var preferred = ResourceRow.From(Pod("web-old", """{ "app": "web" }""", "Pending"), pods);
var running = ResourceRow.From(Pod("web-new", """{ "app": "web" }""", "Running", ready: true), pods);
var picked = ServicePortForward.PickPod([preferred, running], preferred);
Assert.NotNull(picked);
Assert.Equal("web-new", picked.Name);
}
[Fact]
public void PickRunning_matches_stable_labels_when_the_replica_name_changes() {
var pods = ResourceCatalog.Find("pods")!;
var gone = ResourceRow.From(
Pod("longhorn-ui-5d6f5b4c44-4p6n5", """{ "app": "longhorn-ui", "pod-template-hash": "5d6f5b4c44" }""", "Pending"),
pods);
var next = ResourceRow.From(
Pod("longhorn-ui-aaaa-bbbb", """{ "app": "longhorn-ui", "pod-template-hash": "aaaa" }""", "Running", ready: true),
pods);
var picked = ServicePortForward.PickRunning(
[gone, next],
"longhorn-ui-5d6f5b4c44-4p6n5",
new Dictionary<string, string> { ["app"] = "longhorn-ui" });
Assert.NotNull(picked);
Assert.Equal("longhorn-ui-aaaa-bbbb", picked.Name);
}
[Fact]
public void StableLabels_strips_pod_template_hash() {
var pod = Pod(
"longhorn-ui-5d6f5b4c44-4p6n5",
"""{ "app": "longhorn-ui", "pod-template-hash": "5d6f5b4c44" }""");
var labels = ServicePortForward.StableLabels(pod);
Assert.NotNull(labels);
Assert.Equal("longhorn-ui", labels["app"]);
Assert.False(labels.ContainsKey("pod-template-hash"));
}
[Fact]
public void StableLabels_uses_a_service_selector() {
var service = Service("""{ "app": "longhorn-ui" }""");
var labels = ServicePortForward.StableLabels(service);
Assert.NotNull(labels);
Assert.Equal("longhorn-ui", labels["app"]);
}
[Fact]
public void Services_catalog_can_port_forward_and_lists_related_pods() {
var descriptor = ResourceCatalog.Find("services")!;
Assert.True(descriptor.Actions.CanPortForward);
Assert.Contains("Pods", descriptor.DetailTabs);
Assert.Equal(5432, ServicePortForward.DefaultPort(Service(
"""{ "app": "web" }""",
"""[{ "port": 5432, "targetPort": 5432 }]""")));
}
private static JsonObject Service(string selectorJson, string? portsJson = null) {
var ports = portsJson ?? """[{ "port": 80, "targetPort": 80 }]""";
var parsed = JsonNode.Parse($$"""
{
"kind": "Service",
"metadata": { "name": "web", "namespace": "apps" },
"spec": {
"selector": {{selectorJson}},
"ports": {{ports}}
}
}
""") as JsonObject;
Assert.NotNull(parsed);
return parsed;
}
private static JsonObject Pod(string name, string labelsJson, string phase = "Running", bool ready = false) {
var readyStatus = ready ? "True" : "False";
var parsed = JsonNode.Parse($$"""
{
"kind": "Pod",
"metadata": { "name": "{{name}}", "namespace": "apps", "labels": {{labelsJson}} },
"status": {
"phase": "{{phase}}",
"conditions": [{ "type": "Ready", "status": "{{readyStatus}}" }]
}
}
""") as JsonObject;
Assert.NotNull(parsed);
return parsed;
}
}

View File

@ -14,19 +14,7 @@
MinHeight="640">
<DockPanel>
<Border DockPanel.Dock="Bottom" Padding="8,6" Background="#15171a">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Status}" Classes="hint" VerticalAlignment="Center" />
<ItemsControl Grid.Column="1" ItemsSource="{Binding ActivePage.PortForwards}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PortForwardItemViewModel">
<StackPanel Orientation="Horizontal" Margin="8,0,0,0">
<TextBlock Text="{Binding Display}" VerticalAlignment="Center" Margin="0,0,6,0" />
<Button Content="Stop" Click="OnStopPortForwardClick" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
<TextBlock Text="{Binding Status}" Classes="hint" VerticalAlignment="Center" />
</Border>
<Grid Name="ShellGrid" ColumnDefinitions="248,4,228,4,*">
@ -404,8 +392,12 @@
FontWeight="SemiBold"
Classes.warning="{Binding ActivePage.HasLimitOvercommit}" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
<Button Content="Apply selected" Command="{Binding ActivePage.ApplySelectedLimitCommand}" />
<Button Content="Apply edits" Command="{Binding ActivePage.ApplyChangedLimitsCommand}" />
<Button Content="Apply selected"
Command="{Binding ActivePage.ApplySelectedLimitCommand}"
IsVisible="{Binding ActivePage.HasSelectedLimit}" />
<Button Content="Apply edits"
Command="{Binding ActivePage.ApplyChangedLimitsCommand}"
IsVisible="{Binding ActivePage.HasDirtyLimits}" />
</StackPanel>
</Grid>
<DataGrid Name="OverviewLimitsGrid"
@ -611,9 +603,9 @@
<TabItem Header="YAML">
<DockPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,6,0,0" Spacing="6">
<Button Content="New" Command="{Binding ActivePage.NewResourceCommand}" />
<Button Content="Reload" Command="{Binding ActivePage.ReloadYamlCommand}" />
<Button Content="Apply YAML" Command="{Binding ActivePage.ApplyYamlCommand}" />
<Button Content="New" Command="{Binding ActivePage.NewResourceCommand}" IsVisible="{Binding ActivePage.CanCreateResource}" />
<Button Content="Reload" Command="{Binding ActivePage.ReloadYamlCommand}" IsVisible="{Binding ActivePage.CanReloadYaml}" />
<Button Content="Apply YAML" Command="{Binding ActivePage.ApplyYamlCommand}" IsVisible="{Binding ActivePage.CanApplyYaml}" />
<TextBlock Text="Unsaved YAML" Classes="hint" IsVisible="{Binding ActivePage.IsDirty}" VerticalAlignment="Center" />
</StackPanel>
<TextBox Text="{Binding ActivePage.YamlText}" AcceptsReturn="True" FontFamily="Consolas, monospace" TextWrapping="NoWrap" />
@ -621,7 +613,7 @@
</TabItem>
<TabItem Header="Data" IsVisible="{Binding ActivePage.IsDataEditor}">
<DockPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,6,0,0" Spacing="6">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,6,0,0" Spacing="6" IsVisible="{Binding ActivePage.CanEditData}">
<Button Content="Add key" Command="{Binding ActivePage.AddDataEntryCommand}" />
<Button Content="Apply data" Command="{Binding ActivePage.ApplyDataCommand}" />
<TextBlock Text="Decoded secret values are saved back as stringData." Classes="hint" VerticalAlignment="Center" />
@ -700,7 +692,7 @@
AcceptsReturn="False" />
</DockPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Content="Ask about selection" Command="{Binding ActivePage.AskAboutSelectionCommand}" />
<Button Content="Ask about selection" Command="{Binding ActivePage.AskAboutSelectionCommand}" IsVisible="{Binding ActivePage.HasSelectedRow}" />
<Button Content="Clear" Command="{Binding ActivePage.ClearChatCommand}" />
</StackPanel>
</StackPanel>
@ -729,20 +721,44 @@
</DockPanel>
</Grid>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="10,8" Spacing="4" IsVisible="{Binding ActivePage.IsResourceTable}">
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="10,8" Spacing="4" IsVisible="{Binding ActivePage.HasFooterActions}">
<Button Content="Delete" Command="{Binding ActivePage.DeleteCommand}" IsVisible="{Binding ActivePage.CanDelete}" />
<Button Content="Stop" Command="{Binding ActivePage.StopSelectedPortForwardCommand}" IsVisible="{Binding ActivePage.CanStopPortForward}" />
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center" IsVisible="{Binding ActivePage.CanStopPortForward}">
<TextBlock Text="Local" Classes="hint" VerticalAlignment="Center" ToolTip.Tip="Host port on this machine" />
<NumericUpDown Value="{Binding ActivePage.RebindLocalPort}"
Minimum="1"
Maximum="65535"
Width="90"
ToolTip.Tip="Listen on this host port" />
<Button Content="Rebind" Command="{Binding ActivePage.RebindSelectedPortForwardCommand}" />
</StackPanel>
<Button Content="Force delete" Command="{Binding ActivePage.ForceDeleteCommand}" IsVisible="{Binding ActivePage.CanForceDelete}" />
<Button Content="Force delete namespace" Command="{Binding ActivePage.DeleteNamespaceCommand}" IsVisible="{Binding ActivePage.CanDeleteNamespace}" />
<NumericUpDown Value="{Binding ActivePage.ScaleReplicas}" Minimum="0" Maximum="100" Width="80" IsVisible="{Binding ActivePage.CanScale}" />
<Button Content="Scale" Command="{Binding ActivePage.ScaleCommand}" IsVisible="{Binding ActivePage.CanScale}" />
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center" IsVisible="{Binding ActivePage.CanScale}">
<NumericUpDown Value="{Binding ActivePage.ScaleReplicas}" Minimum="0" Maximum="100" Width="80" />
<Button Content="Scale" Command="{Binding ActivePage.ScaleCommand}" />
</StackPanel>
<Button Content="Restart" Command="{Binding ActivePage.RestartCommand}" IsVisible="{Binding ActivePage.CanRestart}" />
<Button Content="Cordon" Command="{Binding ActivePage.CordonCommand}" IsVisible="{Binding ActivePage.CanCordon}" />
<Button Content="Uncordon" Command="{Binding ActivePage.UncordonCommand}" IsVisible="{Binding ActivePage.CanCordon}" />
<Button Content="Drain" Command="{Binding ActivePage.DrainCommand}" IsVisible="{Binding ActivePage.CanDrain}" />
<Button Content="Trigger" Command="{Binding ActivePage.TriggerCronCommand}" IsVisible="{Binding ActivePage.CanTrigger}" />
<NumericUpDown Value="{Binding ActivePage.ForwardLocalPort}" Minimum="1" Maximum="65535" Width="90" IsVisible="{Binding ActivePage.CanPortForward}" />
<NumericUpDown Value="{Binding ActivePage.ForwardContainerPort}" Minimum="1" Maximum="65535" Width="90" IsVisible="{Binding ActivePage.CanPortForward}" />
<Button Content="Port-forward" Command="{Binding ActivePage.StartPortForwardCommand}" IsVisible="{Binding ActivePage.CanPortForward}" />
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center" IsVisible="{Binding ActivePage.CanPortForward}">
<TextBlock Text="Local" Classes="hint" VerticalAlignment="Center" ToolTip.Tip="Port on this machine" />
<NumericUpDown Value="{Binding ActivePage.ForwardLocalPort}"
Minimum="1"
Maximum="65535"
Width="90"
ToolTip.Tip="Local port on this machine" />
<TextBlock Text="Remote" Classes="hint" VerticalAlignment="Center" ToolTip.Tip="Service or container port in the cluster" />
<NumericUpDown Value="{Binding ActivePage.ForwardContainerPort}"
Minimum="1"
Maximum="65535"
Width="90"
ToolTip.Tip="Remote port on the Service or Pod" />
<Button Content="Port-forward" Command="{Binding ActivePage.StartPortForwardCommand}" />
</StackPanel>
</StackPanel>
</Grid>
</Grid>

View File

@ -54,15 +54,15 @@ public partial class MainWindow : Window {
private void OnResourceGridDoubleTapped(object? sender, TappedEventArgs e) {
if (e.Source is not Control { DataContext: ResourceRow })
return;
if (DataContext is MainViewModel { ActivePage: { } page }
&& page.BrowseFilesCommand.CanExecute(null))
page.BrowseFilesCommand.Execute(null);
}
if (DataContext is not MainViewModel { ActivePage: { } page })
return;
if (page.IsPortForwardingView) {
page.OpenSelectedPortForwardCommand.Execute(null);
return;
}
private void OnStopPortForwardClick(object? sender, RoutedEventArgs e) {
if (sender is Control { DataContext: PortForwardItemViewModel item }
&& DataContext is MainViewModel { ActivePage: { } page })
page.StopPortForwardCommand.Execute(item);
if (page.BrowseFilesCommand.CanExecute(null))
page.BrowseFilesCommand.Execute(null);
}
private void RebuildColumns(MainViewModel viewModel) {

View File

@ -1,7 +1,10 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json.Nodes;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MaksIT.Results;
using MaksIT.ClusterConsole.Client;
using MaksIT.ClusterConsole.Shared;
@ -24,6 +27,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
private string? _detailsUid;
private bool _loadingDetails;
private bool _updatingPodContext;
private string? _rebindUid;
private readonly List<double> _cpuHistory = [];
private readonly List<double> _memoryHistory = [];
private const int HistoryPoints = 60;
@ -123,6 +127,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
[ObservableProperty]
private int forwardContainerPort = 80;
[ObservableProperty]
private int rebindLocalPort = 8080;
[ObservableProperty]
private bool followLogs;
@ -183,13 +190,13 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
private void ShowNodesOverview() => OverviewPerNode = true;
public bool CanScale =>
SelectedResourceRef()?.Actions.CanScale == true;
HasSelectedRow && SelectedResourceRef()?.Actions.CanScale == true;
public bool CanRestart =>
SelectedResourceRef()?.Actions.CanRestart == true;
HasSelectedRow && SelectedResourceRef()?.Actions.CanRestart == true;
public bool CanDelete =>
SelectedResourceRef()?.Actions.CanDelete == true && SelectedRow is not null;
HasSelectedRow && SelectedResourceRef()?.Actions.CanDelete == true;
public bool CanForceDelete =>
CanDelete && SelectedDescriptor?.Kind != "Namespace";
@ -197,14 +204,15 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
public bool CanDeleteNamespace {
get {
var name = TargetNamespaceName;
return SelectedDescriptor?.Kind == "Namespace"
return HasSelectedRow
&& SelectedDescriptor?.Kind == "Namespace"
&& !string.IsNullOrEmpty(name)
&& !IsProtectedNamespace(name);
}
}
public bool CanBrowseFiles =>
SelectedRow is not null
HasSelectedRow
&& SelectedDescriptor?.Id is "persistentvolumes" or "persistentvolumeclaims";
public event Action<VolumeFilesViewModel>? VolumeFilesRequested;
@ -213,7 +221,55 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
public bool CanExec => HasDetailTab("Terminal");
public bool CanPortForward => SelectedDescriptor?.Actions.CanPortForward == true || TargetPodName is not null;
public bool CanPortForward =>
HasSelectedRow
&& !IsPortForwardingView
&& (SelectedDescriptor?.Actions.CanPortForward == true || TargetPodName is not null);
public bool IsPortForwardingView =>
SelectedNavItem?.Id == ResourceCatalog.PortForwardingId;
public bool CanStopPortForward =>
IsPortForwardingView && HasSelectedRow;
public bool CanCordon =>
HasSelectedRow && SelectedDescriptor?.Actions.CanCordon == true;
public bool CanDrain =>
HasSelectedRow && SelectedDescriptor?.Actions.CanDrain == true;
public bool CanTrigger =>
HasSelectedRow && SelectedDescriptor?.Actions.CanTrigger == true;
public bool CanApply =>
(SelectedResourceRef() ?? SelectedDescriptor)?.Actions.CanApply != false;
public bool CanCreateResource =>
CanApply
&& SelectedDescriptor is not null
&& !IsPortForwardingView
&& !IsClusterDashboard
&& !IsWorkloadsDashboard;
public bool CanReloadYaml => HasSelectedRow;
public bool CanApplyYaml =>
CanApply && (HasSelectedRow || IsDirty) && !string.IsNullOrWhiteSpace(YamlText);
public bool CanEditData =>
IsDataEditor && (HasSelectedRow || IsDirty);
public bool HasFooterActions =>
CanDelete
|| CanForceDelete
|| CanDeleteNamespace
|| CanScale
|| CanRestart
|| CanCordon
|| CanDrain
|| CanTrigger
|| CanPortForward
|| CanStopPortForward;
public bool ShowEventsTab => HasDetailTab("Events");
@ -231,15 +287,6 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
public bool HasSelectedRow => SelectedRow is not null;
public bool CanCordon => SelectedDescriptor?.Actions.CanCordon == true;
public bool CanDrain => SelectedDescriptor?.Actions.CanDrain == true;
public bool CanTrigger => SelectedDescriptor?.Actions.CanTrigger == true;
public bool CanApply =>
(SelectedResourceRef() ?? SelectedDescriptor)?.Actions.CanApply != false;
public string DetailsTitle {
get {
if (SelectedRow is null)
@ -278,6 +325,10 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
public bool HasContainerLimits => LimitRows.Count > 0;
public bool HasSelectedLimit => SelectedLimitRow is not null;
public bool HasDirtyLimits => LimitRows.Any(row => row.IsDirty);
public bool HasLimitOvercommit =>
CpuSlice.LimitsExceedCapacity || MemorySlice.LimitsExceedCapacity;
@ -297,10 +348,14 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
private ResourceDescriptor? SelectedResourceRef() {
var match = _workspace.FindByGvk(SelectedDocumentApiVersion, SelectedDocumentKind);
if (match is not null && match.Id != ResourceCatalog.ApplicationsId)
if (match is not null
&& match.Id != ResourceCatalog.ApplicationsId
&& match.Id != ResourceCatalog.PortForwardingId)
return match;
if (SelectedDescriptor is { } descriptor && descriptor.Id != ResourceCatalog.ApplicationsId)
if (SelectedDescriptor is { } descriptor
&& descriptor.Id != ResourceCatalog.ApplicationsId
&& descriptor.Id != ResourceCatalog.PortForwardingId)
return descriptor;
return null;
@ -342,6 +397,8 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
PausePolling();
_logsCts?.Cancel();
_chatCts?.Cancel();
foreach (var row in LimitRows)
row.PropertyChanged -= OnLimitRowPropertyChanged;
foreach (var pf in PortForwards.ToList())
pf.Handle.Dispose();
PortForwards.Clear();
@ -439,11 +496,17 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
partial void OnSelectedRowChanged(ResourceRow? value) {
NotifyActionFlags();
NotifyDetailsUi();
SyncRebindLocalPort();
if (value?.Uid == _detailsUid)
return;
_ = LoadDetailsAsync();
}
partial void OnSelectedLimitRowChanged(LimitRowViewModel? value) {
OnPropertyChanged(nameof(HasSelectedLimit));
OnPropertyChanged(nameof(CanApplyLimits));
}
partial void OnSelectedRelatedPodChanged(ResourceRow? value) {
if (_updatingPodContext)
return;
@ -462,6 +525,11 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
_ = LoadLogsAsync();
}
partial void OnIsDirtyChanged(bool value) {
OnPropertyChanged(nameof(CanApplyYaml));
OnPropertyChanged(nameof(CanEditData));
}
partial void OnYamlTextChanged(string value) {
if (!_loadingDetails)
IsDirty = true;
@ -499,9 +567,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
}
if (SelectedNavItem.Id == ResourceCatalog.PortForwardingId) {
_listedRows.Clear();
Rows.Clear();
_setStatus($"{PortForwards.Count} active port-forward(s).");
ShowPortForwardRows();
return;
}
@ -748,23 +814,50 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
if (_workspace.Session is null || SelectedRow is null)
return;
var pod = TargetPodName;
var ns = TargetPodNamespace ?? "default";
if (pod is null) {
_setStatus(ShowPodsTab
? "Select a pod in the details pane, then port-forward."
: "Port-forward requires a pod.");
var target = await ResolvePortForwardTargetAsync();
if (!target.IsSuccess || target.Value is null) {
_setStatus(PortForwardRow.FailedMessage(target.Messages));
return;
}
var started = await _workspace.Session.PortForwardAsync(pod, ns, ForwardContainerPort, ForwardLocalPort);
var kind = IsServiceSelection
? "Service"
: IsPodSelection ? "Pod" : SelectedDescriptor?.Kind ?? "Pod";
var saved = new PersistedPortForward {
Context = Name,
Kind = kind,
Name = SelectedRow.Name,
Namespace = target.Value.Namespace,
PodName = target.Value.PodName,
LocalPort = ForwardLocalPort,
RemotePort = target.Value.RequestedPort,
MatchLabels = ServicePortForward.StableLabels(SelectedRow.Document)
};
var started = await _workspace.Session.PortForwardAsync(
target.Value.PodName,
target.Value.Namespace,
target.Value.ContainerPort,
ForwardLocalPort,
target.Value.RequestedPort,
ResolveEndpoint(saved));
if (!started.IsSuccess || started.Value is null) {
_setStatus(string.Join("; ", started.Messages));
_setStatus(PortForwardRow.FailedMessage(started.Messages));
return;
}
PortForwards.Add(new PortForwardItemViewModel { Handle = started.Value, Cluster = Name });
_setStatus($"Forwarding localhost:{ForwardLocalPort} ({Name})");
var item = new PortForwardItemViewModel {
Handle = started.Value,
Cluster = Name,
Uid = PortForwardRow.Uid(started.Value.LocalPort),
Kind = kind,
ResourceName = saved.Name,
MatchLabels = saved.MatchLabels
};
PortForwards.Add(item);
PersistStarted(item);
if (IsPortForwardingView)
ShowPortForwardRows();
_setStatus(PortForwardRow.StartedMessage(started.Value));
}
[RelayCommand]
@ -772,8 +865,88 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
if (item is null)
return;
var localPort = item.Handle.LocalPort;
item.Handle.Dispose();
PortForwards.Remove(item);
PersistStopped(localPort);
if (IsPortForwardingView)
ShowPortForwardRows();
_setStatus($"Stopped port-forward localhost:{localPort}.");
}
[RelayCommand]
private void StopSelectedPortForward() {
if (SelectedRow is null)
return;
var live = PortForwards.FirstOrDefault(item => item.Uid == SelectedRow.Uid);
if (live is not null) {
StopPortForward(live);
return;
}
if (!PortForwardRow.TryLocalPort(SelectedRow, out var localPort))
return;
PersistStopped(localPort);
ShowPortForwardRows();
_setStatus($"Stopped port-forward localhost:{localPort}.");
}
[RelayCommand]
private void OpenSelectedPortForward() {
if (!IsPortForwardingView || SelectedRow is null)
return;
if (!PortForwardRow.TryLocalPort(SelectedRow, out var localPort))
return;
if (!PortForwards.Any(item => item.Handle.LocalPort == localPort)) {
_setStatus(PortForwardRow.FailedMessage(["forward is not active"]));
return;
}
var url = PortForwardRow.LocalUrl(localPort);
try {
Process.Start(new ProcessStartInfo {
FileName = url,
UseShellExecute = true
});
}
catch (Exception ex) {
_setStatus($"Could not open {url}: {ex.Message}");
}
}
[RelayCommand]
private async Task RebindSelectedPortForwardAsync() {
if (_workspace.Session is null || SelectedRow is null || !IsPortForwardingView)
return;
if (!PortForwardRow.TryLocalPort(SelectedRow, out var oldPort))
return;
var newPort = RebindLocalPort;
if (newPort == oldPort) {
_setStatus($"Port-forward already listening on localhost:{oldPort}.");
return;
}
if (newPort is < 1 or > 65535) {
_setStatus(PortForwardRow.FailedMessage(["local port must be between 1 and 65535"]));
return;
}
if (IsLocalPortTaken(newPort)) {
_setStatus(PortForwardRow.FailedMessage([$"localhost:{newPort} is already in use by another port-forward"]));
return;
}
var live = PortForwards.FirstOrDefault(item => item.Uid == SelectedRow.Uid)
?? PortForwards.FirstOrDefault(item => item.Handle.LocalPort == oldPort);
if (live is not null)
await RebindLiveAsync(live, oldPort, newPort);
else
await RebindPersistedAsync(oldPort, newPort);
}
[RelayCommand]
@ -1060,7 +1233,13 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
desired.Add(new LimitRowViewModel(limit));
}
foreach (var row in LimitRows)
row.PropertyChanged -= OnLimitRowPropertyChanged;
CollectionSync.MergeByKey(LimitRows, desired, row => LimitKey(row.Source));
foreach (var row in LimitRows)
row.PropertyChanged += OnLimitRowPropertyChanged;
if (keep is not null && LimitRows.Contains(keep))
SelectedLimitRow = keep;
else if (SelectedLimitRow is null || !LimitRows.Contains(SelectedLimitRow))
@ -1068,9 +1247,20 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
OnPropertyChanged(nameof(HasContainerLimits));
OnPropertyChanged(nameof(HasLimitOvercommit));
OnPropertyChanged(nameof(LimitsCaption));
OnPropertyChanged(nameof(HasSelectedLimit));
OnPropertyChanged(nameof(HasDirtyLimits));
OnPropertyChanged(nameof(CanApplyLimits));
}
private void OnLimitRowPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName is nameof(LimitRowViewModel.CpuLimit)
or nameof(LimitRowViewModel.MemoryLimit)
or nameof(LimitRowViewModel.IsDirty)) {
OnPropertyChanged(nameof(HasDirtyLimits));
OnPropertyChanged(nameof(CanApplyLimits));
}
}
private static string LimitKey(WorkloadContainerLimit limit) =>
$"{limit.Namespace}\0{limit.WorkloadKind}\0{limit.WorkloadName}\0{limit.Container}\0{limit.Init}";
@ -1168,6 +1358,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
ReplaceRelatedPods(related);
var podDocument = IsPodSelection ? document : SelectedRelatedPod?.Document;
ApplyContainers(podDocument);
ApplyServiceForwardPorts(document);
OverviewText = row.FormatOverview(Containers);
var events = await _workspace.EventsForAsync(row);
if (!DetailsStillCurrent(row))
@ -1279,6 +1470,343 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
OnPropertyChanged(nameof(HasContainers));
}
private void ShowPortForwardRows(string? keepUid = null) {
keepUid ??= SelectedRow?.Uid;
var livePorts = PortForwards.Select(item => item.Handle.LocalPort).ToHashSet();
_listedRows.Clear();
foreach (var item in PortForwards)
_listedRows.Add(PortForwardRow.From(item.Handle, item.Uid));
foreach (var saved in _configuration.Current.PortForwardsFor(Name)) {
if (livePorts.Contains(saved.LocalPort))
continue;
_listedRows.Add(PortForwardRow.FromPersisted(saved, "Failed"));
}
foreach (var column in ResourceCatalog.PortForwardingDescriptor.Columns)
FilterFor(column.Header).LoadValues(_listedRows);
ApplyColumnFilters(keepUid);
NotifyActionFlags();
NotifyDetailsUi();
}
public async Task<PortForwardRestoreSummary> RestorePortForwardsAsync() {
var saved = _configuration.Current.PortForwardsFor(Name);
if (saved.Count == 0)
return new PortForwardRestoreSummary(0, []);
var restored = 0;
var failures = new List<string>();
foreach (var item in saved) {
if (PortForwards.Any(live => live.Handle.LocalPort == item.LocalPort)) {
restored++;
continue;
}
var opened = await OpenPersistedPortForwardAsync(item);
if (opened.IsSuccess)
restored++;
else
failures.Add($"localhost:{item.LocalPort} ({string.Join("; ", opened.Messages)})");
}
if (IsPortForwardingView)
ShowPortForwardRows();
return new PortForwardRestoreSummary(restored, failures);
}
private async Task<Result> OpenPersistedPortForwardAsync(PersistedPortForward saved) {
if (_workspace.Session is null)
return Result.ServiceUnavailable("not connected");
var resolved = await ResolveOwnedTargetAsync(saved);
if (!resolved.IsSuccess || resolved.Value is null)
return resolved.ToResult();
saved.PodName = resolved.Value.PodName;
saved.Namespace = resolved.Value.Namespace;
var started = await _workspace.Session.PortForwardAsync(
resolved.Value.PodName,
resolved.Value.Namespace,
resolved.Value.ContainerPort,
saved.LocalPort,
resolved.Value.RequestedPort,
ResolveEndpoint(saved));
if (!started.IsSuccess || started.Value is null)
return started.ToResult();
var item = new PortForwardItemViewModel {
Handle = started.Value,
Cluster = Name,
Uid = PortForwardRow.Uid(started.Value.LocalPort),
Kind = string.IsNullOrWhiteSpace(saved.Kind) ? "Pod" : saved.Kind,
ResourceName = saved.Name,
MatchLabels = saved.MatchLabels
};
PortForwards.Add(item);
PersistStarted(item);
return Result.Ok();
}
private Func<CancellationToken, Task<Result<PortForwardEndpoint>>> ResolveEndpoint(PersistedPortForward saved) =>
async _ => {
var resolved = await ResolveOwnedTargetAsync(saved);
if (!resolved.IsSuccess || resolved.Value is null)
return new Result<PortForwardEndpoint>(null, false, resolved.Messages, resolved.StatusCode);
saved.PodName = resolved.Value.PodName;
saved.Namespace = resolved.Value.Namespace;
return Result<PortForwardEndpoint>.Ok(new PortForwardEndpoint(
resolved.Value.PodName,
resolved.Value.Namespace,
resolved.Value.ContainerPort));
};
private async Task<Result<PortForwardTarget>> ResolveOwnedTargetAsync(PersistedPortForward saved) {
var ns = string.IsNullOrWhiteSpace(saved.Namespace) ? "default" : saved.Namespace;
if (string.Equals(saved.Kind, "Service", StringComparison.OrdinalIgnoreCase))
return await ResolvePersistedServiceAsync(saved, ns);
var descriptor = ResourceCatalog.BuiltIns.FirstOrDefault(d =>
d.Kind.Equals(saved.Kind, StringComparison.OrdinalIgnoreCase));
if (descriptor is not null
&& !string.Equals(saved.Kind, "Pod", StringComparison.OrdinalIgnoreCase)) {
var got = await _workspace.Session!.GetAsync(descriptor.ToRef(), saved.Name, ns);
if (!got.IsSuccess || got.Value is null)
return new Result<PortForwardTarget>(null, false, got.Messages, got.StatusCode);
RememberLabels(saved, got.Value);
var listed = await _workspace.RelatedPodsAsync(ResourceRow.From(got.Value, descriptor));
if (!listed.IsSuccess)
return new Result<PortForwardTarget>(null, false, listed.Messages, listed.StatusCode);
var picked = ServicePortForward.PickRunning(listed.Value ?? [], saved.PodName, saved.MatchLabels);
if (picked is null)
return Result<PortForwardTarget>.NotFound(null, "No running pods match this port-forward.");
return Result<PortForwardTarget>.Ok(
new PortForwardTarget(picked.Name, picked.Namespace ?? ns, saved.RemotePort, saved.RemotePort));
}
return await ResolvePersistedPodAsync(saved, ns);
}
private async Task<Result<PortForwardTarget>> ResolvePersistedServiceAsync(PersistedPortForward saved, string ns) {
var services = ResourceCatalog.Find("services")!;
var got = await _workspace.Session!.GetAsync(services.ToRef(), saved.Name, ns);
if (!got.IsSuccess || got.Value is null)
return new Result<PortForwardTarget>(null, false, got.Messages, got.StatusCode);
RememberLabels(saved, got.Value);
var owner = ResourceRow.From(got.Value, services);
var listed = await _workspace.RelatedPodsAsync(owner);
if (!listed.IsSuccess)
return new Result<PortForwardTarget>(null, false, listed.Messages, listed.StatusCode);
var preferred = (listed.Value ?? []).FirstOrDefault(p =>
string.Equals(p.Name, saved.PodName, StringComparison.Ordinal));
return ServicePortForward.Resolve(got.Value, listed.Value ?? [], preferred, saved.RemotePort);
}
private async Task<Result<PortForwardTarget>> ResolvePersistedPodAsync(PersistedPortForward saved, string ns) {
var pods = ResourceCatalog.Find("pods")!;
var preferredName = string.IsNullOrWhiteSpace(saved.PodName) ? saved.Name : saved.PodName;
var got = await _workspace.Session!.GetAsync(pods.ToRef(), preferredName, ns);
if (got.IsSuccess && got.Value is not null) {
RememberLabels(saved, got.Value);
var row = ResourceRow.From(got.Value, pods);
if (string.Equals(PodStatus.Of(got.Value), "Running", StringComparison.OrdinalIgnoreCase))
return Result<PortForwardTarget>.Ok(
new PortForwardTarget(row.Name, row.Namespace ?? ns, saved.RemotePort, saved.RemotePort));
}
if (saved.MatchLabels is not { Count: > 0 }) {
if (!got.IsSuccess)
return new Result<PortForwardTarget>(null, false, got.Messages, got.StatusCode);
return Result<PortForwardTarget>.NotFound(null, "No running pods match this port-forward.");
}
var listed = await _workspace.Session.ListAsync(pods.ToRef(), ns);
if (!listed.IsSuccess)
return new Result<PortForwardTarget>(null, false, listed.Messages, listed.StatusCode);
var rows = (listed.Value ?? []).Select(item => ResourceRow.From(item, pods)).ToList();
var picked = ServicePortForward.PickRunning(rows, preferredName, saved.MatchLabels);
if (picked is null)
return Result<PortForwardTarget>.NotFound(null, "No running pods match this port-forward.");
RememberLabels(saved, picked.Document);
return Result<PortForwardTarget>.Ok(
new PortForwardTarget(picked.Name, picked.Namespace ?? ns, saved.RemotePort, saved.RemotePort));
}
private static void RememberLabels(PersistedPortForward saved, JsonObject? document) {
if (saved.MatchLabels is { Count: > 0 })
return;
saved.MatchLabels = ServicePortForward.StableLabels(document);
}
private void PersistStarted(PortForwardItemViewModel item) {
var cfg = _configuration.Current;
cfg.UpsertPortForward(item.ToPersisted());
_configuration.Save(cfg);
}
private void PersistStopped(int localPort) {
var cfg = _configuration.Current;
cfg.RemovePortForward(Name, localPort);
_configuration.Save(cfg);
}
private async Task RebindLiveAsync(PortForwardItemViewModel live, int oldPort, int newPort) {
var previous = live.Handle;
var resolve = ResolveEndpoint(live.ToPersisted());
previous.Dispose();
var started = await _workspace.Session!.PortForwardAsync(
previous.PodName,
previous.Namespace,
previous.ContainerPort,
newPort,
previous.RequestedPort,
resolve);
if (!started.IsSuccess || started.Value is null) {
await RollbackLiveAsync(live, previous, oldPort, resolve);
ShowPortForwardRows(live.Uid);
_setStatus(PortForwardRow.FailedMessage(started.Messages));
return;
}
live.Handle = started.Value;
live.Uid = PortForwardRow.Uid(newPort);
PersistRebind(oldPort, live);
_rebindUid = live.Uid;
RebindLocalPort = newPort;
ShowPortForwardRows(live.Uid);
_setStatus(PortForwardRow.ReboundMessage(oldPort, started.Value));
}
private async Task RollbackLiveAsync(
PortForwardItemViewModel live,
PortForwardHandle previous,
int oldPort,
Func<CancellationToken, Task<Result<PortForwardEndpoint>>> resolve) {
var rollback = await _workspace.Session!.PortForwardAsync(
previous.PodName,
previous.Namespace,
previous.ContainerPort,
oldPort,
previous.RequestedPort,
resolve);
if (!rollback.IsSuccess || rollback.Value is null) {
PortForwards.Remove(live);
return;
}
live.Handle = rollback.Value;
live.Uid = PortForwardRow.Uid(oldPort);
}
private async Task RebindPersistedAsync(int oldPort, int newPort) {
var saved = _configuration.Current.PortForwardsFor(Name)
.FirstOrDefault(item => item.LocalPort == oldPort);
if (saved is null)
return;
var cfg = _configuration.Current;
cfg.RemovePortForward(Name, oldPort);
saved.LocalPort = newPort;
cfg.UpsertPortForward(saved);
_configuration.Save(cfg);
var opened = await OpenPersistedPortForwardAsync(saved);
_rebindUid = PortForwardRow.Uid(newPort);
RebindLocalPort = newPort;
ShowPortForwardRows(_rebindUid);
if (!opened.IsSuccess) {
_setStatus(PortForwardRow.FailedMessage(opened.Messages));
return;
}
var rebound = PortForwards.FirstOrDefault(item => item.Handle.LocalPort == newPort);
_setStatus(rebound is null
? $"Port-forward rebound to localhost:{newPort}."
: PortForwardRow.ReboundMessage(oldPort, rebound.Handle));
}
private void PersistRebind(int oldPort, PortForwardItemViewModel item) {
var cfg = _configuration.Current;
cfg.RemovePortForward(Name, oldPort);
cfg.UpsertPortForward(item.ToPersisted());
_configuration.Save(cfg);
}
private bool IsLocalPortTaken(int localPort) =>
PortForwards.Any(item => item.Handle.LocalPort == localPort)
|| _configuration.Current.PortForwardsFor(Name).Any(item => item.LocalPort == localPort);
private void SyncRebindLocalPort() {
if (!IsPortForwardingView || SelectedRow is null || !PortForwardRow.TryLocalPort(SelectedRow, out var port)) {
_rebindUid = null;
return;
}
if (SelectedRow.Uid == _rebindUid)
return;
_rebindUid = SelectedRow.Uid;
RebindLocalPort = port;
}
private async Task<Result<PortForwardTarget>> ResolvePortForwardTargetAsync() {
var ns = TargetPodNamespace ?? SelectedRow?.Namespace ?? "default";
if (!IsServiceSelection) {
var pod = TargetPodName;
if (pod is null) {
var message = ShowPodsTab
? "Select a pod in the details pane, then port-forward."
: "Port-forward requires a pod.";
return Result<PortForwardTarget>.BadRequest(null, message);
}
return Result<PortForwardTarget>.Ok(new PortForwardTarget(pod, ns, ForwardContainerPort));
}
var related = RelatedPods.Count > 0
? RelatedPods.ToList()
: [];
if (related.Count == 0 && SelectedRow is not null) {
var listed = await _workspace.RelatedPodsAsync(SelectedRow);
if (!listed.IsSuccess)
return new Result<PortForwardTarget>(null, false, listed.Messages, listed.StatusCode);
related = (listed.Value ?? []).ToList();
ReplaceRelatedPods(related);
}
return ServicePortForward.Resolve(SelectedRow!.Document, related, SelectedRelatedPod, ForwardContainerPort);
}
private void ApplyServiceForwardPorts(JsonObject? document) {
if (!IsServiceSelection)
return;
var port = ServicePortForward.DefaultPort(document);
if (port is null)
return;
ForwardContainerPort = port.Value;
ForwardLocalPort = port.Value;
}
private bool IsServiceSelection =>
SelectedDescriptor?.Kind == "Service"
|| ServicePortForward.IsService(SelectedRow?.Document);
private bool IsPodSelection =>
SelectedDescriptor?.Kind == "Pod"
|| SelectedNavItem?.Id is ResourceCatalog.DaprSidecarsId or ResourceCatalog.DaprControlPlaneId
@ -1306,10 +1834,16 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
OnPropertyChanged(nameof(CanLogs));
OnPropertyChanged(nameof(CanExec));
OnPropertyChanged(nameof(CanPortForward));
OnPropertyChanged(nameof(CanStopPortForward));
OnPropertyChanged(nameof(CanCordon));
OnPropertyChanged(nameof(CanDrain));
OnPropertyChanged(nameof(CanTrigger));
OnPropertyChanged(nameof(CanApply));
OnPropertyChanged(nameof(CanCreateResource));
OnPropertyChanged(nameof(CanReloadYaml));
OnPropertyChanged(nameof(CanApplyYaml));
OnPropertyChanged(nameof(CanEditData));
OnPropertyChanged(nameof(HasFooterActions));
OnPropertyChanged(nameof(CanBrowseFiles));
BrowseFilesCommand.NotifyCanExecuteChanged();
}
@ -1327,6 +1861,12 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
OnPropertyChanged(nameof(CanLogs));
OnPropertyChanged(nameof(CanExec));
OnPropertyChanged(nameof(CanPortForward));
OnPropertyChanged(nameof(CanStopPortForward));
OnPropertyChanged(nameof(CanCreateResource));
OnPropertyChanged(nameof(CanReloadYaml));
OnPropertyChanged(nameof(CanApplyYaml));
OnPropertyChanged(nameof(CanEditData));
OnPropertyChanged(nameof(HasFooterActions));
OnPropertyChanged(nameof(CanBrowseFiles));
BrowseFilesCommand.NotifyCanExecuteChanged();
}

View File

@ -131,14 +131,31 @@ public partial class NavGroupViewModel : ObservableObject {
}
public sealed class PortForwardItemViewModel {
public required PortForwardHandle Handle { get; init; }
public required PortForwardHandle Handle { get; set; }
public string Cluster { get; init; } = "";
public string Display =>
string.IsNullOrEmpty(Cluster)
? $"{Handle.Namespace}/{Handle.PodName} {Handle.LocalPort}->{Handle.ContainerPort}"
: $"{Cluster} {Handle.Namespace}/{Handle.PodName} {Handle.LocalPort}->{Handle.ContainerPort}";
public required string Uid { get; set; }
public string Kind { get; init; } = "Pod";
public string ResourceName { get; init; } = "";
public Dictionary<string, string>? MatchLabels { get; init; }
public PersistedPortForward ToPersisted() =>
new() {
Context = Cluster,
Kind = Kind,
Name = string.IsNullOrEmpty(ResourceName) ? Handle.PodName : ResourceName,
Namespace = Handle.Namespace,
PodName = Handle.PodName,
LocalPort = Handle.LocalPort,
RemotePort = Handle.RequestedPort,
MatchLabels = MatchLabels is null
? null
: new Dictionary<string, string>(MatchLabels, StringComparer.Ordinal)
};
}
public partial class DataEntryViewModel : ObservableObject {
@ -300,6 +317,9 @@ public partial class MainViewModel : ObservableObject, IDisposable {
_pages[item.Name] = page;
ActivatePage(page);
var restored = await page.RestorePortForwardsAsync();
if (restored.Total > 0)
Status = restored.Format();
}
[RelayCommand]
@ -344,6 +364,7 @@ public partial class MainViewModel : ObservableObject, IDisposable {
return;
var preferred = _configuration.Current.ActiveContext;
PortForwardRestoreSummary? restored = null;
foreach (var name in names) {
var item = Catalog.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.Ordinal));
if (item is null)
@ -362,6 +383,7 @@ public partial class MainViewModel : ObservableObject, IDisposable {
page.PausePolling();
_pages[item.Name] = page;
restored = MergeRestore(restored, await page.RestorePortForwardsAsync());
}
var active = _pages.TryGetValue(preferred ?? "", out var preferredPage)
@ -371,6 +393,20 @@ public partial class MainViewModel : ObservableObject, IDisposable {
ActivatePage(active);
else
SyncCatalogFlags();
if (restored is not null && restored.Total > 0)
Status = restored.Format();
}
private static PortForwardRestoreSummary MergeRestore(
PortForwardRestoreSummary? current,
PortForwardRestoreSummary incoming) {
if (current is null)
return incoming;
return new PortForwardRestoreSummary(
current.Restored + incoming.Restored,
current.Failures.Concat(incoming.Failures).ToList());
}
private ClusterPageViewModel CreatePage(KubeContextInfo context) {