From a77e12a648cf411ae86eac40d6b820ce225c482a Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Thu, 20 Aug 2026 19:22:51 +0200 Subject: [PATCH] (feature): persist per-cluster table filters and sort --- CHANGELOG.md | 10 + README.md | 4 +- src/Directory.Build.props | 2 +- .../CollectionSync.cs | 20 +- .../Configuration.cs | 4 +- .../LayoutSettings.cs | 187 +++++++++++++++--- .../appsettings.json | 4 +- .../CollectionSyncTests.cs | 18 ++ .../ConfigurationFileServiceTests.cs | 110 +++++++++++ .../LayoutPersistence.cs | 113 +++++++++-- .../MainWindow.axaml.cs | 25 ++- .../ViewModels/ClusterPageViewModel.cs | 44 ++++- 12 files changed, 475 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18c8666..58534b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.4.0] - 2026-08-20 + +### Added + +- Resource table column filters, row sort, widths, and search are stored per cluster in `appsettings.json` (`Configuration:Layout:Tables`). + +### Fixed + +- Switching clusters no longer resets table column filters or sort order. + ## [0.3.0] - 2026-08-20 ### Changed diff --git a/README.md b/README.md index bf37962..b12351e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Capabilities that are first-class in ClusterConsole, not afterthoughts: - **Contexts** — catalog of kubeconfig contexts; radio selects kubectl 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 +- **Resource tables** — list and refresh any catalogued type; per-column filters and row sort persisted per cluster - **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 @@ -68,7 +68,7 @@ Defaults live in `src/MaksIT.ClusterConsole.Shared/appsettings.json` (copied nex | `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 | +| `Layout` | Window and pane sizes, last navigator item, per-cluster table layout (`Tables`) | Port-forwards are saved when you start them in the UI. Chat cannot apply, restart, or delete. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 9f243f7..8f53ae0 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.3.0 + 0.4.0 MaksIT.ClusterConsole MaksIT.ClusterConsole diff --git a/src/MaksIT.ClusterConsole.Shared/CollectionSync.cs b/src/MaksIT.ClusterConsole.Shared/CollectionSync.cs index e8f3df8..9598303 100644 --- a/src/MaksIT.ClusterConsole.Shared/CollectionSync.cs +++ b/src/MaksIT.ClusterConsole.Shared/CollectionSync.cs @@ -5,7 +5,8 @@ public static class CollectionSync { IList target, IReadOnlyList source, Func keySelector, - Action? copy = null) + Action? copy = null, + bool matchSourceOrder = false) where TKey : notnull { ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(source); @@ -48,5 +49,22 @@ public static class CollectionSync { if (index >= 0) target[index] = item; } + + if (!matchSourceOrder) + return; + + var byKey = new Dictionary(); + foreach (var item in target) + byKey[keySelector(item)] = item; + + for (var i = 0; i < nextItems.Count; i++) { + if (!byKey.TryGetValue(nextItems[i].Key, out var item)) + continue; + var at = target.IndexOf(item); + if (at < 0 || at == i) + continue; + target.RemoveAt(at); + target.Insert(i, item); + } } } diff --git a/src/MaksIT.ClusterConsole.Shared/Configuration.cs b/src/MaksIT.ClusterConsole.Shared/Configuration.cs index fdee2e7..c9f7135 100644 --- a/src/MaksIT.ClusterConsole.Shared/Configuration.cs +++ b/src/MaksIT.ClusterConsole.Shared/Configuration.cs @@ -28,9 +28,7 @@ public sealed class Configuration { NamespacesByContext ??= new Dictionary(StringComparer.Ordinal); NavigatorExpanded ??= new Dictionary(StringComparer.Ordinal); Layout ??= new LayoutSettings(); - Layout.ColumnWidths ??= new Dictionary>(StringComparer.Ordinal); - Layout.ColumnFilters ??= new Dictionary>(StringComparer.Ordinal); - Layout.SearchByResource ??= new Dictionary(StringComparer.Ordinal); + Layout.Normalize(); PortForwards ??= []; if (string.IsNullOrWhiteSpace(OllamaEndpoint)) OllamaEndpoint = ClusterChatService.DefaultEndpoint; diff --git a/src/MaksIT.ClusterConsole.Shared/LayoutSettings.cs b/src/MaksIT.ClusterConsole.Shared/LayoutSettings.cs index 9814b83..fa58632 100644 --- a/src/MaksIT.ClusterConsole.Shared/LayoutSettings.cs +++ b/src/MaksIT.ClusterConsole.Shared/LayoutSettings.cs @@ -1,3 +1,6 @@ +using System.Text.Json.Serialization; + + namespace MaksIT.ClusterConsole.Shared; public sealed class SavedColumnFilter { @@ -6,6 +9,22 @@ public sealed class SavedColumnFilter { public List Excluded { get; set; } = []; } +public sealed class SavedColumnSort { + public string Header { get; set; } = ""; + + public string Direction { get; set; } = "Ascending"; +} + +public sealed class SavedTableLayout { + public Dictionary Widths { get; set; } = new(StringComparer.Ordinal); + + public Dictionary Filters { get; set; } = new(StringComparer.Ordinal); + + public SavedColumnSort? Sort { get; set; } + + public string Search { get; set; } = ""; +} + public sealed class LayoutSettings { public const string OverviewWarningsTable = "overview-warnings"; public const string OverviewErrorsTable = "overview-errors"; @@ -30,48 +49,166 @@ public sealed class LayoutSettings { public string? SelectedNavId { get; set; } - public Dictionary> ColumnWidths { get; set; } = new(StringComparer.Ordinal); + public Dictionary Tables { get; set; } = new(StringComparer.Ordinal); - public Dictionary> ColumnFilters { get; set; } = new(StringComparer.Ordinal); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary>? ColumnWidths { get; set; } - public Dictionary SearchByResource { get; set; } = new(StringComparer.Ordinal); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary>? ColumnFilters { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? ColumnSorts { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? SearchByResource { get; set; } public static string ResourceTable(string? resourceId) => string.IsNullOrWhiteSpace(resourceId) ? "resources" : $"resources/{resourceId}"; - public IReadOnlyDictionary? ColumnsFor(string tableKey) { - if (ColumnWidths is not null && ColumnWidths.TryGetValue(tableKey, out var widths) && widths.Count > 0) - return widths; - return null; + public static string ContextTable(string? context, string tableKey) => + string.IsNullOrWhiteSpace(context) ? tableKey : $"{context}/{tableKey}"; + + public void Normalize() { + Tables ??= new Dictionary(StringComparer.Ordinal); + foreach (var table in Tables.Values) + NormalizeTable(table); + + MergeLegacy(); + ColumnWidths = null; + ColumnFilters = null; + ColumnSorts = null; + SearchByResource = null; } - public void SetColumns(string tableKey, Dictionary widths) { - ColumnWidths ??= new Dictionary>(StringComparer.Ordinal); - ColumnWidths[tableKey] = widths; + public IReadOnlyDictionary? ColumnsFor(string tableKey) => + ColumnsFor(null, tableKey); + + public IReadOnlyDictionary? ColumnsFor(string? context, string tableKey) { + var widths = Find(context, tableKey)?.Widths; + return widths is { Count: > 0 } ? widths : null; } - public SavedColumnFilter? FilterFor(string tableKey, string header) { - if (ColumnFilters is not null - && ColumnFilters.TryGetValue(tableKey, out var filters) - && filters.TryGetValue(header, out var filter)) + public void SetColumns(string tableKey, Dictionary widths) => + SetColumns(null, tableKey, widths); + + public void SetColumns(string? context, string tableKey, Dictionary widths) => + GetOrAdd(context, tableKey).Widths = new Dictionary(widths, StringComparer.Ordinal); + + public SavedColumnFilter? FilterFor(string tableKey, string header) => + FilterFor(null, tableKey, header); + + public SavedColumnFilter? FilterFor(string? context, string tableKey, string header) { + var filters = Find(context, tableKey)?.Filters; + if (filters is not null && filters.TryGetValue(header, out var filter)) return filter; return null; } - public void SetFilters(string tableKey, Dictionary filters) { - ColumnFilters ??= new Dictionary>(StringComparer.Ordinal); - ColumnFilters[tableKey] = filters; + public void SetFilters(string tableKey, Dictionary filters) => + SetFilters(null, tableKey, filters); + + public void SetFilters(string? context, string tableKey, Dictionary filters) => + GetOrAdd(context, tableKey).Filters = new Dictionary(filters, StringComparer.Ordinal); + + public SavedColumnSort? SortFor(string tableKey) => + SortFor(null, tableKey); + + public SavedColumnSort? SortFor(string? context, string tableKey) { + var sort = Find(context, tableKey)?.Sort; + return sort is null || string.IsNullOrWhiteSpace(sort.Header) ? null : sort; } - public string SearchFor(string? resourceId) { - var key = ResourceTable(resourceId); - if (SearchByResource is not null && SearchByResource.TryGetValue(key, out var text)) - return text ?? ""; - return ""; + public void SetSort(string tableKey, SavedColumnSort? sort) => + SetSort(null, tableKey, sort); + + public void SetSort(string? context, string tableKey, SavedColumnSort? sort) { + var table = GetOrAdd(context, tableKey); + table.Sort = sort is null || string.IsNullOrWhiteSpace(sort.Header) ? null : sort; } - public void SetSearch(string? resourceId, string text) { - SearchByResource ??= new Dictionary(StringComparer.Ordinal); - SearchByResource[ResourceTable(resourceId)] = text ?? ""; + public string SearchFor(string? resourceId) => + SearchFor(null, resourceId); + + public string SearchFor(string? context, string? resourceId) => + Find(context, ResourceTable(resourceId))?.Search ?? ""; + + public void SetSearch(string? resourceId, string text) => + SetSearch(null, resourceId, text); + + public void SetSearch(string? context, string? resourceId, string text) => + GetOrAdd(context, ResourceTable(resourceId)).Search = text ?? ""; + + private SavedTableLayout GetOrAdd(string? context, string tableKey) { + Tables ??= new Dictionary(StringComparer.Ordinal); + var key = ContextTable(context, tableKey); + if (!Tables.TryGetValue(key, out var table)) { + table = new SavedTableLayout(); + Tables[key] = table; + } + + NormalizeTable(table); + return table; + } + + private SavedTableLayout? Find(string? context, string tableKey) { + if (Tables is null) + return null; + + if (!string.IsNullOrWhiteSpace(context) + && Tables.TryGetValue(ContextTable(context, tableKey), out var keyed)) + return keyed; + + if (Tables.TryGetValue(tableKey, out var shared)) + return shared; + + return null; + } + + private void MergeLegacy() { + if (ColumnWidths is not null) { + foreach (var (key, widths) in ColumnWidths) { + if (widths is not { Count: > 0 }) + continue; + var table = GetOrAdd(null, key); + if (table.Widths.Count == 0) + table.Widths = new Dictionary(widths, StringComparer.Ordinal); + } + } + + if (ColumnFilters is not null) { + foreach (var (key, filters) in ColumnFilters) { + if (filters is not { Count: > 0 }) + continue; + var table = GetOrAdd(null, key); + if (table.Filters.Count == 0) + table.Filters = new Dictionary(filters, StringComparer.Ordinal); + } + } + + if (ColumnSorts is not null) { + foreach (var (key, sort) in ColumnSorts) { + if (sort is null || string.IsNullOrWhiteSpace(sort.Header)) + continue; + var table = GetOrAdd(null, key); + table.Sort ??= sort; + } + } + + if (SearchByResource is not null) { + foreach (var (key, text) in SearchByResource) { + if (string.IsNullOrEmpty(text)) + continue; + var table = GetOrAdd(null, key); + if (string.IsNullOrEmpty(table.Search)) + table.Search = text; + } + } + } + + private static void NormalizeTable(SavedTableLayout table) { + table.Widths ??= new Dictionary(StringComparer.Ordinal); + table.Filters ??= new Dictionary(StringComparer.Ordinal); + table.Search ??= ""; } } diff --git a/src/MaksIT.ClusterConsole.Shared/appsettings.json b/src/MaksIT.ClusterConsole.Shared/appsettings.json index 018d752..70ee22d 100644 --- a/src/MaksIT.ClusterConsole.Shared/appsettings.json +++ b/src/MaksIT.ClusterConsole.Shared/appsettings.json @@ -19,9 +19,7 @@ "NavigatorWidth": 228, "DetailsWidth": 380, "SelectedNavId": "pods", - "ColumnWidths": {}, - "ColumnFilters": {}, - "SearchByResource": {} + "Tables": {} }, "PortForwards": [] } diff --git a/src/MaksIT.ClusterConsole.Tests/CollectionSyncTests.cs b/src/MaksIT.ClusterConsole.Tests/CollectionSyncTests.cs index 65a4248..5eb9d49 100644 --- a/src/MaksIT.ClusterConsole.Tests/CollectionSyncTests.cs +++ b/src/MaksIT.ClusterConsole.Tests/CollectionSyncTests.cs @@ -32,6 +32,24 @@ public class CollectionSyncTests { Assert.Equal("c", target[1].Key); } + [Fact] + public void MergeByKey_can_reorder_to_match_source() { + var first = Item("a", 1); + var second = Item("b", 1); + var third = Item("c", 1); + var target = new ObservableCollection { first, second, third }; + + CollectionSync.MergeByKey( + target, + [third, first, second], + item => item.Key, + matchSourceOrder: true); + + Assert.Equal(["c", "a", "b"], target.Select(item => item.Key).ToList()); + Assert.Same(third, target[0]); + Assert.Same(first, target[1]); + } + [Fact] public void MergeByKey_replaces_records_without_reset() { var target = new ObservableCollection { diff --git a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs index 641b4a2..d157023 100644 --- a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs +++ b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs @@ -289,4 +289,114 @@ public class ConfigurationFileServiceTests { File.Delete(path); } } + + [Fact] + public void Table_filters_and_sort_are_stored_per_context() { + var layout = new LayoutSettings(); + layout.SetFilters("resources/pods", new Dictionary { + ["Status"] = new() { Text = "legacy" } + }); + layout.SetFilters("prod", "resources/pods", new Dictionary { + ["Status"] = new() { Text = "Crash" } + }); + layout.SetSort("prod", "resources/pods", new SavedColumnSort { + Header = "Age", + Direction = "Descending" + }); + layout.SetColumns("prod", "resources/pods", new Dictionary { ["Name"] = 240 }); + + Assert.Equal("Crash", layout.FilterFor("prod", "resources/pods", "Status")?.Text); + Assert.Equal("legacy", layout.FilterFor("dev", "resources/pods", "Status")?.Text); + Assert.Equal("Age", layout.SortFor("prod", "resources/pods")?.Header); + Assert.Equal("Descending", layout.SortFor("prod", "resources/pods")?.Direction); + Assert.Null(layout.SortFor("dev", "resources/pods")); + Assert.Equal(240, layout.ColumnsFor("prod", "resources/pods")!["Name"]); + Assert.Null(layout.ColumnsFor("dev", "resources/pods")); + Assert.Equal("prod/resources/pods", LayoutSettings.ContextTable("prod", "resources/pods")); + } + + [Fact] + public void Save_round_trips_per_context_sort_and_filters() { + 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.Layout.SetFilters("homelab", "resources/pods", new Dictionary { + ["Namespace"] = new() { Excluded = ["default"] } + }); + cfg.Layout.SetSort("homelab", "resources/pods", new SavedColumnSort { + Header = "Age", + Direction = "Descending" + }); + service.Save(cfg); + + var reloaded = new ConfigurationFileService(path); + Assert.Equal(["default"], reloaded.Current.Layout.FilterFor("homelab", "resources/pods", "Namespace")?.Excluded); + Assert.Null(reloaded.Current.Layout.FilterFor("dev", "resources/pods", "Namespace")); + var sort = reloaded.Current.Layout.SortFor("homelab", "resources/pods"); + Assert.NotNull(sort); + Assert.Equal("Age", sort.Header); + Assert.Equal("Descending", sort.Direction); + Assert.Contains("\"Tables\"", File.ReadAllText(path), StringComparison.Ordinal); + Assert.DoesNotContain("\"ColumnSorts\"", File.ReadAllText(path), StringComparison.Ordinal); + } + finally { + File.Delete(path); + } + } + + [Fact] + public void Migrates_legacy_column_maps_into_tables() { + var path = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, """ + { + "Logging": { "LogLevel": { "Default": "Information" } }, + "Configuration": { + "Layout": { + "ColumnWidths": { "resources/pods": { "Name": 220 } }, + "ColumnFilters": { + "homelab/resources/pods": { + "Status": { "Text": "Crash", "Excluded": [] } + } + }, + "ColumnSorts": { + "homelab/resources/pods": { "Header": "Age", "Direction": "Descending" } + }, + "SearchByResource": { "resources/pods": "coredns" } + } + } + } + """); + + try { + var service = new ConfigurationFileService(path); + var layout = service.Current.Layout; + Assert.Equal(220, layout.ColumnsFor("resources/pods")!["Name"]); + Assert.Equal("Crash", layout.FilterFor("homelab", "resources/pods", "Status")?.Text); + Assert.Equal("Age", layout.SortFor("homelab", "resources/pods")?.Header); + Assert.Equal("coredns", layout.SearchFor("pods")); + + service.Save(service.Current); + var json = File.ReadAllText(path); + Assert.Contains("\"Tables\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"ColumnWidths\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"ColumnFilters\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"ColumnSorts\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"SearchByResource\"", json, StringComparison.Ordinal); + + var reloaded = new ConfigurationFileService(path); + Assert.Equal("Crash", reloaded.Current.Layout.FilterFor("homelab", "resources/pods", "Status")?.Text); + Assert.Equal("coredns", reloaded.Current.Layout.SearchFor("pods")); + } + finally { + File.Delete(path); + } + } } diff --git a/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs b/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs index ce49a6d..ede6c91 100644 --- a/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs +++ b/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using Avalonia; using Avalonia.Controls; using Avalonia.Threading; @@ -9,16 +10,23 @@ namespace MaksIT.ClusterConsole.UI; internal sealed class LayoutPersistence { private readonly Window _window; private readonly ConfigurationFileService _configuration; + private readonly Func _contextName; private readonly Func _resourceTableId; private readonly DispatcherTimer _saveTimer; private readonly Dictionary> _tables = []; - private bool _applying; + private int _applyDepth; + private int _restoreSortPending; private bool _attached; private string? _lastSaved; - public LayoutPersistence(Window window, ConfigurationFileService configuration, Func resourceTableId) { + public LayoutPersistence( + Window window, + ConfigurationFileService configuration, + Func contextName, + Func resourceTableId) { _window = window; _configuration = configuration; + _contextName = contextName; _resourceTableId = resourceTableId; _saveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; _saveTimer.Tick += (_, _) => { @@ -53,29 +61,40 @@ internal sealed class LayoutPersistence { }; } - public void ApplyResourceColumns(DataGrid grid) => - ApplyColumnWidths(grid, LayoutSettings.ResourceTable(_resourceTableId())); + public IDisposable SuspendSave() { + _applyDepth++; + return new ApplyScope(this); + } + + public void RestoreTables() { + using (SuspendSave()) { + foreach (var (grid, key) in _tables) + ApplyColumnState(grid, key()); + } + } public void ScheduleSave() { - if (_applying) + if (_applyDepth > 0 || _restoreSortPending > 0) return; _saveTimer.Stop(); _saveTimer.Start(); } public void SaveNow() { - if (_applying) + if (_applyDepth > 0) return; var cfg = _configuration.Current; cfg.EnsureDefaults(); var layout = cfg.Layout; + var context = _contextName(); CaptureWindow(layout); CapturePanes(layout); foreach (var (grid, key) in _tables) { + var tableKey = key(); var widths = ReadColumnWidths(grid); if (widths.Count > 0) - layout.SetColumns(key(), widths); + layout.SetColumns(context, tableKey, widths); } var snapshot = JsonSnapshot(layout); @@ -87,16 +106,12 @@ internal sealed class LayoutPersistence { } private void Apply() { - _applying = true; - try { + using (SuspendSave()) { var layout = _configuration.Current.Layout; ApplyWindow(layout); ApplyPanes(layout); foreach (var (grid, key) in _tables) - ApplyColumnWidths(grid, key()); - } - finally { - _applying = false; + ApplyColumnState(grid, key()); } } @@ -111,6 +126,7 @@ internal sealed class LayoutPersistence { return; _tables[grid] = key; grid.LayoutUpdated += (_, _) => ScheduleSave(); + grid.Sorting += (_, e) => PersistSort(grid, e.Column); } private void ApplyWindow(LayoutSettings layout) { @@ -179,8 +195,13 @@ internal sealed class LayoutPersistence { grid.ColumnDefinitions[index].Width = new GridLength(width); } + private void ApplyColumnState(DataGrid grid, string tableKey) { + ApplyColumnWidths(grid, tableKey); + ApplyColumnSort(grid, tableKey); + } + private void ApplyColumnWidths(DataGrid grid, string tableKey) { - var saved = _configuration.Current.Layout.ColumnsFor(tableKey); + var saved = _configuration.Current.Layout.ColumnsFor(_contextName(), tableKey); if (saved is null) return; @@ -192,6 +213,61 @@ internal sealed class LayoutPersistence { } } + private void ApplyColumnSort(DataGrid grid, string tableKey) { + var saved = _configuration.Current.Layout.SortFor(_contextName(), tableKey); + if (saved is null) + return; + if (!Enum.TryParse(saved.Direction, true, out var direction)) + direction = ListSortDirection.Ascending; + + DataGridColumn? column = null; + foreach (var candidate in grid.Columns) { + if (!string.Equals(ColumnKey(candidate), saved.Header, StringComparison.Ordinal)) + continue; + column = candidate; + break; + } + + if (column is null) + return; + + _restoreSortPending++; + Dispatcher.UIThread.Post(() => { + column.Sort(direction); + Dispatcher.UIThread.Post(() => { + if (_restoreSortPending > 0) + _restoreSortPending--; + }, DispatcherPriority.Background); + }, DispatcherPriority.Loaded); + } + + private void PersistSort(DataGrid grid, DataGridColumn column) { + if (_applyDepth > 0 || _restoreSortPending > 0) + return; + if (!_tables.TryGetValue(grid, out var key)) + return; + var header = ColumnKey(column); + if (header is null) + return; + + var context = _contextName(); + var tableKey = key(); + var previous = _configuration.Current.Layout.SortFor(context, tableKey); + var direction = ListSortDirection.Ascending; + if (previous is not null + && string.Equals(previous.Header, header, StringComparison.Ordinal) + && string.Equals(previous.Direction, nameof(ListSortDirection.Ascending), StringComparison.OrdinalIgnoreCase)) + direction = ListSortDirection.Descending; + + var cfg = _configuration.Current; + cfg.EnsureDefaults(); + cfg.Layout.SetSort(context, tableKey, new SavedColumnSort { + Header = header, + Direction = direction.ToString() + }); + ScheduleSave(); + } + private static Dictionary ReadColumnWidths(DataGrid grid) { var widths = new Dictionary(StringComparer.Ordinal); foreach (var column in grid.Columns) { @@ -220,4 +296,13 @@ internal sealed class LayoutPersistence { private static string JsonSnapshot(LayoutSettings layout) => System.Text.Json.JsonSerializer.Serialize(layout); + + private void ReleaseApply() { + if (_applyDepth > 0) + _applyDepth--; + } + + private sealed class ApplyScope(LayoutPersistence owner) : IDisposable { + public void Dispose() => owner.ReleaseApply(); + } } diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs index 7d96262..60d349a 100644 --- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs +++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs @@ -25,7 +25,11 @@ public partial class MainWindow : Window { public MainWindow(MainViewModel viewModel, ConfigurationFileService configuration) : this() { DataContext = viewModel; - _layout = new LayoutPersistence(this, configuration, () => viewModel.SelectedDescriptor?.Id); + _layout = new LayoutPersistence( + this, + configuration, + () => viewModel.ActivePage?.Name, + () => viewModel.SelectedDescriptor?.Id); Opened += (_, _) => { _layout.Attach(); RebuildColumns(viewModel); @@ -70,16 +74,19 @@ public partial class MainWindow : Window { if (grid is null) return; - grid.Columns.Clear(); - var descriptor = viewModel.SelectedDescriptor; - var headers = descriptor?.Columns.Select(c => c.Header).ToList() - ?? ["Name", "Namespace", "Age"]; + using (_layout?.SuspendSave()) { + viewModel.ActivePage?.ReloadColumnFilters(); + grid.Columns.Clear(); + var descriptor = viewModel.SelectedDescriptor; + var headers = descriptor?.Columns.Select(c => c.Header).ToList() + ?? ["Name", "Namespace", "Age"]; - foreach (var header in headers) { - grid.Columns.Add(CreateColumn(header, viewModel.ActivePage)); + foreach (var header in headers) { + grid.Columns.Add(CreateColumn(header, viewModel.ActivePage)); + } + + _layout?.RestoreTables(); } - - _layout?.ApplyResourceColumns(grid); } private DataGridColumn CreateColumn(string header, ClusterPageViewModel? page) { diff --git a/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs b/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs index 578f719..fc1de17 100644 --- a/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs +++ b/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs @@ -376,7 +376,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable { var nav = items.FirstOrDefault(i => i.Id == savedId) ?? items.FirstOrDefault(i => i.Id == "pods") ?? items.FirstOrDefault(); - Filter = _configuration.Current.Layout.SearchFor(nav?.Id); + Filter = _configuration.Current.Layout.SearchFor(Name, nav?.Id); SelectedNavItem = nav; } finally { @@ -408,15 +408,23 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable { public ColumnFilterViewModel FilterFor(string header) { if (!_columnFilters.TryGetValue(header, out var filter)) { filter = new ColumnFilterViewModel(header, () => OnColumnFilterChanged(header)); - var saved = _configuration.Current.Layout.FilterFor(TableKey(), header); - if (saved is not null) - filter.Restore(saved); _columnFilters[header] = filter; + RestoreColumnFilter(filter); } return filter; } + public void ReloadColumnFilters() { + foreach (var filter in _columnFilters.Values) + RestoreColumnFilter(filter); + } + + private void RestoreColumnFilter(ColumnFilterViewModel filter) { + var saved = _configuration.Current.Layout.FilterFor(Name, TableKey(), filter.Header); + filter.Restore(saved ?? new SavedColumnFilter()); + } + private void OnColumnFilterChanged(string header) { ApplyColumnFilters(); if (header == "Namespace") @@ -435,7 +443,14 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable { desired.Add(row); } - CollectionSync.MergeByKey(Rows, desired, row => row.Uid, static (current, incoming) => current.CopyFrom(incoming)); + SortRows(desired); + + CollectionSync.MergeByKey( + Rows, + desired, + row => row.Uid, + static (current, incoming) => current.CopyFrom(incoming), + matchSourceOrder: true); if (SelectedRow is null || !Rows.Contains(SelectedRow)) SelectedRow = keepUid is null ? null : Rows.FirstOrDefault(row => row.Uid == keepUid); @@ -466,7 +481,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable { var cfg = _configuration.Current; cfg.Layout.SelectedNavId = value?.Id; _configuration.Save(cfg); - var savedSearch = cfg.Layout.SearchFor(value?.Id); + var savedSearch = cfg.Layout.SearchFor(Name, value?.Id); if (Filter != savedSearch) { _syncingLayout = true; Filter = savedSearch; @@ -486,7 +501,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable { partial void OnFilterChanged(string value) { if (!_syncingLayout) { var cfg = _configuration.Current; - cfg.Layout.SetSearch(SelectedNavItem?.Id, value); + cfg.Layout.SetSearch(Name, SelectedNavItem?.Id, value); _configuration.Save(cfg); } @@ -1030,10 +1045,23 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable { map[header] = filter.Snapshot(); var cfg = _configuration.Current; - cfg.Layout.SetFilters(TableKey(), map); + cfg.Layout.SetFilters(Name, TableKey(), map); _configuration.Save(cfg); } + private void SortRows(List rows) { + var sort = _configuration.Current.Layout.SortFor(Name, TableKey()); + if (sort is null) + return; + + var comparer = new ResourceRowComparer(sort.Header); + var descending = string.Equals(sort.Direction, "Descending", StringComparison.OrdinalIgnoreCase); + rows.Sort((left, right) => { + var cmp = comparer.Compare(left, right); + return descending ? -cmp : cmp; + }); + } + private static string NormalizeNamespace(string? value) => string.IsNullOrWhiteSpace(value) ? Configuration.AllNamespaces : value;