(feature): persist per-cluster table filters and sort

This commit is contained in:
Maksym Sadovnychyy 2026-08-20 19:22:51 +02:00
parent bf857135b6
commit a77e12a648
12 changed files with 475 additions and 66 deletions

View File

@ -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

View File

@ -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.

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.3.0</Version>
<Version>0.4.0</Version>
<Product>MaksIT.ClusterConsole</Product>
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
</PropertyGroup>

View File

@ -5,7 +5,8 @@ public static class CollectionSync {
IList<T> target,
IReadOnlyList<T> source,
Func<T, TKey> keySelector,
Action<T, T>? copy = null)
Action<T, T>? 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<TKey, T>();
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);
}
}
}

View File

@ -28,9 +28,7 @@ public sealed class Configuration {
NamespacesByContext ??= new Dictionary<string, string>(StringComparer.Ordinal);
NavigatorExpanded ??= new Dictionary<string, bool>(StringComparer.Ordinal);
Layout ??= new LayoutSettings();
Layout.ColumnWidths ??= new Dictionary<string, Dictionary<string, double>>(StringComparer.Ordinal);
Layout.ColumnFilters ??= new Dictionary<string, Dictionary<string, SavedColumnFilter>>(StringComparer.Ordinal);
Layout.SearchByResource ??= new Dictionary<string, string>(StringComparer.Ordinal);
Layout.Normalize();
PortForwards ??= [];
if (string.IsNullOrWhiteSpace(OllamaEndpoint))
OllamaEndpoint = ClusterChatService.DefaultEndpoint;

View File

@ -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<string> Excluded { get; set; } = [];
}
public sealed class SavedColumnSort {
public string Header { get; set; } = "";
public string Direction { get; set; } = "Ascending";
}
public sealed class SavedTableLayout {
public Dictionary<string, double> Widths { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, SavedColumnFilter> 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<string, Dictionary<string, double>> ColumnWidths { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, SavedTableLayout> Tables { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, Dictionary<string, SavedColumnFilter>> ColumnFilters { get; set; } = new(StringComparer.Ordinal);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, Dictionary<string, double>>? ColumnWidths { get; set; }
public Dictionary<string, string> SearchByResource { get; set; } = new(StringComparer.Ordinal);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, Dictionary<string, SavedColumnFilter>>? ColumnFilters { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, SavedColumnSort>? ColumnSorts { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string>? SearchByResource { get; set; }
public static string ResourceTable(string? resourceId) =>
string.IsNullOrWhiteSpace(resourceId) ? "resources" : $"resources/{resourceId}";
public IReadOnlyDictionary<string, double>? 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<string, SavedTableLayout>(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<string, double> widths) {
ColumnWidths ??= new Dictionary<string, Dictionary<string, double>>(StringComparer.Ordinal);
ColumnWidths[tableKey] = widths;
public IReadOnlyDictionary<string, double>? ColumnsFor(string tableKey) =>
ColumnsFor(null, tableKey);
public IReadOnlyDictionary<string, double>? 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<string, double> widths) =>
SetColumns(null, tableKey, widths);
public void SetColumns(string? context, string tableKey, Dictionary<string, double> widths) =>
GetOrAdd(context, tableKey).Widths = new Dictionary<string, double>(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<string, SavedColumnFilter> filters) {
ColumnFilters ??= new Dictionary<string, Dictionary<string, SavedColumnFilter>>(StringComparer.Ordinal);
ColumnFilters[tableKey] = filters;
public void SetFilters(string tableKey, Dictionary<string, SavedColumnFilter> filters) =>
SetFilters(null, tableKey, filters);
public void SetFilters(string? context, string tableKey, Dictionary<string, SavedColumnFilter> filters) =>
GetOrAdd(context, tableKey).Filters = new Dictionary<string, SavedColumnFilter>(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<string, string>(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<string, SavedTableLayout>(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<string, double>(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<string, SavedColumnFilter>(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<string, double>(StringComparer.Ordinal);
table.Filters ??= new Dictionary<string, SavedColumnFilter>(StringComparer.Ordinal);
table.Search ??= "";
}
}

View File

@ -19,9 +19,7 @@
"NavigatorWidth": 228,
"DetailsWidth": 380,
"SelectedNavId": "pods",
"ColumnWidths": {},
"ColumnFilters": {},
"SearchByResource": {}
"Tables": {}
},
"PortForwards": []
}

View File

@ -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<KeyedItem> { 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<ClusterIssue> {

View File

@ -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<string, SavedColumnFilter> {
["Status"] = new() { Text = "legacy" }
});
layout.SetFilters("prod", "resources/pods", new Dictionary<string, SavedColumnFilter> {
["Status"] = new() { Text = "Crash" }
});
layout.SetSort("prod", "resources/pods", new SavedColumnSort {
Header = "Age",
Direction = "Descending"
});
layout.SetColumns("prod", "resources/pods", new Dictionary<string, double> { ["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<string, SavedColumnFilter> {
["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);
}
}
}

View File

@ -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<string?> _contextName;
private readonly Func<string?> _resourceTableId;
private readonly DispatcherTimer _saveTimer;
private readonly Dictionary<DataGrid, Func<string>> _tables = [];
private bool _applying;
private int _applyDepth;
private int _restoreSortPending;
private bool _attached;
private string? _lastSaved;
public LayoutPersistence(Window window, ConfigurationFileService configuration, Func<string?> resourceTableId) {
public LayoutPersistence(
Window window,
ConfigurationFileService configuration,
Func<string?> contextName,
Func<string?> 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<ListSortDirection>(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<string, double> ReadColumnWidths(DataGrid grid) {
var widths = new Dictionary<string, double>(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();
}
}

View File

@ -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,6 +74,8 @@ public partial class MainWindow : Window {
if (grid is null)
return;
using (_layout?.SuspendSave()) {
viewModel.ActivePage?.ReloadColumnFilters();
grid.Columns.Clear();
var descriptor = viewModel.SelectedDescriptor;
var headers = descriptor?.Columns.Select(c => c.Header).ToList()
@ -79,7 +85,8 @@ public partial class MainWindow : Window {
grid.Columns.Add(CreateColumn(header, viewModel.ActivePage));
}
_layout?.ApplyResourceColumns(grid);
_layout?.RestoreTables();
}
}
private DataGridColumn CreateColumn(string header, ClusterPageViewModel? page) {

View File

@ -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<ResourceRow> 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;