maksit-cluster-console/src/MaksIT.ClusterConsole.UI/ViewModels/Shell/MainViewModel.cs

676 lines
19 KiB
C#

using System.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MaksIT.ClusterConsole.Shared;
using MaksIT.ClusterConsole.Client.Ollama;
using MaksIT.ClusterConsole.UI.ViewModels;
using MaksIT.ClusterConsole.Client.Cluster;
using MaksIT.ClusterConsole.Client.KubeConfig;
using MaksIT.ClusterConsole.UI.ViewModels.Cluster;
using MaksIT.ClusterConsole.UI.ViewModels.Storage;
using MaksIT.ClusterConsole.UI.ViewModels.Connections;
namespace MaksIT.ClusterConsole.UI.ViewModels.Shell;
public partial class NavItemViewModel : ObservableObject {
public required NavigatorItem Item { get; init; }
public required Action<NavigatorItem> Select { get; init; }
public string Title => Item.Title;
[ObservableProperty]
private bool isSelected;
[RelayCommand]
private void Activate() => Select(Item);
}
public partial class NodeUsageViewModel : ObservableObject {
public required string Name { get; init; }
[ObservableProperty]
private ResourceSlice cpu = ResourceSlice.Empty("cpu");
[ObservableProperty]
private ResourceSlice memory = ResourceSlice.Empty("memory");
[ObservableProperty]
private ResourceSlice pods = ResourceSlice.Empty("pods");
[ObservableProperty]
private double cpuPercent;
[ObservableProperty]
private double memoryPercent;
[ObservableProperty]
private double podPercent;
[ObservableProperty]
private IReadOnlyList<double> cpuHistory = [];
[ObservableProperty]
private IReadOnlyList<double> memoryHistory = [];
private readonly List<double> _cpuHistory = [];
private readonly List<double> _memoryHistory = [];
public void Update(NodeUsage usage, int historyPoints, bool sampleHistory) {
Cpu = usage.Cpu;
Memory = usage.Memory;
Pods = usage.Pods;
SetPercents(usage.Cpu, usage.Memory, usage.Pods);
if (!sampleHistory)
return;
AppendHistory(historyPoints);
}
public void UpdateUsed(NodeUsage usage, int historyPoints, bool sampleHistory) {
Cpu = WithUsed(Cpu, usage.Cpu);
Memory = WithUsed(Memory, usage.Memory);
Pods = WithUsed(Pods, usage.Pods);
SetPercents(Cpu, Memory, Pods);
if (!sampleHistory)
return;
AppendHistory(historyPoints);
}
private void SetPercents(ResourceSlice cpu, ResourceSlice memory, ResourceSlice pods) {
CpuPercent = cpu.Allocatable <= 0 ? 0 : Math.Clamp(cpu.Used / cpu.Allocatable * 100, 0, 100);
MemoryPercent = memory.Allocatable <= 0 ? 0 : Math.Clamp(memory.Used / memory.Allocatable * 100, 0, 100);
PodPercent = pods.Allocatable <= 0 ? 0 : Math.Clamp(pods.Used / pods.Allocatable * 100, 0, 100);
}
private void AppendHistory(int historyPoints) {
Append(_cpuHistory, CpuPercent, historyPoints);
Append(_memoryHistory, MemoryPercent, historyPoints);
CpuHistory = _cpuHistory.ToArray();
MemoryHistory = _memoryHistory.ToArray();
}
private static ResourceSlice WithUsed(ResourceSlice current, ResourceSlice incoming) =>
new(incoming.Used, current.Requests, current.Limits, incoming.Allocatable, incoming.Capacity, incoming.Kind);
private static void Append(List<double> history, double value, int historyPoints) {
history.Add(value);
if (history.Count > historyPoints)
history.RemoveAt(0);
}
}
public partial class WorkloadKindCount : ObservableObject {
public required string Id { get; init; }
public required string Title { get; init; }
public required int Count { get; init; }
public required Action<string> Open { get; init; }
[RelayCommand]
private void Activate() => Open(Id);
}
public partial class NavGroupViewModel : ObservableObject {
public required string Section { get; init; }
public required string Path { get; init; }
public string IconData { get; init; } = "";
public required IReadOnlyList<NavItemViewModel> Items { get; init; }
public IReadOnlyList<NavGroupViewModel> Groups { get; init; } = [];
public Action<NavGroupViewModel>? ExpandedChanged { get; init; }
public bool HasIcon => !string.IsNullOrWhiteSpace(IconData);
[ObservableProperty]
private bool isExpanded;
public string Chevron => IsExpanded ? "▾" : "▸";
[RelayCommand]
private void Toggle() {
IsExpanded = !IsExpanded;
ExpandedChanged?.Invoke(this);
}
partial void OnIsExpandedChanged(bool value) =>
OnPropertyChanged(nameof(Chevron));
public bool Contains(string? itemId) =>
AllItems().Any(i => i.Item.Id == itemId);
public IEnumerable<NavItemViewModel> AllItems() {
foreach (var item in Items)
yield return item;
foreach (var group in Groups) {
foreach (var item in group.AllItems())
yield return item;
}
}
}
public sealed class PortForwardItemViewModel {
public required PortForwardHandle Handle { get; set; }
public string Cluster { get; init; } = "";
public required string Uid { get; set; }
public string Kind { get; init; } = "Pod";
public string ResourceName { get; init; } = "";
public Dictionary<string, string>? MatchLabels { get; init; }
public PersistedPortForward ToPersisted() =>
new() {
Context = Cluster,
Kind = Kind,
Name = string.IsNullOrEmpty(ResourceName) ? Handle.PodName : ResourceName,
Namespace = Handle.Namespace,
PodName = Handle.PodName,
LocalPort = Handle.LocalPort,
RemotePort = Handle.RequestedPort,
MatchLabels = MatchLabels is null
? null
: new Dictionary<string, string>(MatchLabels, StringComparer.Ordinal)
};
}
public partial class DataEntryViewModel : ObservableObject {
[ObservableProperty]
private string key = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ValuePreview))]
private string value = string.Empty;
[ObservableProperty]
private bool isBinary;
public string ValuePreview {
get {
if (string.IsNullOrEmpty(Value))
return "";
var end = Value.IndexOfAny(['\r', '\n']);
if (end < 0)
return Value;
return Value[..end] + "…";
}
}
}
public partial class CatalogItemViewModel : ObservableObject {
public required KubeContextInfo Context { get; init; }
public required Action<CatalogItemViewModel> Select { get; init; }
public required Action<CatalogItemViewModel> Close { get; init; }
public required Action<CatalogItemViewModel> UseKubectl { get; init; }
public string Name => Context.Name;
[ObservableProperty]
private bool isConnected;
[ObservableProperty]
private bool isActive;
[ObservableProperty]
private bool isKubectlCurrent;
[RelayCommand]
private void Activate() => Select(this);
[RelayCommand]
private void Disconnect() => Close(this);
[RelayCommand]
private void UseForKubectl() => UseKubectl(this);
}
public partial class MainViewModel : ObservableObject, IDisposable {
private readonly IKubeConfigService _kubeConfig;
private readonly IClusterSessionFactory _sessions;
private readonly ConfigurationFileService _configuration;
private readonly IOllamaChatClient _ollama;
private readonly Dictionary<string, ClusterPageViewModel> _pages = new(StringComparer.Ordinal);
private readonly Task _restoreTask;
public MainViewModel(
IKubeConfigService kubeConfig,
IClusterSessionFactory sessions,
ConfigurationFileService configuration,
IOllamaChatClient ollama,
ScreenshotTourOptions? tour = null) {
_kubeConfig = kubeConfig;
_sessions = sessions;
_configuration = configuration;
_ollama = ollama;
catalogCollapsed = _configuration.Current.Layout.CatalogCollapsed;
if (tour is not null) {
_configuration.Current.Layout.ClearFiltersAndSort();
_configuration.Current.UseAllNamespaces();
}
LoadCatalog();
if (tour is null)
_restoreTask = RestoreSessionsCommand.ExecuteAsync(null);
else
_restoreTask = Task.CompletedTask;
}
public async Task<string?> ConnectForCaptureAsync(string? name) {
await _restoreTask;
var target = string.IsNullOrWhiteSpace(name) ? _configuration.Current.ActiveContext : name;
if (string.IsNullOrWhiteSpace(target))
target = Catalog.FirstOrDefault()?.Name;
if (string.IsNullOrWhiteSpace(target))
return null;
await ConnectNamedAsync(target);
return string.Equals(ActivePage?.Name, target, StringComparison.Ordinal) ? target : null;
}
public ObservableCollection<CatalogItemViewModel> Catalog { get; } = [];
[ObservableProperty]
private ClusterPageViewModel? activePage;
[ObservableProperty]
private string status = "Select a cluster from the catalog.";
[ObservableProperty]
private bool catalogCollapsed;
public bool IsClusterOpen => ActivePage is not null;
public bool ShowChat => _configuration.Current.Ai.Enabled;
public string ClusterTitle => ActivePage?.Name ?? "Catalog";
public NavigatorItem? SelectedNavItem => ActivePage?.SelectedNavItem;
public ResourceDescriptor? SelectedDescriptor => ActivePage?.SelectedDescriptor;
partial void OnCatalogCollapsedChanged(bool value) {
var cfg = _configuration.Current;
if (cfg.Layout.CatalogCollapsed == value)
return;
cfg.Layout.CatalogCollapsed = value;
_configuration.Save(cfg);
}
[RelayCommand]
private void ToggleCatalog() =>
CatalogCollapsed = !CatalogCollapsed;
partial void OnActivePageChanged(ClusterPageViewModel? oldValue, ClusterPageViewModel? newValue) {
if (oldValue is not null)
oldValue.PropertyChanged -= OnActivePagePropertyChanged;
if (newValue is not null)
newValue.PropertyChanged += OnActivePagePropertyChanged;
OnPropertyChanged(nameof(IsClusterOpen));
OnPropertyChanged(nameof(ClusterTitle));
OnPropertyChanged(nameof(SelectedNavItem));
OnPropertyChanged(nameof(SelectedDescriptor));
}
private void OnActivePagePropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName is nameof(ClusterPageViewModel.SelectedNavItem)) {
OnPropertyChanged(nameof(SelectedNavItem));
OnPropertyChanged(nameof(SelectedDescriptor));
}
}
[RelayCommand]
private void LoadCatalog() {
var listed = _kubeConfig.ListContexts();
if (!listed.IsSuccess) {
Status = string.Join("; ", listed.Messages);
return;
}
Catalog.Clear();
foreach (var ctx in listed.Value ?? []) {
Catalog.Add(new CatalogItemViewModel {
Context = ctx,
Select = item => _ = OpenOrSwitchAsync(item),
Close = item => DisconnectNamed(item.Name),
UseKubectl = UseKubectlCurrent
});
}
DropMissingPages();
SyncCatalogFlags();
Status = Catalog.Count == 0
? "No kubeconfig contexts found. Open Connections to add one."
: $"{Catalog.Count} context(s) in kubeconfig · {_pages.Count} connected.";
}
[RelayCommand]
private void OpenConnections() =>
ConnectionsRequested?.Invoke(this, EventArgs.Empty);
public event EventHandler? ConnectionsRequested;
[RelayCommand]
private void OpenAiSettings() =>
AiSettingsRequested?.Invoke(this, EventArgs.Empty);
public event EventHandler? AiSettingsRequested;
[RelayCommand]
private void OpenScreenSaverSettings() =>
ScreenSaverSettingsRequested?.Invoke(this, EventArgs.Empty);
public event EventHandler? ScreenSaverSettingsRequested;
public event Action? ScreenSaverSettingsChanged;
[ObservableProperty]
private bool isFullScreen;
[RelayCommand]
private void ToggleFullScreen() =>
FullScreenToggleRequested?.Invoke(this, EventArgs.Empty);
public event EventHandler? FullScreenToggleRequested;
public event Action<VolumeFilesViewModel>? VolumeFilesRequested;
public Func<RetainReclaimViewModel, Task>? ShowRetainReclaim { get; set; }
public Func<DrainConfirmViewModel, Task<bool>>? ShowDrainPreview { get; set; }
public Func<LonghornAttachViewModel, Task<bool>>? ConfirmLonghornAttach { get; set; }
public Func<LonghornDetachViewModel, Task<bool>>? ConfirmLonghornDetach { get; set; }
public Func<LonghornBackupJobViewModel, Task<bool>>? ConfirmLonghornBackupJob { get; set; }
public ConnectionsViewModel CreateConnectionsViewModel() =>
new(_kubeConfig);
public AiSettingsViewModel CreateAiSettingsViewModel() {
var cfg = _configuration.Current;
return new AiSettingsViewModel(
cfg.Ai.Enabled,
cfg.Ai.AgentEnabled,
cfg.Ai.Endpoint,
cfg.Ai.Model,
cfg.Ai.TimeoutSeconds,
ApplyAiSettings);
}
private void ApplyAiSettings(bool enabled, bool agent, string endpoint, string model, int timeoutSeconds) {
var cfg = _configuration.Current;
cfg.Ai.Enabled = enabled;
cfg.Ai.AgentEnabled = enabled && agent;
cfg.Ai.Endpoint = endpoint;
cfg.Ai.Model = model;
cfg.Ai.TimeoutSeconds = timeoutSeconds;
cfg.EnsureDefaults();
_configuration.Save(cfg);
OnPropertyChanged(nameof(ShowChat));
foreach (var page in _pages.Values)
page.Chat.NotifySettingsChanged();
}
public ScreenSaverSettingsViewModel CreateScreenSaverSettingsViewModel() {
var cfg = _configuration.Current;
return new ScreenSaverSettingsViewModel(
cfg.ScreenSaver.Enabled,
cfg.ScreenSaver.IdleMinutes,
ApplyScreenSaverSettings);
}
private void ApplyScreenSaverSettings(bool enabled, int idleMinutes) {
var cfg = _configuration.Current;
cfg.ScreenSaver.Enabled = enabled;
cfg.ScreenSaver.IdleMinutes = idleMinutes;
cfg.EnsureDefaults();
_configuration.Save(cfg);
ScreenSaverSettingsChanged?.Invoke();
}
public Task ConnectNamedAsync(string name) {
var item = Catalog.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.Ordinal));
return OpenOrSwitchAsync(item);
}
private async Task OpenOrSwitchAsync(CatalogItemViewModel? item) {
if (item is null)
return;
if (_pages.TryGetValue(item.Name, out var existing)) {
ActivatePage(existing);
return;
}
Status = $"Connecting to {item.Name}…";
var created = _sessions.Create(item.Name);
if (!created.IsSuccess || created.Value is null) {
Status = string.Join("; ", created.Messages);
return;
}
var page = CreatePage(item.Context);
var started = await page.StartAsync(created.Value);
if (!started.IsSuccess) {
page.Dispose();
Status = string.Join("; ", started.Messages);
return;
}
_pages[item.Name] = page;
ActivatePage(page);
var restored = await page.RestorePortForwardsAsync();
if (restored.Total > 0)
Status = restored.Format();
}
[RelayCommand]
private void Disconnect() {
if (ActivePage is null)
return;
DisconnectNamed(ActivePage.Name);
}
private void DisconnectNamed(string name) {
if (!_pages.TryGetValue(name, out var page))
return;
var wasActive = ActivePage == page;
if (wasActive)
page.PausePolling();
page.Dispose();
_pages.Remove(name);
if (wasActive) {
ActivePage = null;
var next = _pages.Values.FirstOrDefault();
if (next is not null)
ActivatePage(next);
else
Status = "Disconnected.";
}
SyncCatalogFlags();
PersistOpenState();
}
[RelayCommand]
private async Task RestoreSessionsAsync() {
var names = (_configuration.Current.OpenContexts ?? [])
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.Ordinal)
.ToList();
if (names.Count == 0)
return;
var preferred = _configuration.Current.ActiveContext;
PortForwardRestoreSummary? restored = null;
foreach (var name in names) {
var item = Catalog.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.Ordinal));
if (item is null)
continue;
var created = _sessions.Create(item.Name);
if (!created.IsSuccess || created.Value is null)
continue;
var page = CreatePage(item.Context);
var started = await page.StartAsync(created.Value);
if (!started.IsSuccess) {
page.Dispose();
continue;
}
page.PausePolling();
_pages[item.Name] = page;
restored = MergeRestore(restored, await page.RestorePortForwardsAsync());
}
var active = _pages.TryGetValue(preferred ?? "", out var preferredPage)
? preferredPage
: _pages.Values.FirstOrDefault();
if (active is not null)
ActivatePage(active);
else
SyncCatalogFlags();
if (restored is not null && restored.Total > 0)
Status = restored.Format();
}
private static PortForwardRestoreSummary MergeRestore(
PortForwardRestoreSummary? current,
PortForwardRestoreSummary incoming) {
if (current is null)
return incoming;
return new PortForwardRestoreSummary(
current.Restored + incoming.Restored,
current.Failures.Concat(incoming.Failures).ToList());
}
private ClusterPageViewModel CreatePage(KubeContextInfo context) {
var page = new ClusterPageViewModel(context, new ClusterWorkspace(), _configuration, _ollama, text => Status = text);
page.VolumeFilesRequested += vm => VolumeFilesRequested?.Invoke(vm);
page.ShowRetainReclaim = viewModel => ShowRetainReclaim?.Invoke(viewModel) ?? Task.CompletedTask;
page.ConfirmDrain = dialog => ShowDrainPreview?.Invoke(dialog) ?? Task.FromResult(false);
page.ConfirmLonghornAttach = dialog => ConfirmLonghornAttach?.Invoke(dialog) ?? Task.FromResult(false);
page.ConfirmLonghornDetach = dialog => ConfirmLonghornDetach?.Invoke(dialog) ?? Task.FromResult(false);
page.ConfirmLonghornBackupJob = dialog => ConfirmLonghornBackupJob?.Invoke(dialog) ?? Task.FromResult(false);
return page;
}
private void ActivatePage(ClusterPageViewModel page) {
if (ActivePage == page) {
SyncCatalogFlags();
return;
}
ActivePage?.PausePolling();
ActivePage = page;
page.ResumePolling();
_ = page.RefreshRowsCommand.ExecuteAsync(null);
SyncCatalogFlags();
PersistOpenState();
Status = $"Connected to {page.Name}";
}
private void UseKubectlCurrent(CatalogItemViewModel item) {
if (item.IsKubectlCurrent)
return;
var used = _kubeConfig.UseContext(item.Name);
Status = string.Join("; ", used.Messages);
SyncCatalogFlags();
}
private void SyncCatalogFlags() {
var current = _kubeConfig.GetCurrentContext();
var kubectlCurrent = current.IsSuccess ? current.Value : null;
foreach (var item in Catalog) {
item.IsConnected = _pages.ContainsKey(item.Name);
item.IsActive = ActivePage?.Name == item.Name;
item.IsKubectlCurrent = string.Equals(item.Name, kubectlCurrent, StringComparison.Ordinal);
}
}
private void DropMissingPages() {
foreach (var name in _pages.Keys.Except(Catalog.Select(c => c.Name), StringComparer.Ordinal).ToList())
DisconnectNamed(name);
}
private void PersistOpenState() {
var cfg = _configuration.Current;
cfg.OpenContexts = [.. _pages.Keys];
cfg.ActiveContext = ActivePage?.Name;
_configuration.Save(cfg);
}
public void Dispose() {
foreach (var page in _pages.Values)
page.Dispose();
_pages.Clear();
}
}