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]
|
## [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
|
## [0.3.0] - 2026-08-20
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@ -32,7 +32,7 @@ Capabilities that are first-class in ClusterConsole, not afterthoughts:
|
|||||||
|
|
||||||
- **Contexts** — catalog of kubeconfig contexts; radio selects kubectl current-context
|
- **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
|
- **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
|
- **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
|
- **Workloads** — scale, restart, CronJob trigger; node cordon and drain
|
||||||
- **Pods** — follow logs, exec
|
- **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` |
|
| `OllamaEndpoint` | Chat API, default `http://127.0.0.1:11434` |
|
||||||
| `OllamaModel` | Chat model, default `qwen3:8b` |
|
| `OllamaModel` | Chat model, default `qwen3:8b` |
|
||||||
| `PortForwards` | Enabled localhost forwards; restored when the cluster reconnects |
|
| `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.
|
Port-forwards are saved when you start them in the UI. Chat cannot apply, restart, or delete.
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Version>0.3.0</Version>
|
<Version>0.4.0</Version>
|
||||||
<Product>MaksIT.ClusterConsole</Product>
|
<Product>MaksIT.ClusterConsole</Product>
|
||||||
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
|
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@ -5,7 +5,8 @@ public static class CollectionSync {
|
|||||||
IList<T> target,
|
IList<T> target,
|
||||||
IReadOnlyList<T> source,
|
IReadOnlyList<T> source,
|
||||||
Func<T, TKey> keySelector,
|
Func<T, TKey> keySelector,
|
||||||
Action<T, T>? copy = null)
|
Action<T, T>? copy = null,
|
||||||
|
bool matchSourceOrder = false)
|
||||||
where TKey : notnull {
|
where TKey : notnull {
|
||||||
ArgumentNullException.ThrowIfNull(target);
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@ -48,5 +49,22 @@ public static class CollectionSync {
|
|||||||
if (index >= 0)
|
if (index >= 0)
|
||||||
target[index] = item;
|
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);
|
NamespacesByContext ??= new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
NavigatorExpanded ??= new Dictionary<string, bool>(StringComparer.Ordinal);
|
NavigatorExpanded ??= new Dictionary<string, bool>(StringComparer.Ordinal);
|
||||||
Layout ??= new LayoutSettings();
|
Layout ??= new LayoutSettings();
|
||||||
Layout.ColumnWidths ??= new Dictionary<string, Dictionary<string, double>>(StringComparer.Ordinal);
|
Layout.Normalize();
|
||||||
Layout.ColumnFilters ??= new Dictionary<string, Dictionary<string, SavedColumnFilter>>(StringComparer.Ordinal);
|
|
||||||
Layout.SearchByResource ??= new Dictionary<string, string>(StringComparer.Ordinal);
|
|
||||||
PortForwards ??= [];
|
PortForwards ??= [];
|
||||||
if (string.IsNullOrWhiteSpace(OllamaEndpoint))
|
if (string.IsNullOrWhiteSpace(OllamaEndpoint))
|
||||||
OllamaEndpoint = ClusterChatService.DefaultEndpoint;
|
OllamaEndpoint = ClusterChatService.DefaultEndpoint;
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
|
||||||
namespace MaksIT.ClusterConsole.Shared;
|
namespace MaksIT.ClusterConsole.Shared;
|
||||||
|
|
||||||
public sealed class SavedColumnFilter {
|
public sealed class SavedColumnFilter {
|
||||||
@ -6,6 +9,22 @@ public sealed class SavedColumnFilter {
|
|||||||
public List<string> Excluded { get; set; } = [];
|
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 sealed class LayoutSettings {
|
||||||
public const string OverviewWarningsTable = "overview-warnings";
|
public const string OverviewWarningsTable = "overview-warnings";
|
||||||
public const string OverviewErrorsTable = "overview-errors";
|
public const string OverviewErrorsTable = "overview-errors";
|
||||||
@ -30,48 +49,166 @@ public sealed class LayoutSettings {
|
|||||||
|
|
||||||
public string? SelectedNavId { get; set; }
|
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) =>
|
public static string ResourceTable(string? resourceId) =>
|
||||||
string.IsNullOrWhiteSpace(resourceId) ? "resources" : $"resources/{resourceId}";
|
string.IsNullOrWhiteSpace(resourceId) ? "resources" : $"resources/{resourceId}";
|
||||||
|
|
||||||
public IReadOnlyDictionary<string, double>? ColumnsFor(string tableKey) {
|
public static string ContextTable(string? context, string tableKey) =>
|
||||||
if (ColumnWidths is not null && ColumnWidths.TryGetValue(tableKey, out var widths) && widths.Count > 0)
|
string.IsNullOrWhiteSpace(context) ? tableKey : $"{context}/{tableKey}";
|
||||||
return widths;
|
|
||||||
return null;
|
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) {
|
public IReadOnlyDictionary<string, double>? ColumnsFor(string tableKey) =>
|
||||||
ColumnWidths ??= new Dictionary<string, Dictionary<string, double>>(StringComparer.Ordinal);
|
ColumnsFor(null, tableKey);
|
||||||
ColumnWidths[tableKey] = widths;
|
|
||||||
|
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) {
|
public void SetColumns(string tableKey, Dictionary<string, double> widths) =>
|
||||||
if (ColumnFilters is not null
|
SetColumns(null, tableKey, widths);
|
||||||
&& ColumnFilters.TryGetValue(tableKey, out var filters)
|
|
||||||
&& filters.TryGetValue(header, out var filter))
|
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 filter;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetFilters(string tableKey, Dictionary<string, SavedColumnFilter> filters) {
|
public void SetFilters(string tableKey, Dictionary<string, SavedColumnFilter> filters) =>
|
||||||
ColumnFilters ??= new Dictionary<string, Dictionary<string, SavedColumnFilter>>(StringComparer.Ordinal);
|
SetFilters(null, tableKey, filters);
|
||||||
ColumnFilters[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) {
|
public void SetSort(string tableKey, SavedColumnSort? sort) =>
|
||||||
var key = ResourceTable(resourceId);
|
SetSort(null, tableKey, sort);
|
||||||
if (SearchByResource is not null && SearchByResource.TryGetValue(key, out var text))
|
|
||||||
return text ?? "";
|
public void SetSort(string? context, string tableKey, SavedColumnSort? sort) {
|
||||||
return "";
|
var table = GetOrAdd(context, tableKey);
|
||||||
|
table.Sort = sort is null || string.IsNullOrWhiteSpace(sort.Header) ? null : sort;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetSearch(string? resourceId, string text) {
|
public string SearchFor(string? resourceId) =>
|
||||||
SearchByResource ??= new Dictionary<string, string>(StringComparer.Ordinal);
|
SearchFor(null, resourceId);
|
||||||
SearchByResource[ResourceTable(resourceId)] = text ?? "";
|
|
||||||
|
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,
|
"NavigatorWidth": 228,
|
||||||
"DetailsWidth": 380,
|
"DetailsWidth": 380,
|
||||||
"SelectedNavId": "pods",
|
"SelectedNavId": "pods",
|
||||||
"ColumnWidths": {},
|
"Tables": {}
|
||||||
"ColumnFilters": {},
|
|
||||||
"SearchByResource": {}
|
|
||||||
},
|
},
|
||||||
"PortForwards": []
|
"PortForwards": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,24 @@ public class CollectionSyncTests {
|
|||||||
Assert.Equal("c", target[1].Key);
|
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]
|
[Fact]
|
||||||
public void MergeByKey_replaces_records_without_reset() {
|
public void MergeByKey_replaces_records_without_reset() {
|
||||||
var target = new ObservableCollection<ClusterIssue> {
|
var target = new ObservableCollection<ClusterIssue> {
|
||||||
|
|||||||
@ -289,4 +289,114 @@ public class ConfigurationFileServiceTests {
|
|||||||
File.Delete(path);
|
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;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
@ -9,16 +10,23 @@ namespace MaksIT.ClusterConsole.UI;
|
|||||||
internal sealed class LayoutPersistence {
|
internal sealed class LayoutPersistence {
|
||||||
private readonly Window _window;
|
private readonly Window _window;
|
||||||
private readonly ConfigurationFileService _configuration;
|
private readonly ConfigurationFileService _configuration;
|
||||||
|
private readonly Func<string?> _contextName;
|
||||||
private readonly Func<string?> _resourceTableId;
|
private readonly Func<string?> _resourceTableId;
|
||||||
private readonly DispatcherTimer _saveTimer;
|
private readonly DispatcherTimer _saveTimer;
|
||||||
private readonly Dictionary<DataGrid, Func<string>> _tables = [];
|
private readonly Dictionary<DataGrid, Func<string>> _tables = [];
|
||||||
private bool _applying;
|
private int _applyDepth;
|
||||||
|
private int _restoreSortPending;
|
||||||
private bool _attached;
|
private bool _attached;
|
||||||
private string? _lastSaved;
|
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;
|
_window = window;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
|
_contextName = contextName;
|
||||||
_resourceTableId = resourceTableId;
|
_resourceTableId = resourceTableId;
|
||||||
_saveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
_saveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
||||||
_saveTimer.Tick += (_, _) => {
|
_saveTimer.Tick += (_, _) => {
|
||||||
@ -53,29 +61,40 @@ internal sealed class LayoutPersistence {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ApplyResourceColumns(DataGrid grid) =>
|
public IDisposable SuspendSave() {
|
||||||
ApplyColumnWidths(grid, LayoutSettings.ResourceTable(_resourceTableId()));
|
_applyDepth++;
|
||||||
|
return new ApplyScope(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RestoreTables() {
|
||||||
|
using (SuspendSave()) {
|
||||||
|
foreach (var (grid, key) in _tables)
|
||||||
|
ApplyColumnState(grid, key());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void ScheduleSave() {
|
public void ScheduleSave() {
|
||||||
if (_applying)
|
if (_applyDepth > 0 || _restoreSortPending > 0)
|
||||||
return;
|
return;
|
||||||
_saveTimer.Stop();
|
_saveTimer.Stop();
|
||||||
_saveTimer.Start();
|
_saveTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SaveNow() {
|
public void SaveNow() {
|
||||||
if (_applying)
|
if (_applyDepth > 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var cfg = _configuration.Current;
|
var cfg = _configuration.Current;
|
||||||
cfg.EnsureDefaults();
|
cfg.EnsureDefaults();
|
||||||
var layout = cfg.Layout;
|
var layout = cfg.Layout;
|
||||||
|
var context = _contextName();
|
||||||
CaptureWindow(layout);
|
CaptureWindow(layout);
|
||||||
CapturePanes(layout);
|
CapturePanes(layout);
|
||||||
foreach (var (grid, key) in _tables) {
|
foreach (var (grid, key) in _tables) {
|
||||||
|
var tableKey = key();
|
||||||
var widths = ReadColumnWidths(grid);
|
var widths = ReadColumnWidths(grid);
|
||||||
if (widths.Count > 0)
|
if (widths.Count > 0)
|
||||||
layout.SetColumns(key(), widths);
|
layout.SetColumns(context, tableKey, widths);
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshot = JsonSnapshot(layout);
|
var snapshot = JsonSnapshot(layout);
|
||||||
@ -87,16 +106,12 @@ internal sealed class LayoutPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void Apply() {
|
private void Apply() {
|
||||||
_applying = true;
|
using (SuspendSave()) {
|
||||||
try {
|
|
||||||
var layout = _configuration.Current.Layout;
|
var layout = _configuration.Current.Layout;
|
||||||
ApplyWindow(layout);
|
ApplyWindow(layout);
|
||||||
ApplyPanes(layout);
|
ApplyPanes(layout);
|
||||||
foreach (var (grid, key) in _tables)
|
foreach (var (grid, key) in _tables)
|
||||||
ApplyColumnWidths(grid, key());
|
ApplyColumnState(grid, key());
|
||||||
}
|
|
||||||
finally {
|
|
||||||
_applying = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,6 +126,7 @@ internal sealed class LayoutPersistence {
|
|||||||
return;
|
return;
|
||||||
_tables[grid] = key;
|
_tables[grid] = key;
|
||||||
grid.LayoutUpdated += (_, _) => ScheduleSave();
|
grid.LayoutUpdated += (_, _) => ScheduleSave();
|
||||||
|
grid.Sorting += (_, e) => PersistSort(grid, e.Column);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyWindow(LayoutSettings layout) {
|
private void ApplyWindow(LayoutSettings layout) {
|
||||||
@ -179,8 +195,13 @@ internal sealed class LayoutPersistence {
|
|||||||
grid.ColumnDefinitions[index].Width = new GridLength(width);
|
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) {
|
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)
|
if (saved is null)
|
||||||
return;
|
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) {
|
private static Dictionary<string, double> ReadColumnWidths(DataGrid grid) {
|
||||||
var widths = new Dictionary<string, double>(StringComparer.Ordinal);
|
var widths = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||||
foreach (var column in grid.Columns) {
|
foreach (var column in grid.Columns) {
|
||||||
@ -220,4 +296,13 @@ internal sealed class LayoutPersistence {
|
|||||||
|
|
||||||
private static string JsonSnapshot(LayoutSettings layout) =>
|
private static string JsonSnapshot(LayoutSettings layout) =>
|
||||||
System.Text.Json.JsonSerializer.Serialize(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() {
|
public MainWindow(MainViewModel viewModel, ConfigurationFileService configuration) : this() {
|
||||||
DataContext = viewModel;
|
DataContext = viewModel;
|
||||||
_layout = new LayoutPersistence(this, configuration, () => viewModel.SelectedDescriptor?.Id);
|
_layout = new LayoutPersistence(
|
||||||
|
this,
|
||||||
|
configuration,
|
||||||
|
() => viewModel.ActivePage?.Name,
|
||||||
|
() => viewModel.SelectedDescriptor?.Id);
|
||||||
Opened += (_, _) => {
|
Opened += (_, _) => {
|
||||||
_layout.Attach();
|
_layout.Attach();
|
||||||
RebuildColumns(viewModel);
|
RebuildColumns(viewModel);
|
||||||
@ -70,6 +74,8 @@ public partial class MainWindow : Window {
|
|||||||
if (grid is null)
|
if (grid is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
using (_layout?.SuspendSave()) {
|
||||||
|
viewModel.ActivePage?.ReloadColumnFilters();
|
||||||
grid.Columns.Clear();
|
grid.Columns.Clear();
|
||||||
var descriptor = viewModel.SelectedDescriptor;
|
var descriptor = viewModel.SelectedDescriptor;
|
||||||
var headers = descriptor?.Columns.Select(c => c.Header).ToList()
|
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));
|
grid.Columns.Add(CreateColumn(header, viewModel.ActivePage));
|
||||||
}
|
}
|
||||||
|
|
||||||
_layout?.ApplyResourceColumns(grid);
|
_layout?.RestoreTables();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private DataGridColumn CreateColumn(string header, ClusterPageViewModel? page) {
|
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)
|
var nav = items.FirstOrDefault(i => i.Id == savedId)
|
||||||
?? items.FirstOrDefault(i => i.Id == "pods")
|
?? items.FirstOrDefault(i => i.Id == "pods")
|
||||||
?? items.FirstOrDefault();
|
?? items.FirstOrDefault();
|
||||||
Filter = _configuration.Current.Layout.SearchFor(nav?.Id);
|
Filter = _configuration.Current.Layout.SearchFor(Name, nav?.Id);
|
||||||
SelectedNavItem = nav;
|
SelectedNavItem = nav;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@ -408,15 +408,23 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
public ColumnFilterViewModel FilterFor(string header) {
|
public ColumnFilterViewModel FilterFor(string header) {
|
||||||
if (!_columnFilters.TryGetValue(header, out var filter)) {
|
if (!_columnFilters.TryGetValue(header, out var filter)) {
|
||||||
filter = new ColumnFilterViewModel(header, () => OnColumnFilterChanged(header));
|
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;
|
_columnFilters[header] = filter;
|
||||||
|
RestoreColumnFilter(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 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) {
|
private void OnColumnFilterChanged(string header) {
|
||||||
ApplyColumnFilters();
|
ApplyColumnFilters();
|
||||||
if (header == "Namespace")
|
if (header == "Namespace")
|
||||||
@ -435,7 +443,14 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
desired.Add(row);
|
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))
|
if (SelectedRow is null || !Rows.Contains(SelectedRow))
|
||||||
SelectedRow = keepUid is null ? null : Rows.FirstOrDefault(row => row.Uid == keepUid);
|
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;
|
var cfg = _configuration.Current;
|
||||||
cfg.Layout.SelectedNavId = value?.Id;
|
cfg.Layout.SelectedNavId = value?.Id;
|
||||||
_configuration.Save(cfg);
|
_configuration.Save(cfg);
|
||||||
var savedSearch = cfg.Layout.SearchFor(value?.Id);
|
var savedSearch = cfg.Layout.SearchFor(Name, value?.Id);
|
||||||
if (Filter != savedSearch) {
|
if (Filter != savedSearch) {
|
||||||
_syncingLayout = true;
|
_syncingLayout = true;
|
||||||
Filter = savedSearch;
|
Filter = savedSearch;
|
||||||
@ -486,7 +501,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
partial void OnFilterChanged(string value) {
|
partial void OnFilterChanged(string value) {
|
||||||
if (!_syncingLayout) {
|
if (!_syncingLayout) {
|
||||||
var cfg = _configuration.Current;
|
var cfg = _configuration.Current;
|
||||||
cfg.Layout.SetSearch(SelectedNavItem?.Id, value);
|
cfg.Layout.SetSearch(Name, SelectedNavItem?.Id, value);
|
||||||
_configuration.Save(cfg);
|
_configuration.Save(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1030,10 +1045,23 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
|||||||
map[header] = filter.Snapshot();
|
map[header] = filter.Snapshot();
|
||||||
|
|
||||||
var cfg = _configuration.Current;
|
var cfg = _configuration.Current;
|
||||||
cfg.Layout.SetFilters(TableKey(), map);
|
cfg.Layout.SetFilters(Name, TableKey(), map);
|
||||||
_configuration.Save(cfg);
|
_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) =>
|
private static string NormalizeNamespace(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? Configuration.AllNamespaces : value;
|
string.IsNullOrWhiteSpace(value) ? Configuration.AllNamespaces : value;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user