mirror of
https://github.com/MAKS-IT-COM/maksit-cluster-console.git
synced 2026-09-30 00:38:10 +02:00
(feature): persist per-cluster table filters and sort
This commit is contained in:
parent
bf857135b6
commit
a77e12a648
10
CHANGELOG.md
10
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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ??= "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,9 +19,7 @@
|
||||
"NavigatorWidth": 228,
|
||||
"DetailsWidth": 380,
|
||||
"SelectedNavId": "pods",
|
||||
"ColumnWidths": {},
|
||||
"ColumnFilters": {},
|
||||
"SearchByResource": {}
|
||||
"Tables": {}
|
||||
},
|
||||
"PortForwards": []
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user