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 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 cpuHistory = []; [ObservableProperty] private IReadOnlyList memoryHistory = []; private readonly List _cpuHistory = []; private readonly List _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 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 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 Items { get; init; } public IReadOnlyList Groups { get; init; } = []; public Action? 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 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? MatchLabels { get; init; } public PersistedPortForward ToPersisted() => new() { Context = Cluster, Kind = Kind, Name = string.IsNullOrEmpty(ResourceName) ? Handle.PodName : ResourceName, Namespace = Handle.Namespace, PodName = Handle.PodName, LocalPort = Handle.LocalPort, RemotePort = Handle.RequestedPort, MatchLabels = MatchLabels is null ? null : new Dictionary(MatchLabels, StringComparer.Ordinal) }; } public partial class DataEntryViewModel : ObservableObject { [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 Select { get; init; } public required Action Close { get; init; } public required Action 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 _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 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 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? VolumeFilesRequested; public Func? ShowRetainReclaim { get; set; } public Func>? ShowDrainPreview { get; set; } public Func>? ConfirmLonghornAttach { get; set; } public Func>? ConfirmLonghornDetach { get; set; } public Func>? 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(); } }