diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae2b3d..ce9c326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.6.0] - 2026-08-30 + +### Fixed + +- Footer actions that can run independently now apply to every selected table row (Restart, Scale, Delete, Force delete, Force delete namespace, Cordon, Uncordon, Drain, Trigger, Stop port-forward), not only the current row. + +### Changed + +- Dark UI uses the MAKS.IT origami blues (`#33A5CF` highlight, `#006199` accent). Window/installer/Flatpak icon is a faceted cluster of nodes with a console chevron (not the brand M). GitHub release assets are siblings: portable `maksit-cluster-console-{version}.zip` (win-x64 only), Windows setup `maksit-cluster-console-{version}.exe`, and `maksit-cluster-console-{version}.flatpak`. The installer and Flatpak are not packed inside the zip. On Windows the Flatpak bundle is built via WSL Debian. + ## [0.5.1] - 2026-08-24 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ac4c6c..072f0e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ Coverage shields in `README.md` are rewritten by **CoverageBadges**. 1. Update [CHANGELOG.md](CHANGELOG.md) and bump `` in [src/Directory.Build.props](src/Directory.Build.props). 2. Commit on `main`, tag `v{version}` on HEAD (`v1.2.3` or SemVer prerelease such as `v0.1.0-alpha.1`, `v0.1.0-beta.1`, `v0.1.0-rc.1`). GitHub marks hyphenated versions as prerelease. -3. Run `utils\Invoke-ReleasePackage.bat`. +3. Run `utils\Invoke-ReleasePackage.bat`. GitHub assets are the portable zip (win-x64), Windows setup exe, and Flatpak. ## Commit format diff --git a/README.md b/README.md index bd30eda..7985b9f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # MaksIT.ClusterConsole -![Line Coverage](https://img.shields.io/badge/Line%20Coverage-54.2%25-yellowgreen) -![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.7%25-yellowgreen) -![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.1%25-green) +![Line Coverage](https://img.shields.io/badge/Line%20Coverage-54.4%25-yellowgreen) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.8%25-yellowgreen) +![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.4%25-green) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![License](https://img.shields.io/badge/License-Apache%202.0-blue) ![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-0078D6) @@ -98,6 +98,8 @@ Tests use kubeconfig fixtures and do not require a live cluster. Coverage shield 2. Tag `v{version}` on `main`. 3. Run `utils\Invoke-ReleasePackage.bat`. +GitHub assets are siblings: portable `maksit-cluster-console-{version}.zip` (win-x64 only), Windows setup `maksit-cluster-console-{version}.exe`, and `maksit-cluster-console-{version}.flatpak`. The installer and Flatpak are not inside the zip. On Windows the Flatpak bundle is built via WSL Debian. + ## Solution layout ```text diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 65576d6..9aa0312 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.5.1 + 0.6.0 MaksIT.ClusterConsole MaksIT.ClusterConsole diff --git a/src/MaksIT.ClusterConsole.Shared/ResourceActionBatch.cs b/src/MaksIT.ClusterConsole.Shared/ResourceActionBatch.cs new file mode 100644 index 0000000..39689c9 --- /dev/null +++ b/src/MaksIT.ClusterConsole.Shared/ResourceActionBatch.cs @@ -0,0 +1,53 @@ +using MaksIT.Results; + + +namespace MaksIT.ClusterConsole.Shared; + +public readonly record struct ResourceActionBatchOutcome( + int Succeeded, + int Total, + IReadOnlyList Failures) { + public string Format(string oneDone, string manyDone) { + if (Total <= 0) + return ""; + if (Failures.Count == 0) + return Total == 1 ? oneDone : manyDone; + if (Succeeded == 0) + return string.Join("; ", Failures); + return $"{Succeeded} of {Total} succeeded. {string.Join("; ", Failures)}"; + } +} + +public static class ResourceActionBatch { + public static IReadOnlyList Targets( + IReadOnlyList selectedRows, + ResourceRow? selectedRow) { + if (selectedRows.Count > 0) + return selectedRows; + return selectedRow is null ? [] : [selectedRow]; + } + + public static string Label(ResourceRow row) => + string.IsNullOrEmpty(row.Namespace) ? row.Name : $"{row.Namespace}/{row.Name}"; + + public static async Task RunAsync( + IReadOnlyList rows, + Func> action) { + ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(action); + + var succeeded = 0; + var failures = new List(); + foreach (var row in rows) { + var result = await action(row); + if (result.IsSuccess) { + succeeded++; + continue; + } + + failures.Add($"{Label(row)}: {string.Join("; ", result.Messages)}"); + } + + return new ResourceActionBatchOutcome(succeeded, rows.Count, failures); + } +} diff --git a/src/MaksIT.ClusterConsole.Tests/ResourceActionBatchTests.cs b/src/MaksIT.ClusterConsole.Tests/ResourceActionBatchTests.cs new file mode 100644 index 0000000..dc6d01d --- /dev/null +++ b/src/MaksIT.ClusterConsole.Tests/ResourceActionBatchTests.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Nodes; +using MaksIT.ClusterConsole.Shared; +using MaksIT.Results; + + +namespace MaksIT.ClusterConsole.Tests; + +public class ResourceActionBatchTests { + [Fact] + public void Targets_falls_back_to_the_current_row() { + var current = Row("web"); + Assert.Equal([current], ResourceActionBatch.Targets([], current)); + } + + [Fact] + public void Targets_uses_every_selected_row() { + var first = Row("web-a"); + var second = Row("web-b"); + Assert.Equal([first, second], ResourceActionBatch.Targets([first, second], first)); + } + + [Fact] + public async Task RunAsync_runs_the_action_for_each_row() { + var names = new List(); + var outcome = await ResourceActionBatch.RunAsync( + [Row("web-a", "apps"), Row("web-b", "apps")], + row => { + names.Add(row.Name); + return Task.FromResult(Result.Ok()); + }); + + Assert.Equal(["web-a", "web-b"], names); + Assert.Equal(2, outcome.Succeeded); + Assert.Equal(2, outcome.Total); + Assert.Empty(outcome.Failures); + Assert.Equal("Restarted 2.", outcome.Format("Restarted.", "Restarted 2.")); + } + + [Fact] + public async Task RunAsync_keeps_going_after_a_failure() { + var outcome = await ResourceActionBatch.RunAsync( + [Row("web-a", "apps"), Row("web-b", "apps")], + row => Task.FromResult( + row.Name == "web-a" + ? Result.InternalServerError("forbidden") + : Result.Ok())); + + Assert.Equal(1, outcome.Succeeded); + Assert.Equal(["apps/web-a: forbidden"], outcome.Failures); + Assert.Equal( + "1 of 2 succeeded. apps/web-a: forbidden", + outcome.Format("Restarted.", "Restarted 2.")); + } + + [Fact] + public void Format_keeps_the_singular_status_for_one_row() { + var outcome = new ResourceActionBatchOutcome(1, 1, []); + Assert.Equal("Restarted.", outcome.Format("Restarted.", "Restarted 2.")); + } + + private static ResourceRow Row(string name, string? ns = null) => + new() { + Uid = name, + Name = name, + Namespace = ns, + Document = new JsonObject(), + Cells = new Dictionary { ["Name"] = name } + }; +} diff --git a/src/MaksIT.ClusterConsole.UI/Assets/icon.ico b/src/MaksIT.ClusterConsole.UI/Assets/icon.ico index fa5ec72..40be578 100644 Binary files a/src/MaksIT.ClusterConsole.UI/Assets/icon.ico and b/src/MaksIT.ClusterConsole.UI/Assets/icon.ico differ diff --git a/src/MaksIT.ClusterConsole.UI/Assets/icon.png b/src/MaksIT.ClusterConsole.UI/Assets/icon.png index eb5e63c..44f0426 100644 Binary files a/src/MaksIT.ClusterConsole.UI/Assets/icon.png and b/src/MaksIT.ClusterConsole.UI/Assets/icon.png differ diff --git a/src/MaksIT.ClusterConsole.UI/Assets/icon.svg b/src/MaksIT.ClusterConsole.UI/Assets/icon.svg index 3e88ed1..a082fae 100644 --- a/src/MaksIT.ClusterConsole.UI/Assets/icon.svg +++ b/src/MaksIT.ClusterConsole.UI/Assets/icon.svg @@ -1,12 +1,23 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml index 5fab99c..7e5bcd0 100644 --- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml +++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml @@ -547,11 +547,13 @@ diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs index c20715b..7e279f0 100644 --- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs +++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs @@ -19,7 +19,7 @@ namespace MaksIT.ClusterConsole.UI; public partial class MainWindow : Window { private LayoutPersistence? _layout; - private ClusterPageViewModel? _logsPage; + private ClusterPageViewModel? _activePage; public MainWindow() { InitializeComponent(); @@ -41,9 +41,9 @@ public partial class MainWindow : Window { RebuildColumns(viewModel); if (e.PropertyName == nameof(MainViewModel.ActivePage)) - HookLogsPage(viewModel.ActivePage); + HookActivePage(viewModel.ActivePage); }; - HookLogsPage(viewModel.ActivePage); + HookActivePage(viewModel.ActivePage); viewModel.ConnectionsRequested += async (_, _) => await OpenConnectionsAsync(viewModel); viewModel.VolumeFilesRequested += OpenVolumeFiles; } @@ -53,13 +53,17 @@ public partial class MainWindow : Window { window.Show(this); } - private void HookLogsPage(ClusterPageViewModel? page) { - if (_logsPage is not null) - _logsPage.PropertyChanged -= OnLogsPagePropertyChanged; + private void HookActivePage(ClusterPageViewModel? page) { + if (_activePage is not null) { + _activePage.PropertyChanged -= OnLogsPagePropertyChanged; + _activePage.SelectedRowsRestored -= OnSelectedRowsRestored; + } - _logsPage = page; - if (page is not null) + _activePage = page; + if (page is not null) { page.PropertyChanged += OnLogsPagePropertyChanged; + page.SelectedRowsRestored += OnSelectedRowsRestored; + } } private void OnLogsPagePropertyChanged(object? sender, PropertyChangedEventArgs e) { @@ -83,6 +87,39 @@ public partial class MainWindow : Window { await viewModel.ConnectNamedAsync(connect); } + private void OnResourceGridSelectionChanged(object? sender, SelectionChangedEventArgs e) { + if (DataContext is not MainViewModel { ActivePage: { } page }) + return; + if (page.SyncingSelection || sender is not DataGrid grid) + return; + + page.ReplaceSelectedRows(grid.SelectedItems.OfType()); + } + + private void OnSelectedRowsRestored(IReadOnlyList rows) { + var grid = this.FindControl("ResourceGrid"); + if (grid is null) + return; + + ApplyGridSelection(grid, rows, _activePage?.SelectedRow); + } + + private static void ApplyGridSelection(DataGrid grid, IReadOnlyList rows, ResourceRow? current) { + var wanted = rows.ToHashSet(); + for (var i = grid.SelectedItems.Count - 1; i >= 0; i--) { + if (grid.SelectedItems[i] is not ResourceRow row || !wanted.Contains(row)) + grid.SelectedItems.RemoveAt(i); + } + + foreach (var row in rows) { + if (!grid.SelectedItems.Contains(row)) + grid.SelectedItems.Add(row); + } + + if (current is not null && !ReferenceEquals(grid.SelectedItem, current)) + grid.SelectedItem = current; + } + private void OnResourceGridDoubleTapped(object? sender, TappedEventArgs e) { if (e.Source is not Control { DataContext: ResourceRow }) return; diff --git a/src/MaksIT.ClusterConsole.UI/Themes/Dark.axaml b/src/MaksIT.ClusterConsole.UI/Themes/Dark.axaml index eb8d56d..b390e59 100644 --- a/src/MaksIT.ClusterConsole.UI/Themes/Dark.axaml +++ b/src/MaksIT.ClusterConsole.UI/Themes/Dark.axaml @@ -2,20 +2,23 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:MaksIT.ClusterConsole.UI.Controls"> + #33A5CF + #006199 + #002A6A - - - - + + + + @@ -48,7 +51,7 @@