diff --git a/CHANGELOG.md b/CHANGELOG.md
index be1493f..1f8392b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index f44002c..69832f3 100644
--- a/README.md
+++ b/README.md
@@ -3,30 +3,79 @@



+
+
+
-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:
+
+[
](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)
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index b8c2d00..ffc8ae1 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -3,7 +3,7 @@
latest
enable
enable
- 0.1.1
+ 0.2.0
MaksIT.ClusterConsole
MaksIT.ClusterConsole
diff --git a/src/MaksIT.ClusterConsole.Client/ClusterModels.cs b/src/MaksIT.ClusterConsole.Client/ClusterModels.cs
index 04d0eb1..1780ec2 100644
--- a/src/MaksIT.ClusterConsole.Client/ClusterModels.cs
+++ b/src/MaksIT.ClusterConsole.Client/ClusterModels.cs
@@ -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();
diff --git a/src/MaksIT.ClusterConsole.Client/ClusterSession.cs b/src/MaksIT.ClusterConsole.Client/ClusterSession.cs
index 278f6b6..7fd0c36 100644
--- a/src/MaksIT.ClusterConsole.Client/ClusterSession.cs
+++ b/src/MaksIT.ClusterConsole.Client/ClusterSession.cs
@@ -371,32 +371,28 @@ public sealed class ClusterSession : IClusterSession {
string @namespace,
int containerPort,
int localPort,
+ int requestedPort = 0,
+ Func>>? 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.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.Ok(handle);
}
catch (Exception ex) {
return KubernetesResult.Map(ex);
@@ -1024,31 +1020,149 @@ public sealed class ClusterSession : IClusterSession {
private static string? Label(IDictionary? 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 BindLoopback(int port) {
+ SocketException? last = null;
+ var listeners = new List(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>>? 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>>? 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) {
}
}
}
diff --git a/src/MaksIT.ClusterConsole.Client/IClusterSession.cs b/src/MaksIT.ClusterConsole.Client/IClusterSession.cs
index 653d53d..e68b365 100644
--- a/src/MaksIT.ClusterConsole.Client/IClusterSession.cs
+++ b/src/MaksIT.ClusterConsole.Client/IClusterSession.cs
@@ -67,6 +67,8 @@ public interface IClusterSession : IDisposable {
string @namespace,
int containerPort,
int localPort,
+ int requestedPort = 0,
+ Func>>? resolveTarget = null,
CancellationToken cancellationToken = default);
Task>> ListCustomResourceDefinitionsAsync(
diff --git a/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs b/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs
index 995edb1..1cf1db2 100644
--- a/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs
@@ -143,7 +143,7 @@ public sealed partial class ClusterWorkspace {
return new Result>(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>.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().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),
diff --git a/src/MaksIT.ClusterConsole.Shared/Configuration.cs b/src/MaksIT.ClusterConsole.Shared/Configuration.cs
index 952991d..fdee2e7 100644
--- a/src/MaksIT.ClusterConsole.Shared/Configuration.cs
+++ b/src/MaksIT.ClusterConsole.Shared/Configuration.cs
@@ -21,6 +21,8 @@ public sealed class Configuration {
public LayoutSettings Layout { get; set; } = new();
+ public List PortForwards { get; set; } = [];
+
public void EnsureDefaults() {
OpenContexts ??= [];
NamespacesByContext ??= new Dictionary(StringComparer.Ordinal);
@@ -29,6 +31,7 @@ public sealed class Configuration {
Layout.ColumnWidths ??= new Dictionary>(StringComparer.Ordinal);
Layout.ColumnFilters ??= new Dictionary>(StringComparer.Ordinal);
Layout.SearchByResource ??= new Dictionary(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 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? MatchLabels { get; set; }
}
diff --git a/src/MaksIT.ClusterConsole.Shared/PortForwardRow.cs b/src/MaksIT.ClusterConsole.Shared/PortForwardRow.cs
new file mode 100644
index 0000000..b24b2a4
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Shared/PortForwardRow.cs
@@ -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 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 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(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);
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs b/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs
index ac3c51c..e5ec162 100644
--- a/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs
+++ b/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs
@@ -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,
diff --git a/src/MaksIT.ClusterConsole.Shared/ResourceOwnership.cs b/src/MaksIT.ClusterConsole.Shared/ResourceOwnership.cs
new file mode 100644
index 0000000..f33c3ad
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Shared/ResourceOwnership.cs
@@ -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().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;
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs b/src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs
new file mode 100644
index 0000000..0f4c9f5
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs
@@ -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(), "Service", StringComparison.OrdinalIgnoreCase);
+
+ public static int? DefaultPort(JsonObject? service) {
+ var first = (service?["spec"]?["ports"] as JsonArray)?.OfType().FirstOrDefault();
+ return first is not null && TryNumber(first["port"], out var port) ? port : null;
+ }
+
+ private static readonly HashSet 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 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 pods,
+ string? preferredName,
+ IReadOnlyDictionary? 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? 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 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 MapPort(JsonObject service, JsonObject? pod, int remotePort) {
+ var ports = service["spec"]?["ports"] as JsonArray;
+ var match = ports?.OfType().FirstOrDefault(p =>
+ TryNumber(p["port"], out var port) && port == remotePort);
+ if (match is null)
+ return Result.Ok(remotePort);
+
+ var target = match["targetPort"];
+ if (target is null)
+ return Result.Ok(remotePort);
+
+ if (TryNumber(target, out var number))
+ return Result.Ok(number);
+
+ var name = JsonPath.Text(target);
+ if (string.IsNullOrWhiteSpace(name))
+ return Result.Ok(remotePort);
+
+ var containerPort = FindNamedContainerPort(pod, name);
+ if (containerPort is null)
+ return Result.BadRequest(0, $"service targetPort '{name}' was not found on the selected pod");
+
+ return Result.Ok(containerPort.Value);
+ }
+
+ public static Result Resolve(
+ JsonObject service,
+ IReadOnlyList pods,
+ ResourceRow? preferred,
+ int remotePort) {
+ var selector = ResourceOwnership.SelectorLabels(service);
+ if (selector is null || selector.Count == 0)
+ return Result.BadRequest(null, "Cannot port-forward a Service without a selector.");
+
+ var pod = PickForwardPod(pods, preferred, service, remotePort);
+ if (pod is null)
+ return Result.NotFound(null, "No pods match this Service selector.");
+
+ var mapped = MapPort(service, pod.Document, remotePort);
+ if (!mapped.IsSuccess)
+ return new Result(null, false, mapped.Messages, mapped.StatusCode);
+
+ var ns = pod.Namespace ?? JsonPath.Namespace(service) ?? "default";
+ return Result.Ok(new PortForwardTarget(pod.Name, ns, mapped.Value, remotePort));
+ }
+
+ private static ResourceRow? PickForwardPod(
+ IReadOnlyList 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 OrderedPods(IReadOnlyList 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? ToLabels(JsonObject? labels, bool stripVolatile) {
+ if (labels is null || labels.Count == 0)
+ return null;
+
+ var result = new Dictionary(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()) {
+ if (container["ports"] is not JsonArray ports)
+ continue;
+
+ foreach (var port in ports.OfType()) {
+ 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(out value) && value > 0)
+ return true;
+ if (json.TryGetValue(out var longer) && longer > 0 && longer <= 65535) {
+ value = (int)longer;
+ return true;
+ }
+
+ return json.TryGetValue(out var text)
+ && int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)
+ && value > 0;
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.Shared/appsettings.json b/src/MaksIT.ClusterConsole.Shared/appsettings.json
index d097201..018d752 100644
--- a/src/MaksIT.ClusterConsole.Shared/appsettings.json
+++ b/src/MaksIT.ClusterConsole.Shared/appsettings.json
@@ -22,6 +22,7 @@
"ColumnWidths": {},
"ColumnFilters": {},
"SearchByResource": {}
- }
+ },
+ "PortForwards": []
}
}
diff --git a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs
index 0ab9a59..641b4a2 100644
--- a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs
+++ b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs
@@ -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 { ["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();
diff --git a/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs b/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs
index 5ac1e23..d11cd78 100644
--- a/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs
+++ b/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs
@@ -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();
diff --git a/src/MaksIT.ClusterConsole.Tests/ServicePortForwardTests.cs b/src/MaksIT.ClusterConsole.Tests/ServicePortForwardTests.cs
new file mode 100644
index 0000000..151d381
--- /dev/null
+++ b/src/MaksIT.ClusterConsole.Tests/ServicePortForwardTests.cs
@@ -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());
+ }
+
+ [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 { ["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;
+ }
+}
diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml
index a8a3ae3..f3a9381 100644
--- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml
+++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml
@@ -14,19 +14,7 @@
MinHeight="640">
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -404,8 +392,12 @@
FontWeight="SemiBold"
Classes.warning="{Binding ActivePage.HasLimitOvercommit}" />
-
-
+
+
-
-
-
+
+
+
@@ -621,7 +613,7 @@
-
+
@@ -700,7 +692,7 @@
AcceptsReturn="False" />
-
+
@@ -729,20 +721,44 @@
-
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
-
+
+
+
+
+
+
+
diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs
index 3be1d8a..7d96262 100644
--- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs
+++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs
@@ -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) {
diff --git a/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs b/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs
index a0a399c..578f719 100644
--- a/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs
+++ b/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs
@@ -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 _cpuHistory = [];
private readonly List _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? 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 RestorePortForwardsAsync() {
+ var saved = _configuration.Current.PortForwardsFor(Name);
+ if (saved.Count == 0)
+ return new PortForwardRestoreSummary(0, []);
+
+ var restored = 0;
+ var failures = new List();
+ 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 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>> ResolveEndpoint(PersistedPortForward saved) =>
+ async _ => {
+ var resolved = await ResolveOwnedTargetAsync(saved);
+ if (!resolved.IsSuccess || resolved.Value is null)
+ return new Result(null, false, resolved.Messages, resolved.StatusCode);
+
+ saved.PodName = resolved.Value.PodName;
+ saved.Namespace = resolved.Value.Namespace;
+ return Result.Ok(new PortForwardEndpoint(
+ resolved.Value.PodName,
+ resolved.Value.Namespace,
+ resolved.Value.ContainerPort));
+ };
+
+ private async Task> 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(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(null, false, listed.Messages, listed.StatusCode);
+
+ var picked = ServicePortForward.PickRunning(listed.Value ?? [], saved.PodName, saved.MatchLabels);
+ if (picked is null)
+ return Result.NotFound(null, "No running pods match this port-forward.");
+
+ return Result.Ok(
+ new PortForwardTarget(picked.Name, picked.Namespace ?? ns, saved.RemotePort, saved.RemotePort));
+ }
+
+ return await ResolvePersistedPodAsync(saved, ns);
+ }
+
+ private async Task> 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(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(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> 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.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(null, false, got.Messages, got.StatusCode);
+
+ return Result.NotFound(null, "No running pods match this port-forward.");
+ }
+
+ var listed = await _workspace.Session.ListAsync(pods.ToRef(), ns);
+ if (!listed.IsSuccess)
+ return new Result(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.NotFound(null, "No running pods match this port-forward.");
+
+ RememberLabels(saved, picked.Document);
+ return Result.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>> 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> 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.BadRequest(null, message);
+ }
+
+ return Result.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(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();
}
diff --git a/src/MaksIT.ClusterConsole.UI/ViewModels/MainViewModel.cs b/src/MaksIT.ClusterConsole.UI/ViewModels/MainViewModel.cs
index 0b7ad1a..d400a50 100644
--- a/src/MaksIT.ClusterConsole.UI/ViewModels/MainViewModel.cs
+++ b/src/MaksIT.ClusterConsole.UI/ViewModels/MainViewModel.cs
@@ -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? 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(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) {