mirror of
https://github.com/MAKS-IT-COM/maksit-cluster-console.git
synced 2026-09-30 00:38:10 +02:00
(feature): persist kubectl-compatible service port-forwards
This commit is contained in:
parent
bf63cf1730
commit
1ed5962d21
25
CHANGELOG.md
25
CHANGELOG.md
@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.1.1] - 2026-08-19
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
109
README.md
109
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).
|
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
|
## Requirements
|
||||||
|
|
||||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
|
- [.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
|
- Windows or Linux
|
||||||
|
- Optional: a local [Ollama](https://ollama.com) daemon for Chat
|
||||||
|
|
||||||
## Build
|
## Getting started
|
||||||
|
|
||||||
From `src/` so `global.json` applies:
|
From `src/` so `global.json` applies:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd src
|
cd src
|
||||||
dotnet build MaksIT.ClusterConsole.slnx
|
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
|
## Configuration
|
||||||
dotnet run --project MaksIT.ClusterConsole.UI
|
|
||||||
|
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
|
## Tests
|
||||||
@ -35,23 +84,13 @@ dotnet run --project MaksIT.ClusterConsole.UI
|
|||||||
utils\Invoke-TestEngine.bat
|
utils\Invoke-TestEngine.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
Or from `src/`:
|
From `src/`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet test MaksIT.ClusterConsole.Tests
|
dotnet test MaksIT.ClusterConsole.Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local Ollama chat
|
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**).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Release
|
## 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`.
|
2. Tag `v{version}` on `main`.
|
||||||
3. Run `utils\Invoke-ReleasePackage.bat`.
|
3. Run `utils\Invoke-ReleasePackage.bat`.
|
||||||
|
|
||||||
## What it is
|
## Solution layout
|
||||||
|
|
||||||
- Catalog of kubeconfig contexts (`*` marks kubectl `current-context`)
|
```text
|
||||||
- Connections editor and wizard to add/upsert a context (token, cert, k3s data, or basic auth) and switch kubectl `current-context`
|
utils/ # RepoUtils test and release engines
|
||||||
- Navigator: icon categories with collapsible sub-items (Cluster, Nodes, Applications, Workloads, Config, Network, Storage, Namespaces, Events, Helm, Dapr, Access Control, Custom Resources)
|
src/
|
||||||
- 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).
|
MaksIT.ClusterConsole.slnx
|
||||||
- Workload scale/restart, node cordon/drain, CronJob trigger
|
MaksIT.ClusterConsole.Client/ # Kubernetes API client
|
||||||
- Force delete (grace period 0 + strip finalizers); force-delete namespace from the Namespaces view, including orphaned sandboxes whose Namespace object is already gone
|
MaksIT.ClusterConsole.Shared/ # catalog, workspace, configuration
|
||||||
- Pod logs (follow), exec, port-forward dock
|
MaksIT.ClusterConsole.UI/ # Avalonia desktop host
|
||||||
- Helm releases from secrets (`owner=helm`)
|
MaksIT.ClusterConsole.Tests/
|
||||||
- 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
|
|
||||||
|
|
||||||
## 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)
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Version>0.1.1</Version>
|
<Version>0.2.0</Version>
|
||||||
<Product>MaksIT.ClusterConsole</Product>
|
<Product>MaksIT.ClusterConsole</Product>
|
||||||
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
|
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@ -100,27 +100,45 @@ public sealed record HelmReleaseInfo(
|
|||||||
string AppVersion,
|
string AppVersion,
|
||||||
DateTimeOffset? Updated);
|
DateTimeOffset? Updated);
|
||||||
|
|
||||||
|
public sealed record PortForwardEndpoint(string PodName, string Namespace, int ContainerPort);
|
||||||
|
|
||||||
public sealed class PortForwardHandle : IDisposable {
|
public sealed class PortForwardHandle : IDisposable {
|
||||||
private readonly IDisposable _inner;
|
private readonly IDisposable _inner;
|
||||||
private readonly Action? _onDispose;
|
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;
|
PodName = podName;
|
||||||
Namespace = @namespace;
|
Namespace = @namespace;
|
||||||
ContainerPort = containerPort;
|
ContainerPort = containerPort;
|
||||||
LocalPort = localPort;
|
LocalPort = localPort;
|
||||||
|
RequestedPort = requestedPort > 0 ? requestedPort : containerPort;
|
||||||
_inner = inner;
|
_inner = inner;
|
||||||
_onDispose = onDispose;
|
_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 int LocalPort { get; }
|
||||||
|
|
||||||
|
public void Retarget(string podName, string @namespace, int containerPort) {
|
||||||
|
PodName = podName;
|
||||||
|
Namespace = @namespace;
|
||||||
|
ContainerPort = containerPort;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
_onDispose?.Invoke();
|
_onDispose?.Invoke();
|
||||||
_inner.Dispose();
|
_inner.Dispose();
|
||||||
|
|||||||
@ -371,32 +371,28 @@ public sealed class ClusterSession : IClusterSession {
|
|||||||
string @namespace,
|
string @namespace,
|
||||||
int containerPort,
|
int containerPort,
|
||||||
int localPort,
|
int localPort,
|
||||||
|
int requestedPort = 0,
|
||||||
|
Func<CancellationToken, Task<Result<PortForwardEndpoint>>>? resolveTarget = null,
|
||||||
CancellationToken cancellationToken = default) {
|
CancellationToken cancellationToken = default) {
|
||||||
try {
|
try {
|
||||||
var webSocket = await _client.WebSocketNamespacedPodPortForwardAsync(
|
var listeners = BindLoopback(localPort);
|
||||||
podName,
|
|
||||||
@namespace,
|
|
||||||
[containerPort],
|
|
||||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var demux = new StreamDemuxer(webSocket);
|
|
||||||
demux.Start();
|
|
||||||
var listener = new TcpListener(IPAddress.Loopback, localPort);
|
|
||||||
listener.Start();
|
|
||||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
_ = AcceptAsync(listener, demux, containerPort, cts.Token);
|
var handle = new PortForwardHandle(
|
||||||
|
|
||||||
return Result<PortForwardHandle>.Ok(new PortForwardHandle(
|
|
||||||
podName,
|
podName,
|
||||||
@namespace,
|
@namespace,
|
||||||
containerPort,
|
containerPort,
|
||||||
localPort,
|
localPort,
|
||||||
demux,
|
cts,
|
||||||
() => {
|
() => {
|
||||||
cts.Cancel();
|
cts.Cancel();
|
||||||
listener.Stop();
|
foreach (var listener in listeners)
|
||||||
webSocket.Dispose();
|
listener.Stop();
|
||||||
}));
|
},
|
||||||
|
requestedPort);
|
||||||
|
foreach (var listener in listeners)
|
||||||
|
_ = AcceptAsync(listener, handle, resolveTarget, cts.Token);
|
||||||
|
|
||||||
|
return Result<PortForwardHandle>.Ok(handle);
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
return KubernetesResult.Map<PortForwardHandle>(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) =>
|
private static string? Label(IDictionary<string, string>? labels, string key) =>
|
||||||
labels is not null && labels.TryGetValue(key, out var value) ? value : null;
|
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 {
|
try {
|
||||||
while (!cancellationToken.IsCancellationRequested) {
|
while (!cancellationToken.IsCancellationRequested) {
|
||||||
var client = await listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false);
|
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 (OperationCanceledException) {
|
||||||
}
|
}
|
||||||
catch (ObjectDisposedException) {
|
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 {
|
try {
|
||||||
var remote = demux.GetStream(ChannelIndex.StdOut, ChannelIndex.StdIn);
|
var podName = handle.PodName;
|
||||||
var local = tcp.GetStream();
|
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(
|
await Task.WhenAny(
|
||||||
local.CopyToAsync(remote, cancellationToken),
|
Task.Run(() => CopySocketToStream(socket, stream, copyCts.Token), copyCts.Token),
|
||||||
remote.CopyToAsync(local, cancellationToken)).ConfigureAwait(false);
|
Task.Run(() => CopyStreamToSocket(stream, socket, copyCts.Token), copyCts.Token)).ConfigureAwait(false);
|
||||||
|
copyCts.Cancel();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) {
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
tcp.Dispose();
|
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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,6 +67,8 @@ public interface IClusterSession : IDisposable {
|
|||||||
string @namespace,
|
string @namespace,
|
||||||
int containerPort,
|
int containerPort,
|
||||||
int localPort,
|
int localPort,
|
||||||
|
int requestedPort = 0,
|
||||||
|
Func<CancellationToken, Task<Result<PortForwardEndpoint>>>? resolveTarget = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<Result<IReadOnlyList<JsonObject>>> ListCustomResourceDefinitionsAsync(
|
Task<Result<IReadOnlyList<JsonObject>>> ListCustomResourceDefinitionsAsync(
|
||||||
|
|||||||
@ -143,7 +143,7 @@ public sealed partial class ClusterWorkspace {
|
|||||||
return new Result<IReadOnlyList<ResourceRow>>(null, false, listed.Messages, listed.StatusCode);
|
return new Result<IReadOnlyList<ResourceRow>>(null, false, listed.Messages, listed.StatusCode);
|
||||||
|
|
||||||
var related = (listed.Value ?? [])
|
var related = (listed.Value ?? [])
|
||||||
.Where(p => Owns(p, owner.Document))
|
.Where(p => ResourceOwnership.Owns(p, owner.Document))
|
||||||
.Select(p => ResourceRow.From(p, pods))
|
.Select(p => ResourceRow.From(p, pods))
|
||||||
.ToList();
|
.ToList();
|
||||||
return Result<IReadOnlyList<ResourceRow>>.Ok(related);
|
return Result<IReadOnlyList<ResourceRow>>.Ok(related);
|
||||||
@ -367,34 +367,6 @@ public sealed partial class ClusterWorkspace {
|
|||||||
return ApplicationManifest.WorkloadNames(row.Document).Contains(name, StringComparer.Ordinal);
|
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) {
|
private static bool Matches(ResourceRow row, string? filter) {
|
||||||
if (string.IsNullOrWhiteSpace(filter))
|
if (string.IsNullOrWhiteSpace(filter))
|
||||||
return true;
|
return true;
|
||||||
@ -412,7 +384,11 @@ public sealed partial class ClusterWorkspace {
|
|||||||
"Applications",
|
"Applications",
|
||||||
ResourceCatalog.Applications,
|
ResourceCatalog.Applications,
|
||||||
ResourceCatalog.ApplicationsDescriptor),
|
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.HelmChartsId, "Charts", ResourceCatalog.Helm),
|
||||||
Special(ResourceCatalog.HelmReleasesId, "Releases", ResourceCatalog.Helm),
|
Special(ResourceCatalog.HelmReleasesId, "Releases", ResourceCatalog.Helm),
|
||||||
Special(ResourceCatalog.DaprSidecarsId, "Sidecars", ResourceCatalog.Dapr),
|
Special(ResourceCatalog.DaprSidecarsId, "Sidecars", ResourceCatalog.Dapr),
|
||||||
|
|||||||
@ -21,6 +21,8 @@ public sealed class Configuration {
|
|||||||
|
|
||||||
public LayoutSettings Layout { get; set; } = new();
|
public LayoutSettings Layout { get; set; } = new();
|
||||||
|
|
||||||
|
public List<PersistedPortForward> PortForwards { get; set; } = [];
|
||||||
|
|
||||||
public void EnsureDefaults() {
|
public void EnsureDefaults() {
|
||||||
OpenContexts ??= [];
|
OpenContexts ??= [];
|
||||||
NamespacesByContext ??= new Dictionary<string, string>(StringComparer.Ordinal);
|
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.ColumnWidths ??= new Dictionary<string, Dictionary<string, double>>(StringComparer.Ordinal);
|
||||||
Layout.ColumnFilters ??= new Dictionary<string, Dictionary<string, SavedColumnFilter>>(StringComparer.Ordinal);
|
Layout.ColumnFilters ??= new Dictionary<string, Dictionary<string, SavedColumnFilter>>(StringComparer.Ordinal);
|
||||||
Layout.SearchByResource ??= new Dictionary<string, string>(StringComparer.Ordinal);
|
Layout.SearchByResource ??= new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
PortForwards ??= [];
|
||||||
if (string.IsNullOrWhiteSpace(OllamaEndpoint))
|
if (string.IsNullOrWhiteSpace(OllamaEndpoint))
|
||||||
OllamaEndpoint = ClusterChatService.DefaultEndpoint;
|
OllamaEndpoint = ClusterChatService.DefaultEndpoint;
|
||||||
if (string.IsNullOrWhiteSpace(OllamaModel))
|
if (string.IsNullOrWhiteSpace(OllamaModel))
|
||||||
@ -64,4 +67,41 @@ public sealed class Configuration {
|
|||||||
SelectedNamespace = ns;
|
SelectedNamespace = ns;
|
||||||
ActiveContext = contextName;
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/MaksIT.ClusterConsole.Shared/PortForwardRow.cs
Normal file
104
src/MaksIT.ClusterConsole.Shared/PortForwardRow.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -66,10 +66,32 @@ public static class ResourceCatalog {
|
|||||||
new ResourceActions(CanScale: false, CanRestart: false, CanApply: false),
|
new ResourceActions(CanScale: false, CanRestart: false, CanApply: false),
|
||||||
["Overview", "YAML", "Events", "Pods", "Logs", "Terminal"]);
|
["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) =>
|
public static ResourceDescriptor? Find(string id) =>
|
||||||
id == ApplicationsId
|
id switch {
|
||||||
? ApplicationsDescriptor
|
ApplicationsId => ApplicationsDescriptor,
|
||||||
: BuiltIns.FirstOrDefault(d => d.Id == id);
|
PortForwardingId => PortForwardingDescriptor,
|
||||||
|
_ => BuiltIns.FirstOrDefault(d => d.Id == id)
|
||||||
|
};
|
||||||
|
|
||||||
public static ResourceDescriptor? FindByGvk(string? apiVersion, string? kind) {
|
public static ResourceDescriptor? FindByGvk(string? apiVersion, string? kind) {
|
||||||
if (string.IsNullOrWhiteSpace(kind))
|
if (string.IsNullOrWhiteSpace(kind))
|
||||||
@ -137,6 +159,7 @@ public static class ResourceCatalog {
|
|||||||
var yamlTabs = new[] { "Overview", "YAML", "Events" };
|
var yamlTabs = new[] { "Overview", "YAML", "Events" };
|
||||||
var podTabs = new[] { "Overview", "YAML", "Events", "Logs", "Terminal" };
|
var podTabs = new[] { "Overview", "YAML", "Events", "Logs", "Terminal" };
|
||||||
var workloadTabs = new[] { "Overview", "YAML", "Events", "Pods", "Logs", "Terminal" };
|
var workloadTabs = new[] { "Overview", "YAML", "Events", "Pods", "Logs", "Terminal" };
|
||||||
|
var serviceTabs = new[] { "Overview", "YAML", "Events", "Pods" };
|
||||||
var crud = new ResourceActions();
|
var crud = new ResourceActions();
|
||||||
var scale = new ResourceActions(CanScale: true, CanRestart: true);
|
var scale = new ResourceActions(CanScale: true, CanRestart: true);
|
||||||
var logs = new ResourceActions(CanLogs: true, CanExec: true, CanPortForward: 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("validatingwebhookconfigurations", "Validating Webhooks", Config, "admissionregistration.k8s.io", "v1", "validatingwebhookconfigurations", "ValidatingWebhookConfiguration", false, named, crud, yamlTabs),
|
||||||
D("services", "Services", Network, "", "v1", "services", "Service", true,
|
D("services", "Services", Network, "", "v1", "services", "Service", true,
|
||||||
[..std, new("Type", "spec.type"), new("Cluster IP", "spec.clusterIP"), new("External IP", "service.externalIP"), new("Ports", "spec.ports")],
|
[..std, new("Type", "spec.type"), new("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("endpoints", "Endpoints", Network, "", "v1", "endpoints", "Endpoints", true, std, crud, yamlTabs),
|
||||||
D("endpointslices", "Endpoint Slices", Network, "discovery.k8s.io", "v1", "endpointslices", "EndpointSlice", true, std, crud, yamlTabs),
|
D("endpointslices", "Endpoint Slices", Network, "discovery.k8s.io", "v1", "endpointslices", "EndpointSlice", true, std, crud, yamlTabs),
|
||||||
D("ingresses", "Ingresses", Network, "networking.k8s.io", "v1", "ingresses", "Ingress", true,
|
D("ingresses", "Ingresses", Network, "networking.k8s.io", "v1", "ingresses", "Ingress", true,
|
||||||
|
|||||||
46
src/MaksIT.ClusterConsole.Shared/ResourceOwnership.cs
Normal file
46
src/MaksIT.ClusterConsole.Shared/ResourceOwnership.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
231
src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs
Normal file
231
src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@
|
|||||||
"ColumnWidths": {},
|
"ColumnWidths": {},
|
||||||
"ColumnFilters": {},
|
"ColumnFilters": {},
|
||||||
"SearchByResource": {}
|
"SearchByResource": {}
|
||||||
}
|
},
|
||||||
|
"PortForwards": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]
|
[Fact]
|
||||||
public void Default_path_is_appsettings_beside_the_executable() {
|
public void Default_path_is_appsettings_beside_the_executable() {
|
||||||
var service = new ConfigurationFileService();
|
var service = new ConfigurationFileService();
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using MaksIT.ClusterConsole.Client;
|
||||||
using MaksIT.ClusterConsole.Shared;
|
using MaksIT.ClusterConsole.Shared;
|
||||||
|
|
||||||
|
|
||||||
@ -41,6 +42,68 @@ public class ResourceCatalogTests {
|
|||||||
Assert.True(sections.IndexOf(ResourceCatalog.Applications) < sections.IndexOf(ResourceCatalog.Workloads));
|
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]
|
[Fact]
|
||||||
public void Every_section_has_a_distinct_icon_path() {
|
public void Every_section_has_a_distinct_icon_path() {
|
||||||
var paths = ResourceCatalog.Sections.Select(NavigatorIcons.Path).ToList();
|
var paths = ResourceCatalog.Sections.Select(NavigatorIcons.Path).ToList();
|
||||||
|
|||||||
287
src/MaksIT.ClusterConsole.Tests/ServicePortForwardTests.cs
Normal file
287
src/MaksIT.ClusterConsole.Tests/ServicePortForwardTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,19 +14,7 @@
|
|||||||
MinHeight="640">
|
MinHeight="640">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Border DockPanel.Dock="Bottom" Padding="8,6" Background="#15171a">
|
<Border DockPanel.Dock="Bottom" Padding="8,6" Background="#15171a">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<TextBlock Text="{Binding Status}" Classes="hint" VerticalAlignment="Center" />
|
||||||
<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>
|
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid Name="ShellGrid" ColumnDefinitions="248,4,228,4,*">
|
<Grid Name="ShellGrid" ColumnDefinitions="248,4,228,4,*">
|
||||||
@ -404,8 +392,12 @@
|
|||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Classes.warning="{Binding ActivePage.HasLimitOvercommit}" />
|
Classes.warning="{Binding ActivePage.HasLimitOvercommit}" />
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
|
||||||
<Button Content="Apply selected" Command="{Binding ActivePage.ApplySelectedLimitCommand}" />
|
<Button Content="Apply selected"
|
||||||
<Button Content="Apply edits" Command="{Binding ActivePage.ApplyChangedLimitsCommand}" />
|
Command="{Binding ActivePage.ApplySelectedLimitCommand}"
|
||||||
|
IsVisible="{Binding ActivePage.HasSelectedLimit}" />
|
||||||
|
<Button Content="Apply edits"
|
||||||
|
Command="{Binding ActivePage.ApplyChangedLimitsCommand}"
|
||||||
|
IsVisible="{Binding ActivePage.HasDirtyLimits}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
<DataGrid Name="OverviewLimitsGrid"
|
<DataGrid Name="OverviewLimitsGrid"
|
||||||
@ -611,9 +603,9 @@
|
|||||||
<TabItem Header="YAML">
|
<TabItem Header="YAML">
|
||||||
<DockPanel>
|
<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">
|
||||||
<Button Content="New" Command="{Binding ActivePage.NewResourceCommand}" />
|
<Button Content="New" Command="{Binding ActivePage.NewResourceCommand}" IsVisible="{Binding ActivePage.CanCreateResource}" />
|
||||||
<Button Content="Reload" Command="{Binding ActivePage.ReloadYamlCommand}" />
|
<Button Content="Reload" Command="{Binding ActivePage.ReloadYamlCommand}" IsVisible="{Binding ActivePage.CanReloadYaml}" />
|
||||||
<Button Content="Apply YAML" Command="{Binding ActivePage.ApplyYamlCommand}" />
|
<Button Content="Apply YAML" Command="{Binding ActivePage.ApplyYamlCommand}" IsVisible="{Binding ActivePage.CanApplyYaml}" />
|
||||||
<TextBlock Text="Unsaved YAML" Classes="hint" IsVisible="{Binding ActivePage.IsDirty}" VerticalAlignment="Center" />
|
<TextBlock Text="Unsaved YAML" Classes="hint" IsVisible="{Binding ActivePage.IsDirty}" VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBox Text="{Binding ActivePage.YamlText}" AcceptsReturn="True" FontFamily="Consolas, monospace" TextWrapping="NoWrap" />
|
<TextBox Text="{Binding ActivePage.YamlText}" AcceptsReturn="True" FontFamily="Consolas, monospace" TextWrapping="NoWrap" />
|
||||||
@ -621,7 +613,7 @@
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem Header="Data" IsVisible="{Binding ActivePage.IsDataEditor}">
|
<TabItem Header="Data" IsVisible="{Binding ActivePage.IsDataEditor}">
|
||||||
<DockPanel>
|
<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="Add key" Command="{Binding ActivePage.AddDataEntryCommand}" />
|
||||||
<Button Content="Apply data" Command="{Binding ActivePage.ApplyDataCommand}" />
|
<Button Content="Apply data" Command="{Binding ActivePage.ApplyDataCommand}" />
|
||||||
<TextBlock Text="Decoded secret values are saved back as stringData." Classes="hint" VerticalAlignment="Center" />
|
<TextBlock Text="Decoded secret values are saved back as stringData." Classes="hint" VerticalAlignment="Center" />
|
||||||
@ -700,7 +692,7 @@
|
|||||||
AcceptsReturn="False" />
|
AcceptsReturn="False" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<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}" />
|
<Button Content="Clear" Command="{Binding ActivePage.ClearChatCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@ -729,20 +721,44 @@
|
|||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Grid>
|
</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="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" Command="{Binding ActivePage.ForceDeleteCommand}" IsVisible="{Binding ActivePage.CanForceDelete}" />
|
||||||
<Button Content="Force delete namespace" Command="{Binding ActivePage.DeleteNamespaceCommand}" IsVisible="{Binding ActivePage.CanDeleteNamespace}" />
|
<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}" />
|
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center" IsVisible="{Binding ActivePage.CanScale}">
|
||||||
<Button Content="Scale" Command="{Binding ActivePage.ScaleCommand}" 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="Restart" Command="{Binding ActivePage.RestartCommand}" IsVisible="{Binding ActivePage.CanRestart}" />
|
||||||
<Button Content="Cordon" Command="{Binding ActivePage.CordonCommand}" IsVisible="{Binding ActivePage.CanCordon}" />
|
<Button Content="Cordon" Command="{Binding ActivePage.CordonCommand}" IsVisible="{Binding ActivePage.CanCordon}" />
|
||||||
<Button Content="Uncordon" Command="{Binding ActivePage.UncordonCommand}" 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="Drain" Command="{Binding ActivePage.DrainCommand}" IsVisible="{Binding ActivePage.CanDrain}" />
|
||||||
<Button Content="Trigger" Command="{Binding ActivePage.TriggerCronCommand}" IsVisible="{Binding ActivePage.CanTrigger}" />
|
<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}" />
|
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center" IsVisible="{Binding ActivePage.CanPortForward}">
|
||||||
<NumericUpDown Value="{Binding ActivePage.ForwardContainerPort}" Minimum="1" Maximum="65535" Width="90" IsVisible="{Binding ActivePage.CanPortForward}" />
|
<TextBlock Text="Local" Classes="hint" VerticalAlignment="Center" ToolTip.Tip="Port on this machine" />
|
||||||
<Button Content="Port-forward" Command="{Binding ActivePage.StartPortForwardCommand}" IsVisible="{Binding ActivePage.CanPortForward}" />
|
<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>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -54,15 +54,15 @@ public partial class MainWindow : Window {
|
|||||||
private void OnResourceGridDoubleTapped(object? sender, TappedEventArgs e) {
|
private void OnResourceGridDoubleTapped(object? sender, TappedEventArgs e) {
|
||||||
if (e.Source is not Control { DataContext: ResourceRow })
|
if (e.Source is not Control { DataContext: ResourceRow })
|
||||||
return;
|
return;
|
||||||
if (DataContext is MainViewModel { ActivePage: { } page }
|
if (DataContext is not MainViewModel { ActivePage: { } page })
|
||||||
&& page.BrowseFilesCommand.CanExecute(null))
|
return;
|
||||||
page.BrowseFilesCommand.Execute(null);
|
if (page.IsPortForwardingView) {
|
||||||
}
|
page.OpenSelectedPortForwardCommand.Execute(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnStopPortForwardClick(object? sender, RoutedEventArgs e) {
|
if (page.BrowseFilesCommand.CanExecute(null))
|
||||||
if (sender is Control { DataContext: PortForwardItemViewModel item }
|
page.BrowseFilesCommand.Execute(null);
|
||||||
&& DataContext is MainViewModel { ActivePage: { } page })
|
|
||||||
page.StopPortForwardCommand.Execute(item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RebuildColumns(MainViewModel viewModel) {
|
private void RebuildColumns(MainViewModel viewModel) {
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using MaksIT.Results;
|
||||||
using MaksIT.ClusterConsole.Client;
|
using MaksIT.ClusterConsole.Client;
|
||||||
using MaksIT.ClusterConsole.Shared;
|
using MaksIT.ClusterConsole.Shared;
|
||||||
|
|
||||||
@ -24,6 +27,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
private string? _detailsUid;
|
private string? _detailsUid;
|
||||||
private bool _loadingDetails;
|
private bool _loadingDetails;
|
||||||
private bool _updatingPodContext;
|
private bool _updatingPodContext;
|
||||||
|
private string? _rebindUid;
|
||||||
private readonly List<double> _cpuHistory = [];
|
private readonly List<double> _cpuHistory = [];
|
||||||
private readonly List<double> _memoryHistory = [];
|
private readonly List<double> _memoryHistory = [];
|
||||||
private const int HistoryPoints = 60;
|
private const int HistoryPoints = 60;
|
||||||
@ -123,6 +127,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int forwardContainerPort = 80;
|
private int forwardContainerPort = 80;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int rebindLocalPort = 8080;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool followLogs;
|
private bool followLogs;
|
||||||
|
|
||||||
@ -183,13 +190,13 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
private void ShowNodesOverview() => OverviewPerNode = true;
|
private void ShowNodesOverview() => OverviewPerNode = true;
|
||||||
|
|
||||||
public bool CanScale =>
|
public bool CanScale =>
|
||||||
SelectedResourceRef()?.Actions.CanScale == true;
|
HasSelectedRow && SelectedResourceRef()?.Actions.CanScale == true;
|
||||||
|
|
||||||
public bool CanRestart =>
|
public bool CanRestart =>
|
||||||
SelectedResourceRef()?.Actions.CanRestart == true;
|
HasSelectedRow && SelectedResourceRef()?.Actions.CanRestart == true;
|
||||||
|
|
||||||
public bool CanDelete =>
|
public bool CanDelete =>
|
||||||
SelectedResourceRef()?.Actions.CanDelete == true && SelectedRow is not null;
|
HasSelectedRow && SelectedResourceRef()?.Actions.CanDelete == true;
|
||||||
|
|
||||||
public bool CanForceDelete =>
|
public bool CanForceDelete =>
|
||||||
CanDelete && SelectedDescriptor?.Kind != "Namespace";
|
CanDelete && SelectedDescriptor?.Kind != "Namespace";
|
||||||
@ -197,14 +204,15 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
public bool CanDeleteNamespace {
|
public bool CanDeleteNamespace {
|
||||||
get {
|
get {
|
||||||
var name = TargetNamespaceName;
|
var name = TargetNamespaceName;
|
||||||
return SelectedDescriptor?.Kind == "Namespace"
|
return HasSelectedRow
|
||||||
|
&& SelectedDescriptor?.Kind == "Namespace"
|
||||||
&& !string.IsNullOrEmpty(name)
|
&& !string.IsNullOrEmpty(name)
|
||||||
&& !IsProtectedNamespace(name);
|
&& !IsProtectedNamespace(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanBrowseFiles =>
|
public bool CanBrowseFiles =>
|
||||||
SelectedRow is not null
|
HasSelectedRow
|
||||||
&& SelectedDescriptor?.Id is "persistentvolumes" or "persistentvolumeclaims";
|
&& SelectedDescriptor?.Id is "persistentvolumes" or "persistentvolumeclaims";
|
||||||
|
|
||||||
public event Action<VolumeFilesViewModel>? VolumeFilesRequested;
|
public event Action<VolumeFilesViewModel>? VolumeFilesRequested;
|
||||||
@ -213,7 +221,55 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
public bool CanExec => HasDetailTab("Terminal");
|
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");
|
public bool ShowEventsTab => HasDetailTab("Events");
|
||||||
|
|
||||||
@ -231,15 +287,6 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
public bool HasSelectedRow => SelectedRow is not null;
|
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 {
|
public string DetailsTitle {
|
||||||
get {
|
get {
|
||||||
if (SelectedRow is null)
|
if (SelectedRow is null)
|
||||||
@ -278,6 +325,10 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
public bool HasContainerLimits => LimitRows.Count > 0;
|
public bool HasContainerLimits => LimitRows.Count > 0;
|
||||||
|
|
||||||
|
public bool HasSelectedLimit => SelectedLimitRow is not null;
|
||||||
|
|
||||||
|
public bool HasDirtyLimits => LimitRows.Any(row => row.IsDirty);
|
||||||
|
|
||||||
public bool HasLimitOvercommit =>
|
public bool HasLimitOvercommit =>
|
||||||
CpuSlice.LimitsExceedCapacity || MemorySlice.LimitsExceedCapacity;
|
CpuSlice.LimitsExceedCapacity || MemorySlice.LimitsExceedCapacity;
|
||||||
|
|
||||||
@ -297,10 +348,14 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
private ResourceDescriptor? SelectedResourceRef() {
|
private ResourceDescriptor? SelectedResourceRef() {
|
||||||
var match = _workspace.FindByGvk(SelectedDocumentApiVersion, SelectedDocumentKind);
|
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;
|
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 descriptor;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -342,6 +397,8 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
PausePolling();
|
PausePolling();
|
||||||
_logsCts?.Cancel();
|
_logsCts?.Cancel();
|
||||||
_chatCts?.Cancel();
|
_chatCts?.Cancel();
|
||||||
|
foreach (var row in LimitRows)
|
||||||
|
row.PropertyChanged -= OnLimitRowPropertyChanged;
|
||||||
foreach (var pf in PortForwards.ToList())
|
foreach (var pf in PortForwards.ToList())
|
||||||
pf.Handle.Dispose();
|
pf.Handle.Dispose();
|
||||||
PortForwards.Clear();
|
PortForwards.Clear();
|
||||||
@ -439,11 +496,17 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
partial void OnSelectedRowChanged(ResourceRow? value) {
|
partial void OnSelectedRowChanged(ResourceRow? value) {
|
||||||
NotifyActionFlags();
|
NotifyActionFlags();
|
||||||
NotifyDetailsUi();
|
NotifyDetailsUi();
|
||||||
|
SyncRebindLocalPort();
|
||||||
if (value?.Uid == _detailsUid)
|
if (value?.Uid == _detailsUid)
|
||||||
return;
|
return;
|
||||||
_ = LoadDetailsAsync();
|
_ = LoadDetailsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedLimitRowChanged(LimitRowViewModel? value) {
|
||||||
|
OnPropertyChanged(nameof(HasSelectedLimit));
|
||||||
|
OnPropertyChanged(nameof(CanApplyLimits));
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedRelatedPodChanged(ResourceRow? value) {
|
partial void OnSelectedRelatedPodChanged(ResourceRow? value) {
|
||||||
if (_updatingPodContext)
|
if (_updatingPodContext)
|
||||||
return;
|
return;
|
||||||
@ -462,6 +525,11 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
_ = LoadLogsAsync();
|
_ = LoadLogsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnIsDirtyChanged(bool value) {
|
||||||
|
OnPropertyChanged(nameof(CanApplyYaml));
|
||||||
|
OnPropertyChanged(nameof(CanEditData));
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnYamlTextChanged(string value) {
|
partial void OnYamlTextChanged(string value) {
|
||||||
if (!_loadingDetails)
|
if (!_loadingDetails)
|
||||||
IsDirty = true;
|
IsDirty = true;
|
||||||
@ -499,9 +567,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (SelectedNavItem.Id == ResourceCatalog.PortForwardingId) {
|
if (SelectedNavItem.Id == ResourceCatalog.PortForwardingId) {
|
||||||
_listedRows.Clear();
|
ShowPortForwardRows();
|
||||||
Rows.Clear();
|
|
||||||
_setStatus($"{PortForwards.Count} active port-forward(s).");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -748,23 +814,50 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
if (_workspace.Session is null || SelectedRow is null)
|
if (_workspace.Session is null || SelectedRow is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var pod = TargetPodName;
|
var target = await ResolvePortForwardTargetAsync();
|
||||||
var ns = TargetPodNamespace ?? "default";
|
if (!target.IsSuccess || target.Value is null) {
|
||||||
if (pod is null) {
|
_setStatus(PortForwardRow.FailedMessage(target.Messages));
|
||||||
_setStatus(ShowPodsTab
|
|
||||||
? "Select a pod in the details pane, then port-forward."
|
|
||||||
: "Port-forward requires a pod.");
|
|
||||||
return;
|
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) {
|
if (!started.IsSuccess || started.Value is null) {
|
||||||
_setStatus(string.Join("; ", started.Messages));
|
_setStatus(PortForwardRow.FailedMessage(started.Messages));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PortForwards.Add(new PortForwardItemViewModel { Handle = started.Value, Cluster = Name });
|
var item = new PortForwardItemViewModel {
|
||||||
_setStatus($"Forwarding localhost:{ForwardLocalPort} ({Name})");
|
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]
|
[RelayCommand]
|
||||||
@ -772,8 +865,88 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
if (item is null)
|
if (item is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var localPort = item.Handle.LocalPort;
|
||||||
item.Handle.Dispose();
|
item.Handle.Dispose();
|
||||||
PortForwards.Remove(item);
|
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]
|
[RelayCommand]
|
||||||
@ -1060,7 +1233,13 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
desired.Add(new LimitRowViewModel(limit));
|
desired.Add(new LimitRowViewModel(limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var row in LimitRows)
|
||||||
|
row.PropertyChanged -= OnLimitRowPropertyChanged;
|
||||||
|
|
||||||
CollectionSync.MergeByKey(LimitRows, desired, row => LimitKey(row.Source));
|
CollectionSync.MergeByKey(LimitRows, desired, row => LimitKey(row.Source));
|
||||||
|
foreach (var row in LimitRows)
|
||||||
|
row.PropertyChanged += OnLimitRowPropertyChanged;
|
||||||
|
|
||||||
if (keep is not null && LimitRows.Contains(keep))
|
if (keep is not null && LimitRows.Contains(keep))
|
||||||
SelectedLimitRow = keep;
|
SelectedLimitRow = keep;
|
||||||
else if (SelectedLimitRow is null || !LimitRows.Contains(SelectedLimitRow))
|
else if (SelectedLimitRow is null || !LimitRows.Contains(SelectedLimitRow))
|
||||||
@ -1068,9 +1247,20 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
OnPropertyChanged(nameof(HasContainerLimits));
|
OnPropertyChanged(nameof(HasContainerLimits));
|
||||||
OnPropertyChanged(nameof(HasLimitOvercommit));
|
OnPropertyChanged(nameof(HasLimitOvercommit));
|
||||||
OnPropertyChanged(nameof(LimitsCaption));
|
OnPropertyChanged(nameof(LimitsCaption));
|
||||||
|
OnPropertyChanged(nameof(HasSelectedLimit));
|
||||||
|
OnPropertyChanged(nameof(HasDirtyLimits));
|
||||||
OnPropertyChanged(nameof(CanApplyLimits));
|
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) =>
|
private static string LimitKey(WorkloadContainerLimit limit) =>
|
||||||
$"{limit.Namespace}\0{limit.WorkloadKind}\0{limit.WorkloadName}\0{limit.Container}\0{limit.Init}";
|
$"{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);
|
ReplaceRelatedPods(related);
|
||||||
var podDocument = IsPodSelection ? document : SelectedRelatedPod?.Document;
|
var podDocument = IsPodSelection ? document : SelectedRelatedPod?.Document;
|
||||||
ApplyContainers(podDocument);
|
ApplyContainers(podDocument);
|
||||||
|
ApplyServiceForwardPorts(document);
|
||||||
OverviewText = row.FormatOverview(Containers);
|
OverviewText = row.FormatOverview(Containers);
|
||||||
var events = await _workspace.EventsForAsync(row);
|
var events = await _workspace.EventsForAsync(row);
|
||||||
if (!DetailsStillCurrent(row))
|
if (!DetailsStillCurrent(row))
|
||||||
@ -1279,6 +1470,343 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
OnPropertyChanged(nameof(HasContainers));
|
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 =>
|
private bool IsPodSelection =>
|
||||||
SelectedDescriptor?.Kind == "Pod"
|
SelectedDescriptor?.Kind == "Pod"
|
||||||
|| SelectedNavItem?.Id is ResourceCatalog.DaprSidecarsId or ResourceCatalog.DaprControlPlaneId
|
|| SelectedNavItem?.Id is ResourceCatalog.DaprSidecarsId or ResourceCatalog.DaprControlPlaneId
|
||||||
@ -1306,10 +1834,16 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
OnPropertyChanged(nameof(CanLogs));
|
OnPropertyChanged(nameof(CanLogs));
|
||||||
OnPropertyChanged(nameof(CanExec));
|
OnPropertyChanged(nameof(CanExec));
|
||||||
OnPropertyChanged(nameof(CanPortForward));
|
OnPropertyChanged(nameof(CanPortForward));
|
||||||
|
OnPropertyChanged(nameof(CanStopPortForward));
|
||||||
OnPropertyChanged(nameof(CanCordon));
|
OnPropertyChanged(nameof(CanCordon));
|
||||||
OnPropertyChanged(nameof(CanDrain));
|
OnPropertyChanged(nameof(CanDrain));
|
||||||
OnPropertyChanged(nameof(CanTrigger));
|
OnPropertyChanged(nameof(CanTrigger));
|
||||||
OnPropertyChanged(nameof(CanApply));
|
OnPropertyChanged(nameof(CanApply));
|
||||||
|
OnPropertyChanged(nameof(CanCreateResource));
|
||||||
|
OnPropertyChanged(nameof(CanReloadYaml));
|
||||||
|
OnPropertyChanged(nameof(CanApplyYaml));
|
||||||
|
OnPropertyChanged(nameof(CanEditData));
|
||||||
|
OnPropertyChanged(nameof(HasFooterActions));
|
||||||
OnPropertyChanged(nameof(CanBrowseFiles));
|
OnPropertyChanged(nameof(CanBrowseFiles));
|
||||||
BrowseFilesCommand.NotifyCanExecuteChanged();
|
BrowseFilesCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
@ -1327,6 +1861,12 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
OnPropertyChanged(nameof(CanLogs));
|
OnPropertyChanged(nameof(CanLogs));
|
||||||
OnPropertyChanged(nameof(CanExec));
|
OnPropertyChanged(nameof(CanExec));
|
||||||
OnPropertyChanged(nameof(CanPortForward));
|
OnPropertyChanged(nameof(CanPortForward));
|
||||||
|
OnPropertyChanged(nameof(CanStopPortForward));
|
||||||
|
OnPropertyChanged(nameof(CanCreateResource));
|
||||||
|
OnPropertyChanged(nameof(CanReloadYaml));
|
||||||
|
OnPropertyChanged(nameof(CanApplyYaml));
|
||||||
|
OnPropertyChanged(nameof(CanEditData));
|
||||||
|
OnPropertyChanged(nameof(HasFooterActions));
|
||||||
OnPropertyChanged(nameof(CanBrowseFiles));
|
OnPropertyChanged(nameof(CanBrowseFiles));
|
||||||
BrowseFilesCommand.NotifyCanExecuteChanged();
|
BrowseFilesCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,14 +131,31 @@ public partial class NavGroupViewModel : ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PortForwardItemViewModel {
|
public sealed class PortForwardItemViewModel {
|
||||||
public required PortForwardHandle Handle { get; init; }
|
public required PortForwardHandle Handle { get; set; }
|
||||||
|
|
||||||
public string Cluster { get; init; } = "";
|
public string Cluster { get; init; } = "";
|
||||||
|
|
||||||
public string Display =>
|
public required string Uid { get; set; }
|
||||||
string.IsNullOrEmpty(Cluster)
|
|
||||||
? $"{Handle.Namespace}/{Handle.PodName} {Handle.LocalPort}->{Handle.ContainerPort}"
|
public string Kind { get; init; } = "Pod";
|
||||||
: $"{Cluster} {Handle.Namespace}/{Handle.PodName} {Handle.LocalPort}->{Handle.ContainerPort}";
|
|
||||||
|
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 {
|
public partial class DataEntryViewModel : ObservableObject {
|
||||||
@ -300,6 +317,9 @@ public partial class MainViewModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
_pages[item.Name] = page;
|
_pages[item.Name] = page;
|
||||||
ActivatePage(page);
|
ActivatePage(page);
|
||||||
|
var restored = await page.RestorePortForwardsAsync();
|
||||||
|
if (restored.Total > 0)
|
||||||
|
Status = restored.Format();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@ -344,6 +364,7 @@ public partial class MainViewModel : ObservableObject, IDisposable {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var preferred = _configuration.Current.ActiveContext;
|
var preferred = _configuration.Current.ActiveContext;
|
||||||
|
PortForwardRestoreSummary? restored = null;
|
||||||
foreach (var name in names) {
|
foreach (var name in names) {
|
||||||
var item = Catalog.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.Ordinal));
|
var item = Catalog.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.Ordinal));
|
||||||
if (item is null)
|
if (item is null)
|
||||||
@ -362,6 +383,7 @@ public partial class MainViewModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
page.PausePolling();
|
page.PausePolling();
|
||||||
_pages[item.Name] = page;
|
_pages[item.Name] = page;
|
||||||
|
restored = MergeRestore(restored, await page.RestorePortForwardsAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
var active = _pages.TryGetValue(preferred ?? "", out var preferredPage)
|
var active = _pages.TryGetValue(preferred ?? "", out var preferredPage)
|
||||||
@ -371,6 +393,20 @@ public partial class MainViewModel : ObservableObject, IDisposable {
|
|||||||
ActivatePage(active);
|
ActivatePage(active);
|
||||||
else
|
else
|
||||||
SyncCatalogFlags();
|
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) {
|
private ClusterPageViewModel CreatePage(KubeContextInfo context) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user