mirror of
https://github.com/MAKS-IT-COM/maksit-cluster-console.git
synced 2026-09-30 00:38:10 +02:00
(bugfix): refresh resource tables in place and stream logs follow
This commit is contained in:
parent
a77e12a648
commit
14952c50b9
@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.1] - 2026-08-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Staying on Pods (or any resource table) now picks up cluster changes on the automatic refresh. The grid previously only updated after leaving and returning to the view.
|
||||
- Logs **Follow** tails new pod lines instead of hanging on the snapshot request, and turning Follow off no longer races the live stream into the log text.
|
||||
|
||||
## [0.4.0] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
@ -26,7 +26,7 @@ Coverage shields in `README.md` are rewritten by **CoverageBadges**.
|
||||
### Release
|
||||
|
||||
1. Update [CHANGELOG.md](CHANGELOG.md) and bump `<Version>` in [src/Directory.Build.props](src/Directory.Build.props).
|
||||
2. Commit on `main`, tag `v{version}`.
|
||||
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`.
|
||||
|
||||
## Commit format
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# MaksIT.ClusterConsole
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.4.0</Version>
|
||||
<Version>0.4.1</Version>
|
||||
<Product>MaksIT.ClusterConsole</Product>
|
||||
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Net.Sockets;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Runtime.CompilerServices;
|
||||
using k8s;
|
||||
using k8s.Models;
|
||||
using MaksIT.Results;
|
||||
@ -347,8 +348,8 @@ public sealed class ClusterSession : IClusterSession {
|
||||
string podName,
|
||||
string @namespace,
|
||||
string? container,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) {
|
||||
var stream = await _client.CoreV1.ReadNamespacedPodLogAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default) {
|
||||
var response = await _client.CoreV1.ReadNamespacedPodLogWithHttpMessagesAsync(
|
||||
podName,
|
||||
@namespace,
|
||||
container: container,
|
||||
@ -356,9 +357,34 @@ public sealed class ClusterSession : IClusterSession {
|
||||
tailLines: 200,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try {
|
||||
var stream = response.Body;
|
||||
if (stream is null)
|
||||
yield break;
|
||||
|
||||
await foreach (var line in ReadLogLinesAsync(stream, cancellationToken).ConfigureAwait(false))
|
||||
yield return line;
|
||||
}
|
||||
finally {
|
||||
response.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal static async IAsyncEnumerable<string> ReadLogLinesAsync(
|
||||
Stream stream,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default) {
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
using var reader = new StreamReader(stream);
|
||||
using var registration = cancellationToken.Register(stream.Dispose);
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
string? line;
|
||||
try {
|
||||
line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is ObjectDisposedException or IOException or OperationCanceledException) {
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (line is null)
|
||||
yield break;
|
||||
|
||||
|
||||
39
src/MaksIT.ClusterConsole.Tests/PodLogsTests.cs
Normal file
39
src/MaksIT.ClusterConsole.Tests/PodLogsTests.cs
Normal file
@ -0,0 +1,39 @@
|
||||
using System.Text;
|
||||
using MaksIT.ClusterConsole.Client;
|
||||
|
||||
|
||||
namespace MaksIT.ClusterConsole.Tests;
|
||||
|
||||
public class PodLogsTests {
|
||||
[Fact]
|
||||
public async Task ReadLogLines_splits_on_newlines() {
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("a\nb\n"));
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in ClusterSession.ReadLogLinesAsync(stream, TestContext.Current.CancellationToken))
|
||||
lines.Add(line);
|
||||
|
||||
Assert.Equal(["a", "b"], lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadLogLines_keeps_a_final_line_without_newline() {
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("only"));
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in ClusterSession.ReadLogLinesAsync(stream, TestContext.Current.CancellationToken))
|
||||
lines.Add(line);
|
||||
|
||||
Assert.Equal(["only"], lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadLogLines_stops_when_cancelled() {
|
||||
using var stream = new MemoryStream();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in ClusterSession.ReadLogLinesAsync(stream, cts.Token))
|
||||
lines.Add(line);
|
||||
|
||||
Assert.Empty(lines);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ using System.ComponentModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using MaksIT.ClusterConsole.Shared;
|
||||
|
||||
|
||||
@ -14,6 +15,7 @@ internal sealed class LayoutPersistence {
|
||||
private readonly Func<string?> _resourceTableId;
|
||||
private readonly DispatcherTimer _saveTimer;
|
||||
private readonly Dictionary<DataGrid, Func<string>> _tables = [];
|
||||
private readonly Dictionary<DataGrid, PendingColumnSort> _pendingSorts = [];
|
||||
private int _applyDepth;
|
||||
private int _restoreSortPending;
|
||||
private bool _attached;
|
||||
@ -125,7 +127,10 @@ internal sealed class LayoutPersistence {
|
||||
if (grid is null)
|
||||
return;
|
||||
_tables[grid] = key;
|
||||
grid.LayoutUpdated += (_, _) => ScheduleSave();
|
||||
grid.LayoutUpdated += (_, _) => {
|
||||
TryApplyPendingSort(grid);
|
||||
ScheduleSave();
|
||||
};
|
||||
grid.Sorting += (_, e) => PersistSort(grid, e.Column);
|
||||
}
|
||||
|
||||
@ -215,30 +220,47 @@ internal sealed class LayoutPersistence {
|
||||
|
||||
private void ApplyColumnSort(DataGrid grid, string tableKey) {
|
||||
var saved = _configuration.Current.Layout.SortFor(_contextName(), tableKey);
|
||||
if (saved is null)
|
||||
if (saved is null) {
|
||||
_pendingSorts.Remove(grid);
|
||||
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;
|
||||
}
|
||||
_pendingSorts[grid] = new PendingColumnSort(saved.Header, direction);
|
||||
Dispatcher.UIThread.Post(() => TryApplyPendingSort(grid), DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
if (column is null)
|
||||
private void TryApplyPendingSort(DataGrid grid) {
|
||||
if (!_pendingSorts.TryGetValue(grid, out var pending))
|
||||
return;
|
||||
|
||||
var column = FindColumn(grid, pending.Header);
|
||||
if (column is null) {
|
||||
_pendingSorts.Remove(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort() NREs when the column is detached or the header has not been generated yet.
|
||||
if (!grid.IsAttachedToVisualTree() || !grid.IsEffectivelyVisible || column.ActualWidth <= 0)
|
||||
return;
|
||||
|
||||
_pendingSorts.Remove(grid);
|
||||
_restoreSortPending++;
|
||||
column.Sort(pending.Direction);
|
||||
Dispatcher.UIThread.Post(() => {
|
||||
column.Sort(direction);
|
||||
Dispatcher.UIThread.Post(() => {
|
||||
if (_restoreSortPending > 0)
|
||||
_restoreSortPending--;
|
||||
}, DispatcherPriority.Background);
|
||||
}, DispatcherPriority.Loaded);
|
||||
if (_restoreSortPending > 0)
|
||||
_restoreSortPending--;
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static DataGridColumn? FindColumn(DataGrid grid, string header) {
|
||||
foreach (var candidate in grid.Columns) {
|
||||
if (string.Equals(ColumnKey(candidate), header, StringComparison.Ordinal))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void PersistSort(DataGrid grid, DataGridColumn column) {
|
||||
@ -302,6 +324,8 @@ internal sealed class LayoutPersistence {
|
||||
_applyDepth--;
|
||||
}
|
||||
|
||||
private sealed record PendingColumnSort(string Header, ListSortDirection Direction);
|
||||
|
||||
private sealed class ApplyScope(LayoutPersistence owner) : IDisposable {
|
||||
public void Dispose() => owner.ReleaseApply();
|
||||
}
|
||||
|
||||
@ -670,7 +670,11 @@
|
||||
Margin="12,0,0,0"
|
||||
ToolTip.Tip="{Binding ActivePage.SelectedContainer.Display}" />
|
||||
</DockPanel>
|
||||
<TextBox Text="{Binding ActivePage.LogsText}" AcceptsReturn="True" IsReadOnly="True" FontFamily="Consolas, monospace" />
|
||||
<TextBox Name="LogsTextBox"
|
||||
Text="{Binding ActivePage.LogsText, Mode=OneWay}"
|
||||
AcceptsReturn="True"
|
||||
IsReadOnly="True"
|
||||
FontFamily="Consolas, monospace" />
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
<TabItem Header="Terminal" IsVisible="{Binding ActivePage.ShowTerminalTab}">
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
@ -18,6 +19,7 @@ namespace MaksIT.ClusterConsole.UI;
|
||||
|
||||
public partial class MainWindow : Window {
|
||||
private LayoutPersistence? _layout;
|
||||
private ClusterPageViewModel? _logsPage;
|
||||
|
||||
public MainWindow() {
|
||||
InitializeComponent();
|
||||
@ -37,7 +39,11 @@ public partial class MainWindow : Window {
|
||||
viewModel.PropertyChanged += (_, e) => {
|
||||
if (e.PropertyName is nameof(MainViewModel.SelectedNavItem) or nameof(MainViewModel.ActivePage))
|
||||
RebuildColumns(viewModel);
|
||||
|
||||
if (e.PropertyName == nameof(MainViewModel.ActivePage))
|
||||
HookLogsPage(viewModel.ActivePage);
|
||||
};
|
||||
HookLogsPage(viewModel.ActivePage);
|
||||
viewModel.ConnectionsRequested += async (_, _) => await OpenConnectionsAsync(viewModel);
|
||||
viewModel.VolumeFilesRequested += OpenVolumeFiles;
|
||||
}
|
||||
@ -47,6 +53,28 @@ public partial class MainWindow : Window {
|
||||
window.Show(this);
|
||||
}
|
||||
|
||||
private void HookLogsPage(ClusterPageViewModel? page) {
|
||||
if (_logsPage is not null)
|
||||
_logsPage.PropertyChanged -= OnLogsPagePropertyChanged;
|
||||
|
||||
_logsPage = page;
|
||||
if (page is not null)
|
||||
page.PropertyChanged += OnLogsPagePropertyChanged;
|
||||
}
|
||||
|
||||
private void OnLogsPagePropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName != nameof(ClusterPageViewModel.LogsText))
|
||||
return;
|
||||
if (sender is not ClusterPageViewModel { FollowLogs: true })
|
||||
return;
|
||||
|
||||
var box = this.FindControl<TextBox>("LogsTextBox");
|
||||
if (box is null)
|
||||
return;
|
||||
|
||||
box.CaretIndex = box.Text?.Length ?? 0;
|
||||
}
|
||||
|
||||
private async Task OpenConnectionsAsync(MainViewModel viewModel) {
|
||||
var window = new ConnectionsWindow(viewModel.CreateConnectionsViewModel());
|
||||
var connect = await window.ShowDialog<string?>(this);
|
||||
|
||||
@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json.Nodes;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MaksIT.Results;
|
||||
@ -17,7 +18,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
||||
private readonly ClusterChatService _chat;
|
||||
private readonly Action<string> _setStatus;
|
||||
private CancellationTokenSource? _logsCts;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private DispatcherTimer? _refreshTimer;
|
||||
private bool _refreshBusy;
|
||||
private int _logsGeneration;
|
||||
private bool _hasDaprCrd;
|
||||
private bool _syncingNamespace;
|
||||
private bool _syncingLayout;
|
||||
@ -388,13 +391,20 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
||||
}
|
||||
|
||||
public void PausePolling() =>
|
||||
_refreshCts?.Cancel();
|
||||
_refreshTimer?.Stop();
|
||||
|
||||
public void ResumePolling() =>
|
||||
StartRefreshLoop();
|
||||
public void ResumePolling() {
|
||||
_refreshTimer ??= CreateRefreshTimer();
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
PausePolling();
|
||||
if (_refreshTimer is not null) {
|
||||
_refreshTimer.Tick -= OnRefreshTimerTick;
|
||||
_refreshTimer = null;
|
||||
}
|
||||
|
||||
_logsCts?.Cancel();
|
||||
_chatCts?.Cancel();
|
||||
foreach (var row in LimitRows)
|
||||
@ -454,6 +464,12 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
||||
|
||||
if (SelectedRow is null || !Rows.Contains(SelectedRow))
|
||||
SelectedRow = keepUid is null ? null : Rows.FirstOrDefault(row => row.Uid == keepUid);
|
||||
else if (!IsDirty) {
|
||||
if (IsPodSelection)
|
||||
ApplyContainers(SelectedRow.Document);
|
||||
|
||||
OverviewText = SelectedRow.FormatOverview(Containers);
|
||||
}
|
||||
|
||||
var title = SelectedNavItem?.Title ?? "items";
|
||||
var filtered = _columnFilters.Values.Any(filter => filter.IsActive) && Rows.Count != _listedRows.Count;
|
||||
@ -555,58 +571,64 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshRowsAsync() {
|
||||
if (SelectedNavItem is null)
|
||||
if (_refreshBusy || SelectedNavItem is null)
|
||||
return;
|
||||
|
||||
await SampleClusterUsageAsync();
|
||||
_refreshBusy = true;
|
||||
try {
|
||||
await SampleClusterUsageAsync();
|
||||
|
||||
if (SelectedNavItem.Id == ResourceCatalog.OverviewId) {
|
||||
if (SelectedNavItem.Id == ResourceCatalog.OverviewId) {
|
||||
_listedRows.Clear();
|
||||
Rows.Clear();
|
||||
await LoadOverviewIssuesAsync();
|
||||
_setStatus($"Overview · {Name}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedNavItem.Id == ResourceCatalog.WorkloadsOverviewId) {
|
||||
await LoadWorkloadsOverviewAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedNavItem.Id == ResourceCatalog.HelmChartsId) {
|
||||
_listedRows.Clear();
|
||||
Rows.Clear();
|
||||
OverviewText = "Add chart repositories with the helm CLI. Releases are listed under Helm → Releases.";
|
||||
_setStatus("Helm charts are managed via helm repos on this machine.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedNavItem.Id == ResourceCatalog.PortForwardingId) {
|
||||
ShowPortForwardRows();
|
||||
return;
|
||||
}
|
||||
|
||||
var listed = await _workspace.ListAsync(SelectedNavItem.Id, Configuration.AllNamespaces, Filter);
|
||||
var keepUid = SelectedRow?.Uid;
|
||||
_listedRows.Clear();
|
||||
Rows.Clear();
|
||||
await LoadOverviewIssuesAsync();
|
||||
_setStatus($"Overview · {Name}");
|
||||
return;
|
||||
if (!listed.IsSuccess) {
|
||||
Rows.Clear();
|
||||
_setStatus(string.Join("; ", listed.Messages));
|
||||
return;
|
||||
}
|
||||
|
||||
_listedRows.AddRange(listed.Value ?? []);
|
||||
foreach (var column in SelectedDescriptor?.Columns ?? [])
|
||||
FilterFor(column.Header).LoadValues(_listedRows);
|
||||
SeedNamespaceColumnFromSelection();
|
||||
|
||||
ApplyColumnFilters(keepUid);
|
||||
NotifyActionFlags();
|
||||
NotifyDetailsUi();
|
||||
OnPropertyChanged(nameof(IsDataEditor));
|
||||
OnPropertyChanged(nameof(IsClusterDashboard));
|
||||
OnPropertyChanged(nameof(IsWorkloadsDashboard));
|
||||
OnPropertyChanged(nameof(IsResourceTable));
|
||||
}
|
||||
|
||||
if (SelectedNavItem.Id == ResourceCatalog.WorkloadsOverviewId) {
|
||||
await LoadWorkloadsOverviewAsync();
|
||||
return;
|
||||
finally {
|
||||
_refreshBusy = false;
|
||||
}
|
||||
|
||||
if (SelectedNavItem.Id == ResourceCatalog.HelmChartsId) {
|
||||
_listedRows.Clear();
|
||||
Rows.Clear();
|
||||
OverviewText = "Add chart repositories with the helm CLI. Releases are listed under Helm → Releases.";
|
||||
_setStatus("Helm charts are managed via helm repos on this machine.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedNavItem.Id == ResourceCatalog.PortForwardingId) {
|
||||
ShowPortForwardRows();
|
||||
return;
|
||||
}
|
||||
|
||||
var listed = await _workspace.ListAsync(SelectedNavItem.Id, Configuration.AllNamespaces, Filter);
|
||||
var keepUid = SelectedRow?.Uid;
|
||||
_listedRows.Clear();
|
||||
if (!listed.IsSuccess) {
|
||||
Rows.Clear();
|
||||
_setStatus(string.Join("; ", listed.Messages));
|
||||
return;
|
||||
}
|
||||
|
||||
_listedRows.AddRange(listed.Value ?? []);
|
||||
foreach (var column in SelectedDescriptor?.Columns ?? [])
|
||||
FilterFor(column.Header).LoadValues(_listedRows);
|
||||
SeedNamespaceColumnFromSelection();
|
||||
|
||||
ApplyColumnFilters(keepUid);
|
||||
NotifyActionFlags();
|
||||
NotifyDetailsUi();
|
||||
OnPropertyChanged(nameof(IsDataEditor));
|
||||
OnPropertyChanged(nameof(IsClusterDashboard));
|
||||
OnPropertyChanged(nameof(IsWorkloadsDashboard));
|
||||
OnPropertyChanged(nameof(IsResourceTable));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@ -1425,6 +1447,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
||||
private async Task LoadLogsAsync() {
|
||||
_logsCts?.Cancel();
|
||||
_logsCts = null;
|
||||
var generation = ++_logsGeneration;
|
||||
if (!ShowLogsTab) {
|
||||
LogsText = "";
|
||||
return;
|
||||
@ -1446,32 +1469,58 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
||||
if (FollowLogs) {
|
||||
_logsCts = new CancellationTokenSource();
|
||||
LogsText = "";
|
||||
_ = FollowLogsLoopAsync(pod, ns, container, _logsCts.Token);
|
||||
_ = FollowLogsLoopAsync(pod, ns, container, generation, _logsCts.Token);
|
||||
return;
|
||||
}
|
||||
|
||||
var logs = await _workspace.Session.GetLogsAsync(pod, ns, container, false, 200);
|
||||
if (generation != _logsGeneration)
|
||||
return;
|
||||
|
||||
LogsText = logs.IsSuccess ? logs.Value ?? "" : string.Join("; ", logs.Messages);
|
||||
}
|
||||
|
||||
private async Task FollowLogsLoopAsync(string pod, string ns, string? container, CancellationToken cancellationToken) {
|
||||
private async Task FollowLogsLoopAsync(
|
||||
string pod,
|
||||
string ns,
|
||||
string? container,
|
||||
int generation,
|
||||
CancellationToken cancellationToken) {
|
||||
if (_workspace.Session is null)
|
||||
return;
|
||||
|
||||
try {
|
||||
await foreach (var line in _workspace.Session.FollowLogsAsync(pod, ns, container, cancellationToken)) {
|
||||
LogsText += line + Environment.NewLine;
|
||||
if (LogsText.Length > 200_000)
|
||||
LogsText = LogsText[^100_000..];
|
||||
var text = line;
|
||||
Dispatcher.UIThread.Post(() => {
|
||||
if (generation != _logsGeneration || cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
AppendLogLine(text);
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException) {
|
||||
}
|
||||
catch (Exception ex) {
|
||||
LogsText = string.IsNullOrEmpty(LogsText) ? ex.Message : LogsText + Environment.NewLine + ex.Message;
|
||||
if (generation != _logsGeneration || cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
Dispatcher.UIThread.Post(() => {
|
||||
if (generation != _logsGeneration || cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
LogsText = string.IsNullOrEmpty(LogsText) ? ex.Message : LogsText + Environment.NewLine + ex.Message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendLogLine(string line) {
|
||||
LogsText += line + Environment.NewLine;
|
||||
if (LogsText.Length > 200_000)
|
||||
LogsText = LogsText[^100_000..];
|
||||
}
|
||||
|
||||
private void ReplaceRelatedPods(IReadOnlyList<ResourceRow> pods) {
|
||||
_updatingPodContext = true;
|
||||
var keep = SelectedRelatedPod?.Name;
|
||||
@ -1899,20 +1948,21 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
|
||||
BrowseFilesCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void StartRefreshLoop() {
|
||||
_refreshCts?.Cancel();
|
||||
_refreshCts = new CancellationTokenSource();
|
||||
var token = _refreshCts.Token;
|
||||
_ = Task.Run(async () => {
|
||||
while (!token.IsCancellationRequested) {
|
||||
try {
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), token);
|
||||
await RefreshRowsAsync();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, token);
|
||||
private DispatcherTimer CreateRefreshTimer() {
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
||||
timer.Tick += OnRefreshTimerTick;
|
||||
return timer;
|
||||
}
|
||||
|
||||
private void OnRefreshTimerTick(object? sender, EventArgs e) =>
|
||||
_ = RefreshFromTimerAsync();
|
||||
|
||||
private async Task RefreshFromTimerAsync() {
|
||||
try {
|
||||
await RefreshRowsAsync();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException) {
|
||||
_setStatus(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,16 +6,65 @@
|
||||
Keep a Changelog header parsing and section extraction.
|
||||
|
||||
.DESCRIPTION
|
||||
Supports only the standard Keep a Changelog version line:
|
||||
Supports Keep a Changelog version lines and shared SemVer checks, including prerelease:
|
||||
## [1.0.0] - 2026-05-24
|
||||
## [0.1.0-alpha.1] - 2026-08-21
|
||||
## [0.1.0-beta.1] - 2026-08-21
|
||||
## [0.1.0-rc.1] - 2026-08-21
|
||||
#>
|
||||
|
||||
function Get-ChangelogSemverPattern {
|
||||
return '\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?'
|
||||
}
|
||||
|
||||
function Test-ReleaseSemver {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return [bool]($Version -match ('^' + (Get-ChangelogSemverPattern) + '$'))
|
||||
}
|
||||
|
||||
function Test-ReleaseSemverPrerelease {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
return (Test-ReleaseSemver -Version $Version) -and ($Version -match '-')
|
||||
}
|
||||
|
||||
function Get-ReleaseSemverPrereleaseLabel {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
if (-not (Test-ReleaseSemverPrerelease -Version $Version)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($Version -match '^\d+\.\d+\.\d+-([A-Za-z][0-9A-Za-z]*)') {
|
||||
return $Matches[1].ToLowerInvariant()
|
||||
}
|
||||
|
||||
return 'next'
|
||||
}
|
||||
|
||||
function Get-ChangelogVersionHeaderPattern {
|
||||
return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||
return '(?m)^##\s+\[(' + (Get-ChangelogSemverPattern) + ')\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||
}
|
||||
|
||||
function Get-ChangelogNextVersionHeaderPattern {
|
||||
return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||
return '(?m)^##\s+\[' + (Get-ChangelogSemverPattern) + '\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||
}
|
||||
|
||||
function Get-LatestChangelogVersion {
|
||||
@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
|
||||
return $match.Value.Trim()
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
|
||||
Export-ModuleMember -Function Get-ChangelogSemverPattern, Test-ReleaseSemver, Test-ReleaseSemverPrerelease, Get-ReleaseSemverPrereleaseLabel, Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
|
||||
|
||||
@ -8,10 +8,10 @@
|
||||
.DESCRIPTION
|
||||
Dedicated version-loading plugin. Reads <Version> from the first configured
|
||||
projectFiles .csproj, or from the nearest Directory.Build.props when the
|
||||
csproj omits it. Writes version plus the resolved projectFiles (csproj paths
|
||||
for later pack/publish) to the shared runtime context. Declares
|
||||
providesVersion = $true so the engine can discover it as the single release
|
||||
version source.
|
||||
csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
|
||||
version plus the resolved projectFiles (csproj paths for later pack/publish)
|
||||
to the shared runtime context. Declares providesVersion = $true so the engine
|
||||
can discover it as the single release version source.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -135,6 +135,10 @@ function Invoke-Plugin {
|
||||
|
||||
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
|
||||
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||
throw "DotNetReleaseVersion: version '$version' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||
}
|
||||
|
||||
Set-EngineState -Context $shared -Name 'version' -Value $version
|
||||
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'
|
||||
|
||||
@ -28,6 +28,7 @@ function Invoke-Plugin {
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
@ -62,6 +63,14 @@ function Invoke-Plugin {
|
||||
[string]$pluginSettings.access
|
||||
}
|
||||
|
||||
$npmDistTag = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) {
|
||||
$npmDistTag = [string]$pluginSettings.npmDistTag
|
||||
}
|
||||
else {
|
||||
$npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version)
|
||||
}
|
||||
|
||||
$publishOrder = @()
|
||||
if ($pluginSettings.publishOrder) {
|
||||
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
|
||||
@ -84,7 +93,8 @@ function Invoke-Plugin {
|
||||
|
||||
if ($dryRun) {
|
||||
foreach ($packageName in $publishOrder) {
|
||||
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry"
|
||||
$tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag }
|
||||
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry (dist-tag $tagNote)"
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -112,13 +122,20 @@ registry=$registry
|
||||
|
||||
foreach ($packageName in $publishOrder) {
|
||||
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
|
||||
$publishArgs = @('publish')
|
||||
if ($useWorkspaces) {
|
||||
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
|
||||
$publishArgs += @('-w', $packageName)
|
||||
}
|
||||
else {
|
||||
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
|
||||
npm publish --access $access --userconfig $tempNpmRcPath
|
||||
}
|
||||
$publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath)
|
||||
if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) {
|
||||
$publishArgs += @('--tag', $npmDistTag)
|
||||
Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)."
|
||||
}
|
||||
|
||||
npm @publishArgs
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to publish npm package '$packageName'."
|
||||
|
||||
@ -35,8 +35,9 @@ function Get-PackageJsonVersionInternal {
|
||||
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
|
||||
}
|
||||
|
||||
if ($version -notmatch '^\d+\.\d+\.\d+') {
|
||||
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver."
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||
}
|
||||
|
||||
return $version
|
||||
@ -69,6 +70,7 @@ function Invoke-Plugin {
|
||||
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
|
||||
@ -7,9 +7,10 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Reads a single-line semver from the configured versionFilePath (default
|
||||
repo-root VERSION). Useful for repositories without .csproj or package.json
|
||||
version metadata. Declares providesVersion = $true so the engine can
|
||||
discover it as the single release version source.
|
||||
repo-root VERSION), including optional prerelease (0.1.0-alpha.1). Useful for
|
||||
repositories without .csproj or package.json version metadata. Declares
|
||||
providesVersion = $true so the engine can discover it as the single release
|
||||
version source.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -36,8 +37,9 @@ function Get-VersionFileSemverInternal {
|
||||
}
|
||||
|
||||
$version = $version -replace '^[vV]', ''
|
||||
if ($version -notmatch '^\d+\.\d+\.\d+') {
|
||||
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver."
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||
}
|
||||
|
||||
return $version
|
||||
@ -55,6 +57,7 @@ function Invoke-Plugin {
|
||||
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
|
||||
$shared = $Settings.context
|
||||
$versionFileSetting = if ($Settings.versionFilePath) {
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
repository, and creates the configured GitHub release using the
|
||||
shared release artifacts and release notes from CHANGELOG.md.
|
||||
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
|
||||
(see ChangelogSupport.psm1).
|
||||
(including optional SemVer prerelease, e.g. ## [0.1.0-alpha.1] / [0.1.0-beta.1] / [0.1.0-rc.1];
|
||||
see ChangelogSupport.psm1). Hyphenated versions are created with gh --prerelease.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -95,6 +96,7 @@ function Invoke-Plugin {
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
@ -128,6 +130,9 @@ function Invoke-Plugin {
|
||||
}
|
||||
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
|
||||
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo"
|
||||
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
|
||||
Write-Log -Level "INFO" -Message "Dry run: release would be marked prerelease."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -261,6 +266,10 @@ function Invoke-Plugin {
|
||||
"--title", $releaseName,
|
||||
"--notes-file", $notesFilePath
|
||||
)
|
||||
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
|
||||
$createReleaseArgs += '--prerelease'
|
||||
}
|
||||
|
||||
& gh @createReleaseArgs
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
|
||||
@ -11,8 +11,9 @@
|
||||
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
|
||||
branch lists; put allowed branches here instead.
|
||||
|
||||
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
|
||||
tag version vs DotNetReleaseVersion, optional push tag to remote.
|
||||
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD
|
||||
(vX.Y.Z or vX.Y.Z-prerelease such as v0.1.0-alpha.1 / v0.1.0-beta.1 / v0.1.0-rc.1),
|
||||
tag version vs release version, optional push tag to remote.
|
||||
|
||||
The engine preflight no longer reads git tags; this plugin sets context.tag from the
|
||||
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
|
||||
@ -77,6 +78,7 @@ function Invoke-Plugin {
|
||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
|
||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
|
||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
@ -123,8 +125,9 @@ function Invoke-Plugin {
|
||||
return
|
||||
}
|
||||
|
||||
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
|
||||
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
|
||||
$tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
|
||||
if ($tag -notmatch $tagPattern) {
|
||||
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z or vX.Y.Z-prerelease (e.g. v0.1.0-alpha.1, v0.1.0-beta.1, v0.1.0-rc.1)."
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user