diff --git a/.ai/rules/feature-completeness.md b/.ai/rules/feature-completeness.md new file mode 100644 index 0000000..b29d25e --- /dev/null +++ b/.ai/rules/feature-completeness.md @@ -0,0 +1,80 @@ +--- +applyTo: "**/*" +--- + +# Feature Completeness + +Build success proves compilation. It proves nothing about behaviour. These standards exist because code that compiles cleanly can still be silently broken — callbacks wired but never triggered, UI elements that advertise features that do not exist, actions that return without doing anything or telling anyone why. + +## Build ≠ Behaviour + +> **"Build succeeded" is never a signal that work is complete.** + +Compilation verifies types and syntax. It does not verify that a button click actually fires, that a registered handler is ever called, that a feature works as documented, or that a user-facing interaction produces the expected result. + +When work cannot be verified at runtime — because there is no test harness, the environment is unavailable, or the UI cannot be exercised programmatically — say so explicitly. Do not present unverified behaviour as confirmed. + +## Complete Implementation Chains + +Every user-facing feature is a chain: a trigger connects to a handler connects to an action. All links in the chain must exist before the feature is considered done. + +**Common failure modes:** + +- A button, keyboard shortcut, or menu item is documented and visible — but no handler exists for it +- A handler is registered (event, callback, delegate) — but nothing ever calls it +- An action is implemented — but no trigger connects it to user input +- An interface or abstract method is declared — but left as a no-op or stub and never noticed + +**The rule:** When you add any one part of a chain, locate and verify all the other parts. If they do not exist, either create them in the same change or explicitly flag them as missing. + +Chains to audit in this codebase: +- UI label / key hint → key handler case → trigger method → registered callback → executing action +- Interface method / abstract member → all implementations → all call sites +- Event subscription → event firing → observable side effect + +## Document–Code Consistency + +Everything the user can read — UI hints, help text, comments, XML docs, error messages — is a promise about what the code does. Broken promises are bugs. + +**Rule:** Any text visible to a user that describes a behaviour, shortcut, or feature must have a corresponding implementation. If the implementation does not exist, remove the text. If the text is wrong, fix it. Never leave documentation and code in disagreement. + +Examples: +- A tooltip says "Press R to replay" → `ConsoleKey.R` must have a handler +- An XML doc says "Throws X when Y" → the code must throw X when Y +- A README says "Supports Z format" → Z must be supported + +## No Silent Failures + +When a user-triggered operation cannot proceed because a precondition is not met, always surface feedback. A method that silently returns accomplishes nothing except making the user believe the feature is broken. + +**Rule:** Every early-return guard in a user-facing operation must explain why it returned. Use status messages, error indicators, or log output — whatever is appropriate for the context. Never return silently. + +```csharp +// ❌ User sees nothing, concludes the feature is broken +if (selected is null) return; + +// ✅ User sees "Select a row first" +if (selected is null) { ShowHint("Select a row first"); return; } +``` + +This applies equally to: +- User input handlers that silently drop input +- API endpoints that return empty 200s instead of 404s +- Background jobs that swallow exceptions +- Validation that fails without reporting what was invalid + +## Carry-Forward Integrity + +When extending existing code, do not assume it is complete. Scaffold, boilerplate, and copy-pasted patterns frequently have missing links — stubs, unimplemented handlers, wired-but-never-called callbacks. The fact that existing code was not flagged as broken does not mean it works. + +**Rule:** When touching an existing feature, trace the full implementation chain — trigger → handler → action — and verify each link. Any gap you find is a pre-existing bug. Either fix it in the same change or leave an explicit note. + +## Verification Checklist + +Before marking any feature complete, confirm: + +- [ ] Every user-visible description of a behaviour has a corresponding implementation +- [ ] Every registered handler/callback/listener is called by something +- [ ] Every trigger (button, shortcut, event) connects to a handler +- [ ] Every early return in a user-facing path provides user feedback +- [ ] If runtime behaviour could not be verified, that is stated explicitly diff --git a/.claude/rules/feature-completeness.md b/.claude/rules/feature-completeness.md new file mode 120000 index 0000000..c79c159 --- /dev/null +++ b/.claude/rules/feature-completeness.md @@ -0,0 +1 @@ +../../.ai/rules/feature-completeness.md \ No newline at end of file diff --git a/.github/instructions/feature-completeness.instructions.md b/.github/instructions/feature-completeness.instructions.md new file mode 120000 index 0000000..c79c159 --- /dev/null +++ b/.github/instructions/feature-completeness.instructions.md @@ -0,0 +1 @@ +../../.ai/rules/feature-completeness.md \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 01cbab2..3c5882b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs b/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs index 266fa00..5ff7241 100644 --- a/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs +++ b/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs @@ -11,7 +11,7 @@ public class and_running_state_has_quarantined_value : Specification void Because() => _isQuarantined = ListObserversCommand.IsQuarantined(new ObserverInformation { - RunningState = (ObserverRunningState)5 + RunningState = ObserverRunningState.Quarantined }); [Fact] void should_return_true() => _isQuarantined.ShouldBeTrue(); diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 008d56a..219ff3f 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -24,6 +24,7 @@ + diff --git a/Source/Cli/Commands/Chronicle/Workbench/NavFrame.cs b/Source/Cli/Commands/Chronicle/Workbench/NavFrame.cs deleted file mode 100644 index b05f993..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/NavFrame.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -/// -/// A frame on the workbench navigation stack, capturing view state before drilling into a detail view. -/// -/// The view enum value (stored as int to avoid volatile boxing). -/// The list cursor position in that view. -/// The focused item identifier in that view. -public readonly record struct NavFrame(int View, int SelectedIndex, string FocusedId); diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchAction.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchAction.cs deleted file mode 100644 index 3411c7b..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchAction.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -/// -/// The current action lifecycle state of the workbench. -/// -public enum WorkbenchActionState -{ - /// Normal operation — no action pending. - None, - - /// An action is queued and awaiting user confirmation. - AwaitingConfirmation, - - /// An action is being executed against the server. - Executing, - - /// An action has completed; the result is available. - Completed -} - -/// -/// Describes a pending in-place action that requires user confirmation before execution. -/// -/// Human-readable label shown in the confirmation prompt. -/// Message displayed after successful execution. -/// The gRPC call to invoke when the user confirms. -public record PendingAction( - string Description, - string SuccessMessage, - Func Execute); - -/// -/// Carries all render-relevant state for a single frame of the workbench, keeping the Build() signature stable. -/// -/// The currently active view (primary or detail). -/// The selected list-item index within the current view. -/// The current refresh interval in seconds. -/// Whether a data fetch is currently in progress. -/// The current action lifecycle state. -/// Description of the action awaiting confirmation, or . -/// Result message after action completion, or . -/// Whether the most recent action completed with an error. -/// The identifier of the item shown in a detail view. -/// The content scroll offset (line number) in detail views. -/// Navigation breadcrumb path, e.g. ["Observers", "SomeProjection"]. -/// The active inline filter string, or empty when no filter is applied. -/// Whether the user is actively typing a filter (entered via '/'). -/// Whether the event log is sorted ascending (oldest first) instead of the default descending (newest first). -/// The current zero-based page index within the event log (50 events per page). -public record WorkbenchRenderState( - WorkbenchView View, - int SelectedIndex, - int Interval, - bool IsRefreshing, - WorkbenchActionState ActionState, - string? PendingActionDescription, - string? ActionResult, - bool IsActionError = false, - string FocusedId = "", - int ScrollOffset = 0, - IReadOnlyList? Breadcrumb = null, - string FilterText = "", - bool FilterInputMode = false, - bool EventLogAscending = false, - int EventLogPage = 0); diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs new file mode 100644 index 0000000..0b195b3 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Configuration; +using SharpConsoleUI.Drivers; +using SharpConsoleUI.Panel; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Bootstraps and runs the Chronicle Workbench TUI application using SharpConsoleUI. +/// +/// The data service for fetching workbench snapshots. +/// The workbench command settings. +/// The Chronicle gRPC service clients. +/// Pre-fetched data snapshot to populate all views before the first frame is rendered. +/// Persisted workbench state from the previous session. +public class WorkbenchApp(WorkbenchDataService dataService, WorkbenchSettings settings, IServices services, WorkbenchData initialData, WorkbenchState state) +{ + /// + /// Runs the workbench TUI and blocks until the user exits. + /// + /// The exit code. + public int Run() + { + var windowSystem = new ConsoleWindowSystem( + new NetConsoleDriver(RenderMode.Buffer), + options: new ConsoleWindowSystemOptions( + TopPanelConfig: panel => panel.Left(Elements.StatusText(string.Empty)), + BottomPanelConfig: panel => panel.Left(Elements.StatusText(WorkbenchHints.BottomBar)))); + + windowSystem.PanelStateService.TopStatus = "◆ CHRONICLE WORKBENCH"; + + var mainWindow = new MainWindow(windowSystem, dataService, settings, services, initialData, state); + windowSystem.AddWindow(mainWindow.Build(), activateWindow: true); + + return windowSystem.Run(); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs new file mode 100644 index 0000000..aaa926d --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs @@ -0,0 +1,51 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SColor = SharpConsoleUI.Color; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// SharpConsoleUI-compatible color constants for the workbench — midnight-blue palette with vibrant accents. +/// +public static class WorkbenchColors +{ + /// Very deep midnight-navy background — the base window color. + public static readonly SColor Background = new(8, 12, 28, 255); + + /// Deep navy surface — content pane background, slightly lighter than the window. + public static readonly SColor Surface = new(16, 22, 46, 255); + + /// Primary foreground — blue-tinted white, easy on the eyes on dark backgrounds. + public static readonly SColor Foreground = new(220, 225, 250, 255); + + /// Primary accent — electric blue, used for highlights, selected items, and borders. + public static readonly SColor Accent = new(100, 180, 255, 255); + + /// Navigation pane selection background — rich royal blue. + public static readonly SColor SelectedBg = new(35, 70, 150, 255); + + /// Muted secondary text — perceptible but not distracting. + public static readonly SColor Muted = new(90, 110, 160, 255); + + /// Success / healthy state — vibrant mint green. + public static readonly SColor Success = new(100, 220, 130, 255); + + /// Warning state — warm amber, used for the OBSERVATION nav section. + public static readonly SColor Warning = new(240, 190, 80, 255); + + /// Danger / error state — bright coral-pink for failed partitions and errors. + public static readonly SColor Danger = new(255, 100, 130, 255); + + /// Teal / cyan accent — used for the EVENTS nav section and event-type borders. + public static readonly SColor Teal = new(60, 220, 200, 255); + + /// Mauve / violet accent — used for the PROJECTIONS nav section. + public static readonly SColor Mauve = new(200, 155, 255, 255); + + /// Content pane border — subtle blue-violet, visible but not distracting. + public static readonly SColor ContentBorder = new(45, 65, 115, 255); + + /// Dim table/panel border color — very subtle dark-blue chrome. + public static readonly SColor ChromeBorder = new(30, 40, 80, 255); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs index c5e4e4d..5e5a350 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs @@ -1,62 +1,19 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System.Text.Json; -using Cratis.Chronicle.Contracts.Jobs; -using Cratis.Cli.Commands.Chronicle.ReadModels; - namespace Cratis.Cli.Commands.Chronicle.Workbench; /// /// Interactive TUI workbench — a live-updating dashboard with full drill-down navigation and in-place /// actions for observers, failed partitions, jobs, recommendations, event log, event types, and projections. /// -[LlmDescription("Opens an interactive full-screen TUI workbench for the Chronicle server. Navigate with ← → or 1–8, drill into items with Enter, go back with Escape. Not suitable for scripting.")] +[LlmDescription("Opens an interactive full-screen TUI workbench for the Chronicle server. Navigate with number keys 1–9 to switch tabs. Not suitable for scripting.")] [CliCommand("workbench", "Open the interactive Chronicle workbench (live TUI dashboard)", Branch = typeof(ChronicleBranch))] [CliExample("chronicle", "workbench")] [CliExample("chronicle", "workbench", "--interval", "10")] [CliExample("chronicle", "workbench", "-e", "my-event-store")] public class WorkbenchCommand : ChronicleCommand { - /// Number of primary values (values < 100) — must be kept in sync with the enum. - const int ViewCount = 11; - - /// Number of events displayed per page in the Event Log view. - const int EventLogPageSize = 50; - - /// Total events fetched from the server for the Event Log — determines how many pages are available. - const int EventLogFetchWindow = 500; - - static readonly JsonSerializerOptions _instanceJsonOptions = new() { WriteIndented = true }; - - /// Nav stack — only touched by the input task (HandleKey). Render loop uses snapshot fields. - readonly Stack _navStack = new(); - - volatile int _currentView; - volatile int _selectedIndex; - volatile int _actionState; - volatile int _scrollOffset; - volatile int _isActionError; - volatile int _filterInputMode; - volatile int _eventLogAscending; - volatile int _eventLogPage; - - /// - /// Fired by the input task whenever the user presses a key; wakes the render loop immediately - /// rather than waiting for the full refresh interval to elapse. - /// - TaskCompletionSource _keyPressSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); - - PendingAction? _pendingAction; - string _actionResult = string.Empty; - string _focusedId = string.Empty; - string _filter = string.Empty; - string? _activeEventStore; - string? _activeNamespace; - IReadOnlyList _breadcrumb = []; - IServices? _services; - WorkbenchData? _lastData; - /// protected override bool UseStatusSpinner => false; @@ -73,1098 +30,28 @@ protected override async Task ExecuteCommandAsync(IServices services, Workb return ExitCodes.ValidationError; } - _services = services; - - using var cts = new CancellationTokenSource(); - Console.CancelKeyPress += (_, e) => + // Restore persisted state from the previous session. + var state = WorkbenchState.Load(); + if (settings.Interval == 5) { - e.Cancel = true; - cts.Cancel(); - }; - - var inputTask = Task.Run( - async () => - { - while (!cts.Token.IsCancellationRequested) - { - if (Console.KeyAvailable) - { - var pressedKey = Console.ReadKey(intercept: true); - HandleKey(pressedKey, settings, cts); - } - - try - { - await Task.Delay(50, cts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - } - }, - cts.Token); - - var data = WorkbenchData.Loading(settings); - - await AnsiConsole.Live(WorkbenchRenderer.Build(data, LoadingState(settings))) - .StartAsync(async ctx => - { - while (!cts.Token.IsCancellationRequested) - { - var signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Interlocked.Exchange(ref _keyPressSignal, signal); - - ctx.UpdateTarget(WorkbenchRenderer.Build(data, RenderState(settings, isRefreshing: true))); - ctx.Refresh(); - - data = await FetchData(services, settings, _activeEventStore, _activeNamespace, (WorkbenchView)_currentView, _focusedId, cts.Token); - _lastData = data; - - ctx.UpdateTarget(WorkbenchRenderer.Build(data, RenderState(settings, isRefreshing: false))); - ctx.Refresh(); + // Only apply saved interval when the user hasn't explicitly set one via --interval. + settings.Interval = state.Interval; + } - try - { - await Task.WhenAny( - Task.Delay(TimeSpan.FromSeconds(settings.Interval), cts.Token), - signal.Task); - } - catch (OperationCanceledException) - { - break; - } + var dataService = new WorkbenchDataService(services, settings); - if (cts.Token.IsCancellationRequested) break; + // Pre-fetch before launching the window so every view has real data from the first frame — + // mirroring the old render-loop approach where data was fetched before the first render. + var initialData = await dataService.FetchAsync(null, null, null, CancellationToken.None).ConfigureAwait(false); - ctx.UpdateTarget(WorkbenchRenderer.Build(data, RenderState(settings, isRefreshing: true))); - ctx.Refresh(); - } - }); + var app = new WorkbenchApp(dataService, settings, services, initialData, state); + app.Run(); - try - { - await inputTask; - } - catch (OperationCanceledException) - { - } + // Persist final state so next session restores the same context. + state.Interval = settings.Interval; + state.Save(); AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Workbench closed.[/]"); return ExitCodes.Success; } - - static async Task FetchData( - IServices services, - WorkbenchSettings settings, - string? activeEventStore, - string? activeNamespace, - WorkbenchView currentView, - string focusedId, - CancellationToken ct) - { - var eventStore = activeEventStore ?? settings.ResolveEventStore(); - var ns = activeNamespace ?? settings.ResolveNamespace(); - var connectionString = settings.ResolveConnectionString(); - - string? serverVersion = null; - var isConnected = true; - - try - { - var versionInfo = await services.Server.GetVersionInfo(); - serverVersion = versionInfo.Version; - } - catch - { - isConnected = false; - } - - var eventStoreNames = new List(); - try { eventStoreNames = [.. await services.EventStores.GetEventStores()]; } - catch { } - - IReadOnlyList observers = []; - try - { - observers = [.. await services.Observers.GetObservers(new AllObserversRequest - { - EventStore = eventStore, - Namespace = ns - })]; - } - catch { } - - IReadOnlyList failedPartitions = []; - try - { - failedPartitions = [.. await services.FailedPartitions.GetFailedPartitions(new GetFailedPartitionsRequest - { - EventStore = eventStore, - Namespace = ns - })]; - } - catch { } - - IReadOnlyList jobs = []; - try - { - jobs = [.. (await services.Jobs.GetJobs(new GetJobsRequest - { - EventStore = eventStore, - Namespace = ns - })) ?? []]; - } - catch { } - - IReadOnlyList recommendations = []; - try - { - recommendations = [.. await services.Recommendations.GetRecommendations(new GetRecommendationsRequest - { - EventStore = eventStore, - Namespace = ns - })]; - } - catch { } - - ulong? tailSequenceNumber = null; - try - { - var tail = await services.EventSequences.GetTailSequenceNumber(new GetTailSequenceNumberRequest - { - EventStore = eventStore, - Namespace = ns, - EventSequenceId = CliDefaults.DefaultEventSequenceId - }); - tailSequenceNumber = tail.SequenceNumber == ulong.MaxValue ? null : tail.SequenceNumber; - } - catch { } - - IReadOnlyList eventTypeRegistrations = []; - try - { - eventTypeRegistrations = [.. await services.EventTypes.GetAllRegistrations( - new GetAllEventTypesRequest { EventStore = eventStore })]; - } - catch { } - - IReadOnlyList projectionDefinitions = []; - try - { - projectionDefinitions = [.. await services.Projections.GetAllDefinitions( - new GetAllDefinitionsRequest { EventStore = eventStore })]; - } - catch { } - - var projectionDeclarations = new Dictionary(); - try - { - var declarations = await services.Projections.GetAllDeclarations( - new GetAllDeclarationsRequest { EventStore = eventStore }); - foreach (var d in declarations) - { - projectionDeclarations[d.Identifier] = d.Declaration ?? string.Empty; - } - } - catch { } - - IReadOnlyList recentEvents = []; - try - { - if (tailSequenceNumber > 0) - { - var fromSeq = tailSequenceNumber.Value >= EventLogFetchWindow - ? tailSequenceNumber.Value - EventLogFetchWindow + 1 - : 0; - var eventsResp = await services.EventSequences.GetEventsFromEventSequenceNumber( - new GetFromEventSequenceNumberRequest - { - EventStore = eventStore, - Namespace = ns, - EventSequenceId = CliDefaults.DefaultEventSequenceId, - FromEventSequenceNumber = fromSeq - }); - recentEvents = [.. eventsResp.Events.OrderByDescending(e => e.Context.SequenceNumber)]; - } - } - catch { } - - IReadOnlyList readModelDefinitions = []; - try - { - var defs = await services.ReadModels.GetDefinitions(new GetDefinitionsRequest { EventStore = eventStore }); - readModelDefinitions = [.. defs.ReadModels.Select(rm => new WorkbenchReadModel( - rm.ContainerName, - rm.DisplayName, - rm.Owner.ToString(), - !string.Equals(rm.Owner.ToString(), "Client", StringComparison.Ordinal), - rm.Source.ToString(), - rm.Type?.Identifier ?? string.Empty))]; - } - catch { } - - IReadOnlyList namespaceNames = []; - try { namespaceNames = [.. await services.Namespaces.GetNamespaces(new GetNamespacesRequest { EventStore = eventStore })]; } - catch { } - - IReadOnlyList readModelInstances = []; - var readModelInstancesTotalCount = 0; - string? readModelInstancesError = null; - if (currentView == WorkbenchView.ReadModelDetail && !string.IsNullOrEmpty(focusedId)) - { - try - { - var instResp = await services.ReadModels.GetInstances(new GetInstancesRequest - { - EventStore = eventStore, - Namespace = ns, - ReadModel = focusedId, - Page = 0, - PageSize = 20 - }); - readModelInstancesTotalCount = (int)Math.Min(instResp.TotalCount, int.MaxValue); - readModelInstances = [.. (instResp.Instances ?? []) - .Select(ReadModelJsonCleaner.CleanInstance) - .Where(o => o is not null) - .Select(o => JsonSerializer.Serialize(o, _instanceJsonOptions))]; - } - catch (Exception ex) - { - readModelInstancesError = ex.Message; - } - } - - return new WorkbenchData( - ConnectionString: connectionString, - EventStore: eventStore, - Namespace: ns, - IsConnected: isConnected, - ServerVersion: serverVersion, - EventStoreNames: eventStoreNames, - Observers: observers, - FailedPartitions: failedPartitions, - Jobs: jobs, - Recommendations: recommendations, - TailSequenceNumber: tailSequenceNumber, - CapturedAt: DateTimeOffset.Now, - FetchError: null, - EventTypeRegistrations: eventTypeRegistrations, - ProjectionDefinitions: projectionDefinitions, - ProjectionDeclarations: projectionDeclarations, - RecentEvents: recentEvents, - ReadModelDefinitions: readModelDefinitions, - NamespaceNames: namespaceNames, - ReadModelInstances: readModelInstances, - ReadModelInstancesTotalCount: readModelInstancesTotalCount, - ReadModelInstancesError: readModelInstancesError); - } - - static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch - { - ObserverRunningState.Disconnected => 0, - ObserverRunningState.Replaying => 1, - ObserverRunningState.Active => 2, - ObserverRunningState.Suspended => 3, - _ => 4 - }; - - static string GetViewLabel(WorkbenchView view) => view switch - { - WorkbenchView.Overview => "Overview", - WorkbenchView.Observers => "Observers", - WorkbenchView.FailedPartitions => "Failures", - WorkbenchView.Jobs => "Jobs", - WorkbenchView.Recommendations => "Recommendations", - WorkbenchView.EventLog => "Event Log", - WorkbenchView.EventTypes => "Event Types", - WorkbenchView.Projections => "Projections", - WorkbenchView.ReadModels => "Read Models", - WorkbenchView.EventStores => "Event Stores", - WorkbenchView.Namespaces => "Namespaces", - WorkbenchView.ObserverDetail => "Observer", - WorkbenchView.FailedPartitionDetail => "Failure", - WorkbenchView.EventDetail => "Event", - WorkbenchView.EventTypeDetail => "Event Type", - WorkbenchView.ProjectionDetail => "Projection", - WorkbenchView.ReadModelDetail => "Read Model", - _ => string.Empty - }; - - static WorkbenchRenderState LoadingState(WorkbenchSettings settings) => - new(WorkbenchView.Overview, 0, settings.Interval, true, WorkbenchActionState.None, null, null); - - static string TruncateForPrompt(string s) => s.Length <= 60 ? s : s[..57] + "…"; - - static bool HasSubNavigation(WorkbenchView view) => view == WorkbenchView.ObserverDetail; - - static bool IsFilterableView(WorkbenchView view) => - view is WorkbenchView.Observers or WorkbenchView.EventTypes - or WorkbenchView.EventLog or WorkbenchView.Projections - or WorkbenchView.ReadModels; - - int GetMaxSelectedIndex() - { - var view = (WorkbenchView)_currentView; - var data = _lastData; - if (data is null) return 0; - if (view == WorkbenchView.EventLog) - { - var pageStart = _eventLogPage * EventLogPageSize; - return Math.Max(0, Math.Min(EventLogPageSize, data.RecentEvents.Count - pageStart) - 1); - } - return Math.Max(0, view switch - { - WorkbenchView.Observers => data.Observers.Count - 1, - WorkbenchView.FailedPartitions => data.FailedPartitions.Count - 1, - WorkbenchView.Jobs => data.Jobs.Count - 1, - WorkbenchView.Recommendations => data.Recommendations.Count - 1, - WorkbenchView.EventTypes => data.EventTypeRegistrations.Count - 1, - WorkbenchView.Projections => data.ProjectionDefinitions.Count - 1, - WorkbenchView.ReadModels => data.ReadModelDefinitions.Count - 1, - WorkbenchView.EventStores => data.EventStoreNames.Count - 1, - WorkbenchView.Namespaces => data.NamespaceNames.Count - 1, - WorkbenchView.ObserverDetail => Math.Max(0, (data.Observers - .FirstOrDefault(o => o.Id == _focusedId)?.EventTypes?.Count() ?? 1) - 1), - _ => 0 - }); - } - - WorkbenchRenderState RenderState(WorkbenchSettings settings, bool isRefreshing) => - new( - View: (WorkbenchView)_currentView, - SelectedIndex: _selectedIndex, - Interval: settings.Interval, - IsRefreshing: isRefreshing, - ActionState: (WorkbenchActionState)_actionState, - PendingActionDescription: _pendingAction?.Description, - ActionResult: _actionResult, - IsActionError: _isActionError != 0, - FocusedId: _focusedId, - ScrollOffset: _scrollOffset, - Breadcrumb: _breadcrumb, - FilterText: _filter, - FilterInputMode: _filterInputMode != 0, - EventLogAscending: _eventLogAscending != 0, - EventLogPage: _eventLogPage); - - ObserverInformation? GetSelectedObserver() => - _lastData?.Observers - .OrderBy(ObserverSortOrder) - .ThenBy(o => o.Id) - .Skip(_selectedIndex) - .FirstOrDefault(); - - ObserverInformation? FindObserverById(string id) => - _lastData?.Observers.FirstOrDefault(o => o.Id == id); - - FailedPartition? GetSelectedFailedPartition() => - _lastData?.FailedPartitions - .OrderByDescending(fp => fp.Attempts.Count()) - .Skip(_selectedIndex) - .FirstOrDefault(); - - FailedPartition? FindFailedPartitionByFocusedId(string focusedId) - { - var sep = focusedId.IndexOf('/'); - if (sep < 0) return null; - var obsId = focusedId[..sep]; - var partition = focusedId[(sep + 1)..]; - return _lastData?.FailedPartitions - .FirstOrDefault(fp => fp.ObserverId == obsId && fp.Partition == partition); - } - - Job? GetSelectedJob() => - _lastData?.Jobs - .OrderBy(j => j.Status.ToString()) - .Skip(_selectedIndex) - .FirstOrDefault(); - - Recommendation? GetSelectedRecommendation() => - _lastData?.Recommendations - .Skip(_selectedIndex) - .FirstOrDefault(); - - AppendedEvent? GetSelectedEvent() => - _lastData?.RecentEvents - .Skip(_selectedIndex) - .FirstOrDefault(); - - EventTypeRegistration? GetSelectedEventTypeRegistration() => - _lastData?.EventTypeRegistrations - .OrderBy(r => r.Type.Id, StringComparer.OrdinalIgnoreCase) - .ThenBy(r => r.Type.Generation) - .Skip(_selectedIndex) - .FirstOrDefault(); - - ProjectionDefinition? GetSelectedProjectionDefinition() => - _lastData?.ProjectionDefinitions - .OrderBy(d => d.Identifier, StringComparer.OrdinalIgnoreCase) - .Skip(_selectedIndex) - .FirstOrDefault(); - - WorkbenchReadModel? GetSelectedReadModel() => - _lastData?.ReadModelDefinitions - .OrderBy(d => d.ContainerName, StringComparer.OrdinalIgnoreCase) - .Skip(_selectedIndex) - .FirstOrDefault(); - - string? GetSelectedEventStoreName() => - _lastData?.EventStoreNames - .Order(StringComparer.OrdinalIgnoreCase) - .Skip(_selectedIndex) - .FirstOrDefault(); - - string? GetSelectedNamespaceName() => - _lastData?.NamespaceNames - .Order(StringComparer.OrdinalIgnoreCase) - .Skip(_selectedIndex) - .FirstOrDefault(); - - void PushNav() - { - _navStack.Push(new NavFrame(_currentView, _selectedIndex, _focusedId)); - } - - List BuildBreadcrumb() - { - if (_navStack.Count == 0) return []; - var path = new List(); - foreach (var frame in _navStack.Reverse()) - { - path.Add(GetViewLabel((WorkbenchView)frame.View)); - } - - if (!string.IsNullOrEmpty(_focusedId)) path.Add(TruncateForPrompt(_focusedId)); - return path; - } - - void NavigateToDetail(WorkbenchView detailView, string focusedId) - { - PushNav(); - _focusedId = focusedId; - _currentView = (int)detailView; - _selectedIndex = 0; - _scrollOffset = 0; - _breadcrumb = BuildBreadcrumb(); - } - - void NavigateBack() - { - var frame = _navStack.Pop(); - _currentView = frame.View; - _selectedIndex = frame.SelectedIndex; - _focusedId = frame.FocusedId; - _scrollOffset = 0; - _breadcrumb = BuildBreadcrumb(); - } - - void SetPendingAction(PendingAction action) - { - _pendingAction = action; - _actionState = (int)WorkbenchActionState.AwaitingConfirmation; - _keyPressSignal.TrySetResult(); - } - - async Task ExecuteActionAsync(CancellationToken ct) - { - var action = _pendingAction; - if (action is null) return; - - _isActionError = 0; - _actionState = (int)WorkbenchActionState.Executing; - _keyPressSignal.TrySetResult(); - - try - { - await action.Execute(ct); - _actionResult = action.SuccessMessage; - } - catch (OperationCanceledException) - { - _actionState = (int)WorkbenchActionState.None; - return; - } - catch (Exception ex) - { - var msg = ex.Message; - _actionResult = msg.Length > 100 ? msg[..100] + "…" : msg; - _isActionError = 1; - } - - _pendingAction = null; - _actionState = (int)WorkbenchActionState.Completed; - _keyPressSignal.TrySetResult(); - } - - void HandleKey(ConsoleKeyInfo keyInfo, WorkbenchSettings settings, CancellationTokenSource cts) - { - var key = keyInfo.Key; - var actionState = (WorkbenchActionState)_actionState; - var view = (WorkbenchView)_currentView; - - // When a completed action is showing its result, any key dismisses it. - if (actionState == WorkbenchActionState.Completed) - { - _actionState = (int)WorkbenchActionState.None; - _actionResult = string.Empty; - _keyPressSignal.TrySetResult(); - return; - } - - // While executing, ignore all input except quit. - if (actionState == WorkbenchActionState.Executing) - { - if (key == ConsoleKey.Q) cts.Cancel(); - return; - } - - // Confirmation prompt — only Y/N/Escape are valid. - if (actionState == WorkbenchActionState.AwaitingConfirmation) - { - switch (key) - { - case ConsoleKey.Y: - _ = Task.Run(() => ExecuteActionAsync(cts.Token), cts.Token); - break; - case ConsoleKey.N: - case ConsoleKey.Escape: - _pendingAction = null; - _actionState = (int)WorkbenchActionState.None; - _keyPressSignal.TrySetResult(); - break; - } - - return; - } - - var isDetail = (int)view >= 100; - - // Filter input mode — route all input to the filter until Enter or Escape exits it. - if (_filterInputMode != 0) - { - switch (key) - { - case ConsoleKey.Escape: - _filterInputMode = 0; - _filter = string.Empty; - _selectedIndex = 0; - break; - case ConsoleKey.Enter: - _filterInputMode = 0; - break; - case ConsoleKey.Backspace: - if (!string.IsNullOrEmpty(_filter)) - _filter = _filter[..^1]; - break; - case ConsoleKey.UpArrow: - _selectedIndex = Math.Max(0, _selectedIndex - 1); - break; - case ConsoleKey.DownArrow: - _selectedIndex++; - break; - default: - if (keyInfo.KeyChar >= ' ') - { - _filter += keyInfo.KeyChar; - _selectedIndex = 0; - } - break; - } - _keyPressSignal.TrySetResult(); - return; - } - - switch (key) - { - // Primary view number keys — always clear the nav stack and jump directly. - case ConsoleKey.D1: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Overview; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D2: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Observers; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D3: - _navStack.Clear(); - _currentView = (int)WorkbenchView.FailedPartitions; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D4: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Jobs; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D5: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Recommendations; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D6: - _navStack.Clear(); - _currentView = (int)WorkbenchView.EventLog; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _eventLogPage = 0; - _breadcrumb = []; - break; - case ConsoleKey.D7: - _navStack.Clear(); - _currentView = (int)WorkbenchView.EventTypes; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D8: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Projections; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _filterInputMode = 0; - _breadcrumb = []; - break; - case ConsoleKey.D9: - _navStack.Clear(); - _currentView = (int)WorkbenchView.ReadModels; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _filterInputMode = 0; - _breadcrumb = []; - break; - case ConsoleKey.D0: - _navStack.Clear(); - _currentView = (int)WorkbenchView.EventStores; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _filterInputMode = 0; - _breadcrumb = []; - break; - - // Left/right — cycle through primary views only (detail views excluded). - case ConsoleKey.LeftArrow when !isDetail: - _currentView = (_currentView - 1 + ViewCount) % ViewCount; - _selectedIndex = 0; - _scrollOffset = 0; - _filter = string.Empty; - _filterInputMode = 0; - _eventLogPage = 0; - break; - case ConsoleKey.RightArrow when !isDetail: - _currentView = (_currentView + 1) % ViewCount; - _selectedIndex = 0; - _scrollOffset = 0; - _filter = string.Empty; - _filterInputMode = 0; - _eventLogPage = 0; - break; - - // Up/down — navigate list in primary views and sub-navigation detail views; scroll in other detail views. - case ConsoleKey.UpArrow: - case ConsoleKey.K: - if (isDetail && !HasSubNavigation(view)) - _scrollOffset = Math.Max(0, _scrollOffset - 1); - else - _selectedIndex = Math.Max(0, _selectedIndex - 1); - break; - case ConsoleKey.DownArrow: - if (isDetail && !HasSubNavigation(view)) - _scrollOffset = Math.Min(2000, _scrollOffset + 1); - else - _selectedIndex = Math.Min(GetMaxSelectedIndex(), _selectedIndex + 1); - break; - - // Enter — drill into detail view for selected item. - case ConsoleKey.Enter: - HandleEnterKey(settings); - return; // HandleEnterKey fires signal - - // Escape — clear filter if active; otherwise pop nav stack or reset selection. - case ConsoleKey.Escape: - if (!string.IsNullOrEmpty(_filter)) - { - _filter = string.Empty; - _selectedIndex = 0; - } - else if (_navStack.Count > 0) - { - NavigateBack(); - } - else - { - _selectedIndex = 0; - _scrollOffset = 0; - } - - break; - - // Backspace — remove last filter character when a filter is active. - case ConsoleKey.Backspace: - if (!string.IsNullOrEmpty(_filter)) - { - _filter = _filter[..^1]; - _selectedIndex = 0; - } - - break; - - // Interval adjustment. - case ConsoleKey.OemPlus: - case ConsoleKey.Add: - settings.Interval = Math.Min(60, settings.Interval + 1); - break; - case ConsoleKey.OemMinus: - case ConsoleKey.Subtract: - settings.Interval = Math.Max(1, settings.Interval - 1); - break; - - case ConsoleKey.Q: - cts.Cancel(); - return; - - // --- In-view action keys --- - case ConsoleKey.R when view == WorkbenchView.Observers: - case ConsoleKey.R when view == WorkbenchView.ObserverDetail: - { - var obs = view == WorkbenchView.ObserverDetail - ? FindObserverById(_focusedId) - : GetSelectedObserver(); - if (obs is not null) - { - SetPendingAction(new PendingAction( - $"Replay observer '{TruncateForPrompt(obs.Id)}'", - $"Replay started for observer '{TruncateForPrompt(obs.Id)}'", - ct => _services!.Observers.Replay(new Replay - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - ObserverId = obs.Id, - EventSequenceId = CliDefaults.DefaultEventSequenceId - }))); - } - - return; - } - - case ConsoleKey.T when view == WorkbenchView.FailedPartitions: - case ConsoleKey.T when view == WorkbenchView.FailedPartitionDetail: - { - var fp = view == WorkbenchView.FailedPartitionDetail - ? FindFailedPartitionByFocusedId(_focusedId) - : GetSelectedFailedPartition(); - if (fp is not null) - { - SetPendingAction(new PendingAction( - $"Retry partition '{fp.Partition}' of '{TruncateForPrompt(fp.ObserverId)}'", - $"Retry started for partition '{fp.Partition}'", - ct => _services!.Observers.RetryPartition(new RetryPartition - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - ObserverId = fp.ObserverId, - Partition = fp.Partition, - EventSequenceId = CliDefaults.DefaultEventSequenceId - }))); - } - - return; - } - - case ConsoleKey.P when view == WorkbenchView.FailedPartitions: - case ConsoleKey.P when view == WorkbenchView.FailedPartitionDetail: - { - var fp = view == WorkbenchView.FailedPartitionDetail - ? FindFailedPartitionByFocusedId(_focusedId) - : GetSelectedFailedPartition(); - if (fp is not null) - { - SetPendingAction(new PendingAction( - $"Replay partition '{fp.Partition}' of '{TruncateForPrompt(fp.ObserverId)}'", - $"Replay started for partition '{fp.Partition}'", - ct => _services!.Observers.ReplayPartition(new ReplayPartition - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - ObserverId = fp.ObserverId, - Partition = fp.Partition, - EventSequenceId = CliDefaults.DefaultEventSequenceId - }))); - } - - return; - } - - // In ObserverDetail, P navigates to the projection definition for projection-type observers. - case ConsoleKey.P when view == WorkbenchView.ObserverDetail: - { - var obs = FindObserverById(_focusedId); - if (obs is not null && _lastData is not null - && obs.Type.ToString().Contains("Projection", StringComparison.OrdinalIgnoreCase)) - { - var projDef = _lastData.ProjectionDefinitions - .FirstOrDefault(d => string.Equals(d.Identifier, obs.Id, StringComparison.OrdinalIgnoreCase)); - if (projDef is not null) - NavigateToDetail(WorkbenchView.ProjectionDetail, projDef.Identifier); - } - - break; - } - - case ConsoleKey.S when view == WorkbenchView.Jobs: - { - var job = GetSelectedJob(); - if (job is not null) - { - SetPendingAction(new PendingAction( - $"Stop job '{TruncateForPrompt(job.Type ?? job.Id.ToString())}'", - "Job stopped", - ct => _services!.Jobs.Stop(new StopJob - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - JobId = job.Id - }))); - } - - return; - } - - case ConsoleKey.U when view == WorkbenchView.Jobs: - { - var job = GetSelectedJob(); - if (job is not null) - { - SetPendingAction(new PendingAction( - $"Resume job '{TruncateForPrompt(job.Type ?? job.Id.ToString())}'", - "Job resumed", - ct => _services!.Jobs.Resume(new ResumeJob - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - JobId = job.Id - }))); - } - - return; - } - - case ConsoleKey.A when view == WorkbenchView.Recommendations: - { - var rec = GetSelectedRecommendation(); - if (rec is not null) - { - SetPendingAction(new PendingAction( - $"Apply recommendation '{TruncateForPrompt(rec.Name ?? rec.Id.ToString())}'", - "Recommendation applied", - ct => _services!.Recommendations.Perform(new Perform - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - RecommendationId = rec.Id - }))); - } - - return; - } - - case ConsoleKey.I when view == WorkbenchView.Recommendations: - { - var rec = GetSelectedRecommendation(); - if (rec is not null) - { - SetPendingAction(new PendingAction( - $"Ignore recommendation '{TruncateForPrompt(rec.Name ?? rec.Id.ToString())}'", - "Recommendation ignored", - ct => _services!.Recommendations.Ignore(new Perform - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - RecommendationId = rec.Id - }))); - } - - return; - } - - // In EventDetail, T navigates to the event type detail view. - case ConsoleKey.T when view == WorkbenchView.EventDetail: - var evtForType = _lastData?.RecentEvents - .FirstOrDefault(e => e.Context.SequenceNumber.ToString() == _focusedId); - var etToNav = evtForType?.Context.EventType; - if (etToNav is not null) - NavigateToDetail(WorkbenchView.EventTypeDetail, $"{etToNav.Id}+{etToNav.Generation}"); - break; - - // Event Log sort order toggle — also resets to page 0. - case ConsoleKey.S when view == WorkbenchView.EventLog: - _eventLogAscending = 1 - _eventLogAscending; - _eventLogPage = 0; - _selectedIndex = 0; - break; - - // Event Log paging — PageDown goes to older events, PageUp goes to newer. - case ConsoleKey.PageDown when view == WorkbenchView.EventLog: - var totalEvents = _lastData?.RecentEvents.Count ?? 0; - var maxPage = Math.Max(0, (totalEvents - 1) / EventLogPageSize); - _eventLogPage = Math.Min(maxPage, _eventLogPage + 1); - _selectedIndex = 0; - break; - - case ConsoleKey.PageUp when view == WorkbenchView.EventLog: - _eventLogPage = Math.Max(0, _eventLogPage - 1); - _selectedIndex = 0; - break; - - // Quick navigation to context-switching views from any primary view. - case ConsoleKey.E when !isDetail: - _navStack.Clear(); - _currentView = (int)WorkbenchView.EventStores; - _selectedIndex = 0; - _scrollOffset = 0; - _filter = string.Empty; - _filterInputMode = 0; - _focusedId = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.N when !isDetail: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Namespaces; - _selectedIndex = 0; - _scrollOffset = 0; - _filter = string.Empty; - _filterInputMode = 0; - _focusedId = string.Empty; - _breadcrumb = []; - break; - - // '/' activates inline filter mode in filterable views. - default: - if (!isDetail && IsFilterableView(view) && keyInfo.KeyChar == '/') - { - _filterInputMode = 1; - _filter = string.Empty; - _selectedIndex = 0; - } - - break; - } - - _keyPressSignal.TrySetResult(); - } - - void HandleEnterKey(WorkbenchSettings settings) - { - _filter = string.Empty; - - switch ((WorkbenchView)_currentView) - { - case WorkbenchView.Observers: - if (GetSelectedObserver() is { } obs) - NavigateToDetail(WorkbenchView.ObserverDetail, obs.Id); - break; - - case WorkbenchView.ObserverDetail: - var focusedObs = FindObserverById(_focusedId); - var eventTypes = (focusedObs?.EventTypes ?? []) - .OrderBy(et => et.Id, StringComparer.OrdinalIgnoreCase) - .ToList(); - if (_selectedIndex < eventTypes.Count) - { - var et = eventTypes[_selectedIndex]; - NavigateToDetail(WorkbenchView.EventTypeDetail, $"{et.Id}+{et.Generation}"); - } - - break; - - case WorkbenchView.FailedPartitions: - if (GetSelectedFailedPartition() is { } fp) - NavigateToDetail(WorkbenchView.FailedPartitionDetail, $"{fp.ObserverId}/{fp.Partition}"); - break; - - case WorkbenchView.EventLog: - if (GetSelectedEvent() is { } evt) - NavigateToDetail(WorkbenchView.EventDetail, evt.Context.SequenceNumber.ToString()); - break; - - case WorkbenchView.EventTypes: - if (GetSelectedEventTypeRegistration() is { } et2) - NavigateToDetail(WorkbenchView.EventTypeDetail, $"{et2.Type.Id}+{et2.Type.Generation}"); - break; - - case WorkbenchView.Projections: - if (GetSelectedProjectionDefinition() is { } proj) - NavigateToDetail(WorkbenchView.ProjectionDetail, proj.Identifier); - break; - - case WorkbenchView.ReadModels: - if (GetSelectedReadModel() is { } rm) - NavigateToDetail(WorkbenchView.ReadModelDetail, rm.ContainerName); - break; - - case WorkbenchView.EventStores: - var storeName = GetSelectedEventStoreName(); - if (storeName is not null) - { - _activeEventStore = storeName; - _activeNamespace = null; - _navStack.Clear(); - _currentView = (int)WorkbenchView.Overview; - _selectedIndex = 0; - _filter = string.Empty; - _filterInputMode = 0; - _focusedId = string.Empty; - _breadcrumb = []; - } - break; - - case WorkbenchView.Namespaces: - var nsName = GetSelectedNamespaceName(); - if (nsName is not null) - { - _activeNamespace = nsName; - _navStack.Clear(); - _currentView = (int)WorkbenchView.Overview; - _selectedIndex = 0; - _filter = string.Empty; - _filterInputMode = 0; - _focusedId = string.Empty; - _breadcrumb = []; - } - break; - } - - _keyPressSignal.TrySetResult(); - } } diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs index 169a44f..75f0b22 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs @@ -1,7 +1,9 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.Chronicle.Contracts.Identities; using Cratis.Chronicle.Contracts.Jobs; +using Cratis.Chronicle.Contracts.Observation.EventStoreSubscriptions; namespace Cratis.Cli.Commands.Chronicle.Workbench; @@ -47,6 +49,10 @@ public record WorkbenchReadModel( /// Instances for the currently focused read model (populated in ReadModelDetail view only). /// Total instance count for the currently focused read model. /// Error message from the last read model instances fetch, or . +/// All registered OAuth applications on the Chronicle server. +/// All registered users on the Chronicle server. +/// All known identities in the current event store namespace. +/// All event store subscriptions configured for the current event store. public record WorkbenchData( string ConnectionString, string EventStore, @@ -69,7 +75,11 @@ public record WorkbenchData( IReadOnlyList NamespaceNames, IReadOnlyList ReadModelInstances, int ReadModelInstancesTotalCount, - string? ReadModelInstancesError) + string? ReadModelInstancesError, + IReadOnlyList Applications, + IReadOnlyList Users, + IReadOnlyList Identities, + IReadOnlyList EventStoreSubscriptions) { /// /// Gets the number of observers in the state. @@ -118,5 +128,9 @@ public record WorkbenchData( NamespaceNames: [], ReadModelInstances: [], ReadModelInstancesTotalCount: 0, - ReadModelInstancesError: null); + ReadModelInstancesError: null, + Applications: [], + Users: [], + Identities: [], + EventStoreSubscriptions: []); } diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs new file mode 100644 index 0000000..573b363 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs @@ -0,0 +1,383 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Chronicle.Contracts.Identities; +using Cratis.Chronicle.Contracts.Jobs; +using Cratis.Chronicle.Contracts.Observation.EventStoreSubscriptions; +using Cratis.Cli.Commands.Chronicle.ReadModels; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Fetches all Chronicle server data needed by the workbench in a single async snapshot. +/// All independent gRPC calls are executed in parallel to minimise fetch latency. +/// +/// The Chronicle gRPC service clients. +/// The resolved workbench settings (connection string, event store, namespace). +public class WorkbenchDataService(IServices services, WorkbenchSettings settings) +{ + /// Total events fetched from the server per refresh — determines how many pages are available in the Event Log view. + public const int EventLogFetchWindow = 500; + + static readonly JsonSerializerOptions _instanceJsonOptions = new() { WriteIndented = true }; + + /// + /// Fetches a point-in-time snapshot of all data from the Chronicle server. + /// All independent gRPC calls run in parallel; only the recent-events fetch waits on the tail-sequence result. + /// + /// Override for the active event store (null uses settings default). + /// Override for the active namespace (null uses settings default). + /// Container name when fetching read model instances (non-null triggers an instances fetch). + /// Cancellation token. + /// A snapshot. + public async Task FetchAsync( + string? activeEventStore, + string? activeNamespace, + string? readModelContainerName, + CancellationToken ct) + { + var eventStore = activeEventStore ?? settings.ResolveEventStore(); + var ns = activeNamespace ?? settings.ResolveNamespace(); + var connectionString = settings.ResolveConnectionString(); + + // Run all independent calls in parallel. + var versionTask = FetchVersionAsync(); + var eventStoresTask = FetchEventStoresAsync(); + var observersTask = FetchObserversAsync(eventStore, ns); + var failedPartitionsTask = FetchFailedPartitionsAsync(eventStore, ns); + var jobsTask = FetchJobsAsync(eventStore, ns); + var recommendationsTask = FetchRecommendationsAsync(eventStore, ns); + var tailTask = FetchTailSequenceNumberAsync(eventStore, ns); + var eventTypesTask = FetchEventTypesAsync(eventStore); + var projectionsTask = FetchProjectionDefinitionsAsync(eventStore); + var declarationsTask = FetchProjectionDeclarationsAsync(eventStore); + var readModelsTask = FetchReadModelDefinitionsAsync(eventStore); + var namespacesTask = FetchNamespacesAsync(eventStore); + var applicationsTask = FetchApplicationsAsync(); + var usersTask = FetchUsersAsync(); + var identitiesTask = FetchIdentitiesAsync(eventStore, ns); + var subscriptionsTask = FetchSubscriptionsAsync(eventStore); + + await Task.WhenAll(versionTask, eventStoresTask, observersTask, failedPartitionsTask, jobsTask, recommendationsTask, tailTask, eventTypesTask, projectionsTask, declarationsTask, readModelsTask, namespacesTask, applicationsTask, usersTask, identitiesTask, subscriptionsTask).ConfigureAwait(false); + + // All tasks are complete — awaiting them returns immediately. + var (serverVersion, isConnected) = await versionTask.ConfigureAwait(false); + var tailSequenceNumber = await tailTask.ConfigureAwait(false); + var eventStoreNames = await eventStoresTask.ConfigureAwait(false); + var observers = await observersTask.ConfigureAwait(false); + var failedPartitions = await failedPartitionsTask.ConfigureAwait(false); + var jobs = await jobsTask.ConfigureAwait(false); + var recommendations = await recommendationsTask.ConfigureAwait(false); + var eventTypeRegistrations = await eventTypesTask.ConfigureAwait(false); + var projectionDefinitions = await projectionsTask.ConfigureAwait(false); + var projectionDeclarations = await declarationsTask.ConfigureAwait(false); + var readModelDefinitions = await readModelsTask.ConfigureAwait(false); + var namespaceNames = await namespacesTask.ConfigureAwait(false); + var applications = await applicationsTask.ConfigureAwait(false); + var users = await usersTask.ConfigureAwait(false); + var identities = await identitiesTask.ConfigureAwait(false); + var subscriptions = await subscriptionsTask.ConfigureAwait(false); + + // Recent events depends on the tail — fetch after the parallel batch. + var recentEvents = await FetchRecentEventsAsync(eventStore, ns, tailSequenceNumber).ConfigureAwait(false); + + // Read model instances are optional and fetched only when a container is specified. + var (readModelInstances, readModelInstancesTotalCount, readModelInstancesError) = + await FetchReadModelInstancesAsync(eventStore, ns, readModelContainerName).ConfigureAwait(false); + + return new WorkbenchData( + ConnectionString: connectionString, + EventStore: eventStore, + Namespace: ns, + IsConnected: isConnected, + ServerVersion: serverVersion, + EventStoreNames: eventStoreNames, + Observers: observers, + FailedPartitions: failedPartitions, + Jobs: jobs, + Recommendations: recommendations, + TailSequenceNumber: tailSequenceNumber, + CapturedAt: DateTimeOffset.Now, + FetchError: null, + EventTypeRegistrations: eventTypeRegistrations, + ProjectionDefinitions: projectionDefinitions, + ProjectionDeclarations: projectionDeclarations, + RecentEvents: recentEvents, + ReadModelDefinitions: readModelDefinitions, + NamespaceNames: namespaceNames, + ReadModelInstances: readModelInstances, + ReadModelInstancesTotalCount: readModelInstancesTotalCount, + ReadModelInstancesError: readModelInstancesError, + Applications: applications, + Users: users, + Identities: identities, + EventStoreSubscriptions: subscriptions); + } + + /// + /// Fetches only the new events appended since for the live event stream. + /// + /// The last known sequence number; only events with higher numbers are returned. + /// Override for the active event store (null uses settings default). + /// Override for the active namespace (null uses settings default). + /// Cancellation token. + /// New events ordered oldest-first, or an empty list if none. + public async Task> FetchNewEventsAsync( + ulong afterSequenceNumber, + string? activeEventStore, + string? activeNamespace, + CancellationToken ct) + { + var eventStore = activeEventStore ?? settings.ResolveEventStore(); + var ns = activeNamespace ?? settings.ResolveNamespace(); + + try + { + var eventsResp = await services.EventSequences.GetEventsFromEventSequenceNumber( + new GetFromEventSequenceNumberRequest + { + EventStore = eventStore, + Namespace = ns, + EventSequenceId = CliDefaults.DefaultEventSequenceId, + FromEventSequenceNumber = afterSequenceNumber + 1 + }).ConfigureAwait(false); + return [.. eventsResp.Events.OrderBy(e => e.Context.SequenceNumber)]; + } + catch + { + return []; + } + } + + async Task<(string? Version, bool IsConnected)> FetchVersionAsync() + { + try + { + var info = await services.Server.GetVersionInfo().ConfigureAwait(false); + return (info.Version, true); + } + catch + { + return (null, false); + } + } + + async Task> FetchEventStoresAsync() + { + try { return [.. await services.EventStores.GetEventStores().ConfigureAwait(false)]; } + catch { return []; } + } + + async Task> FetchObserversAsync(string eventStore, string ns) + { + try + { + return [.. await services.Observers.GetObservers(new AllObserversRequest + { + EventStore = eventStore, + Namespace = ns + }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchFailedPartitionsAsync(string eventStore, string ns) + { + try + { + return [.. await services.FailedPartitions.GetFailedPartitions(new GetFailedPartitionsRequest + { + EventStore = eventStore, + Namespace = ns + }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchJobsAsync(string eventStore, string ns) + { + try + { + return [.. (await services.Jobs.GetJobs(new GetJobsRequest + { + EventStore = eventStore, + Namespace = ns + }).ConfigureAwait(false)) ?? []]; + } + catch { return []; } + } + + async Task> FetchRecommendationsAsync(string eventStore, string ns) + { + try + { + return [.. await services.Recommendations.GetRecommendations(new GetRecommendationsRequest + { + EventStore = eventStore, + Namespace = ns + }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task FetchTailSequenceNumberAsync(string eventStore, string ns) + { + try + { + var tail = await services.EventSequences.GetTailSequenceNumber(new GetTailSequenceNumberRequest + { + EventStore = eventStore, + Namespace = ns, + EventSequenceId = CliDefaults.DefaultEventSequenceId + }).ConfigureAwait(false); + return tail.SequenceNumber == ulong.MaxValue ? null : tail.SequenceNumber; + } + catch { return null; } + } + + async Task> FetchEventTypesAsync(string eventStore) + { + try + { + return [.. await services.EventTypes.GetAllRegistrations( + new GetAllEventTypesRequest { EventStore = eventStore }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchProjectionDefinitionsAsync(string eventStore) + { + try + { + return [.. await services.Projections.GetAllDefinitions( + new GetAllDefinitionsRequest { EventStore = eventStore }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchProjectionDeclarationsAsync(string eventStore) + { + try + { + var declarations = await services.Projections.GetAllDeclarations( + new GetAllDeclarationsRequest { EventStore = eventStore }).ConfigureAwait(false); + return declarations.ToDictionary(d => d.Identifier, d => d.Declaration ?? string.Empty); + } + catch { return new Dictionary(); } + } + + async Task> FetchRecentEventsAsync(string eventStore, string ns, ulong? tailSequenceNumber) + { + try + { + if (tailSequenceNumber is null or 0) return []; + var fromSeq = tailSequenceNumber.Value >= EventLogFetchWindow + ? tailSequenceNumber.Value - EventLogFetchWindow + 1 + : 0; + var eventsResp = await services.EventSequences.GetEventsFromEventSequenceNumber( + new GetFromEventSequenceNumberRequest + { + EventStore = eventStore, + Namespace = ns, + EventSequenceId = CliDefaults.DefaultEventSequenceId, + FromEventSequenceNumber = fromSeq + }).ConfigureAwait(false); + return [.. eventsResp.Events.OrderByDescending(e => e.Context.SequenceNumber)]; + } + catch { return []; } + } + + async Task> FetchReadModelDefinitionsAsync(string eventStore) + { + try + { + var defs = await services.ReadModels.GetDefinitions( + new GetDefinitionsRequest { EventStore = eventStore }).ConfigureAwait(false); + return [.. defs.ReadModels.Select(rm => new WorkbenchReadModel( + rm.ContainerName, + rm.DisplayName, + rm.Owner.ToString(), + !string.Equals(rm.Owner.ToString(), "Client", StringComparison.Ordinal), + rm.Source.ToString(), + rm.Type?.Identifier ?? string.Empty))]; + } + catch { return []; } + } + + async Task> FetchNamespacesAsync(string eventStore) + { + try + { + return [.. await services.Namespaces.GetNamespaces( + new GetNamespacesRequest { EventStore = eventStore }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchApplicationsAsync() + { + try { return [.. await services.Applications.GetAll().ConfigureAwait(false) ?? []]; } + catch { return []; } + } + + async Task> FetchUsersAsync() + { + try { return [.. await services.Users.GetAll().ConfigureAwait(false) ?? []]; } + catch { return []; } + } + + async Task> FetchIdentitiesAsync(string eventStore, string ns) + { + try + { + return [.. await services.Identities.GetIdentities(new GetIdentitiesRequest + { + EventStore = eventStore, + Namespace = ns + }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchSubscriptionsAsync(string eventStore) + { + try + { + return [.. await services.EventStoreSubscriptions.GetSubscriptions(new GetEventStoreSubscriptionsRequest + { + TargetEventStore = eventStore + }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task<(IReadOnlyList Instances, int TotalCount, string? Error)> FetchReadModelInstancesAsync( + string eventStore, + string ns, + string? containerName) + { + if (string.IsNullOrEmpty(containerName)) return ([], 0, null); + try + { + var instResp = await services.ReadModels.GetInstances(new GetInstancesRequest + { + EventStore = eventStore, + Namespace = ns, + ReadModel = containerName, + Page = 0, + PageSize = 20 + }).ConfigureAwait(false); + var totalCount = (int)Math.Min(instResp.TotalCount, int.MaxValue); + var instances = (IReadOnlyList)[.. (instResp.Instances ?? []) + .Select(ReadModelJsonCleaner.CleanInstance) + .Where(o => o is not null) + .Select(o => JsonSerializer.Serialize(o, _instanceJsonOptions))]; + return (instances, totalCount, null); + } + catch (Exception ex) + { + return ([], 0, ex.Message); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchHints.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchHints.cs new file mode 100644 index 0000000..d2a9f86 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchHints.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// User-facing hint strings displayed in the system status panels and help overlays. +/// Centralised here so there are no magic strings scattered across the workbench. +/// +static class WorkbenchHints +{ + /// Hint text shown in the system bottom panel at all times. + public const string BottomBar = "F Filter | ? Help | Ctrl+P Palette | Q Quit"; +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.DetailViews.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.DetailViews.cs deleted file mode 100644 index c13316f..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.DetailViews.cs +++ /dev/null @@ -1,265 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System.Text.Json; -using Spectre.Console.Rendering; - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -public static partial class WorkbenchRenderer -{ - static readonly JsonSerializerOptions _prettyJson = new() { WriteIndented = true }; - - static Rows BuildObserverDetailPage(WorkbenchData data, string focusedId, int selectedIndex) - { - var obs = data.Observers.FirstOrDefault(o => o.Id == focusedId); - if (obs is null) - { - return new Rows(new Panel(new Markup($"[{_dan}]Observer '{focusedId.EscapeMarkup()}' not found.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()); - } - - var lag = ComputeLag(obs, data.TailSequenceNumber); - var lagText = lag switch { null => $"[{_mut}]—[/]", 0 => $"[{_suc}]caught up[/]", _ => $"[{_war}]{lag.Value:N0} events behind tail ({data.TailSequenceNumber?.ToString("N0") ?? "—"})[/]" }; - - var infoPanel = new Panel(new Rows( - new Markup($" [{_mut}]type[/] [bold]{obs.Type.ToString().EscapeMarkup()}[/] [{_mut}]owner[/] {obs.Owner.ToString().EscapeMarkup()} [{_mut}]sequence[/] [{_mut}]{obs.EventSequenceId.EscapeMarkup()}[/] [{_mut}]replayable[/] {(obs.IsReplayable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")}"), - new Markup($" [{_mut}]state[/] {StateIcon(obs.RunningState)} {StateName(obs.RunningState)} [{_mut}]subscribed[/] {(obs.IsSubscribed ? $"[{_suc}]yes[/]" : $"[{_dan}]no[/]")}"), - new Markup($" [{_mut}]next seq[/] {FormatSeq(obs.NextEventSequenceNumber)} [{_mut}]last handled[/] {FormatSeq(obs.LastHandledEventSequenceNumber)} [{_mut}]lag[/] {lagText}"))) - .Header($"[{_acc}] {obs.Id.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - var eventTypes = (obs.EventTypes ?? []).OrderBy(et => et.Id, StringComparer.OrdinalIgnoreCase).ToList(); - IRenderable eventTypesSection; - if (eventTypes.Count == 0) - { - eventTypesSection = new Panel(new Markup($" [{_mut}]No subscribed event types.[/]")) - .Header($"[{_acc}] Subscribed Event Types [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand(); - } - else - { - var etEffective = Math.Min(selectedIndex, eventTypes.Count - 1); - var etTable = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]EventType[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Generation[/]").Padding(1, 0).Width(12).NoWrap()); - - var (winStart, winEnd) = ListWindow(eventTypes.Count, etEffective); - if (winStart > 0) - etTable.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var et = eventTypes[i]; - var isSel = i == etEffective; - etTable.AddRow( - new Markup(isSel ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSel ? $"[bold {_acc}]{et.Id.EscapeMarkup()}[/]" : et.Id.EscapeMarkup()), - new Markup($"[{_mut}]{et.Generation}[/]")); - } - - if (winEnd < eventTypes.Count - 1) - etTable.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {eventTypes.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty)); - - eventTypesSection = new Panel(etTable) - .Header($"[{_acc}] Subscribed Event Types ({eventTypes.Count}) ↑↓ select [[ Enter ]] view schema [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - } - - if (obs.Type.ToString().Contains("Projection", StringComparison.OrdinalIgnoreCase)) - { - var projHint = new Panel(new Markup($" [{_acc}][[ P ]][/] [{_mut}]View this observer's projection definition[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand(); - return new Rows(infoPanel, eventTypesSection, projHint); - } - - return new Rows(infoPanel, eventTypesSection); - } - - static Rows BuildFailedPartitionDetailPage(WorkbenchData data, string focusedId, int scrollOffset) - { - var sep = focusedId.IndexOf('/'); - FailedPartition? fp = null; - if (sep >= 0) - { - var obsId = focusedId[..sep]; - var partition = focusedId[(sep + 1)..]; - fp = data.FailedPartitions.FirstOrDefault(f => f.ObserverId == obsId && f.Partition == partition); - } - - if (fp is null) - { - return new Rows(new Panel(new Markup($"[{_dan}]Failed partition not found.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()); - } - - var attempts = fp.Attempts.OrderByDescending(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue).ToList(); - var lastAttempt = attempts.FirstOrDefault(); - var msg = (lastAttempt?.Messages.FirstOrDefault() ?? "—").EscapeMarkup(); - var stackTrace = lastAttempt?.StackTrace ?? string.Empty; - - var header = new Panel(new Markup( - $" [{_mut}]observer[/] [{_dan}]{fp.ObserverId.EscapeMarkup()}[/] [{_mut}]partition[/] {fp.Partition.EscapeMarkup()} [{_mut}]attempts[/] [{_dan}]{attempts.Count}[/]\n" + - $" [{_mut}]last failed[/] [{_dan}]{(lastAttempt is not null ? lastAttempt.Occurred.ToString() : "—").EscapeMarkup()}[/]")) - .Header($"[{_dan}] Failure Detail [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand(); - - var msgPanel = new Panel(new Markup($" [{_dan}]{msg}[/]")) - .Header($"[{_dan}] Last Error [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand(); - - var stackLines = (string.IsNullOrEmpty(stackTrace) ? ["(no stack trace)"] : stackTrace.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]").ToList()) - as IReadOnlyList; - var stackPanel = ScrollableText("Stack Trace", stackLines, scrollOffset, OutputFormatter.Muted); - - return new Rows(header, msgPanel, stackPanel); - } - - static Rows BuildEventDetailPage(WorkbenchData data, string focusedId, int scrollOffset) - { - AppendedEvent? evt = null; - if (ulong.TryParse(focusedId, out var seq)) - evt = data.RecentEvents.FirstOrDefault(e => e.Context.SequenceNumber == seq); - - if (evt is null) - { - return new Rows(new Panel(new Markup($"[{_dan}]Event not found (seq# {focusedId.EscapeMarkup()}).[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()); - } - - var ctx = evt.Context; - var header = new Panel(new Rows( - new Markup($" [{_mut}]seq#[/] [bold]{ctx.SequenceNumber:N0}[/] [{_mut}]eventType[/] [bold {_acc}]{(ctx.EventType?.Id ?? "—").EscapeMarkup()}[/] [{_mut}]+{ctx.EventType?.Generation}[/]"), - new Markup($" [{_mut}]eventSourceId[/] {(ctx.EventSourceId ?? "—").EscapeMarkup()} [{_mut}]occurred[/] [{_mut}]{ctx.Occurred.ToString().EscapeMarkup()}[/]"), - new Markup($" [{_mut}]correlationId[/] [{_mut}]{ctx.CorrelationId.ToString().EscapeMarkup()}[/]"))) - .Header($"[{_acc}] Event seq# {ctx.SequenceNumber:N0} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - List contentLines; - if (string.IsNullOrWhiteSpace(evt.Content)) - { - contentLines = [$" [{_mut}](no content)[/]"]; - } - else - { - try - { - var pretty = JsonSerializer.Serialize( - JsonSerializer.Deserialize(evt.Content), - _prettyJson); - contentLines = [.. pretty.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")]; - } - catch - { - contentLines = [$" [{_mut}]{evt.Content.EscapeMarkup()}[/]"]; - } - } - - var contentPanel = ScrollableText("Content", contentLines, scrollOffset, OutputFormatter.Muted); - return new Rows(header, contentPanel); - } - - static Rows BuildEventTypeDetailPage(WorkbenchData data, string focusedId, int scrollOffset) - { - var parts = focusedId.Split('+'); - var typeId = parts[0]; - var gen = parts.Length > 1 ? parts[1] : string.Empty; - var reg = data.EventTypeRegistrations - .FirstOrDefault(r => string.Equals(r.Type.Id, typeId, StringComparison.OrdinalIgnoreCase) - && r.Type.Generation.ToString() == gen); - - if (reg is null) - { - return new Rows(new Panel(new Markup($"[{_dan}]Event type '{focusedId.EscapeMarkup()}' not found.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()); - } - - var header = new Panel(new Markup( - $" [{_mut}]id[/] [bold]{reg.Type.Id.EscapeMarkup()}[/] [{_mut}]generation[/] {reg.Type.Generation} [{_mut}]owner[/] {reg.Owner.ToString().EscapeMarkup()} [{_mut}]source[/] {reg.Source.ToString().EscapeMarkup()} [{_mut}]tombstone[/] {reg.Type.Tombstone}")) - .Header($"[{_acc}] {reg.Type.Id.EscapeMarkup()} +{reg.Type.Generation} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - List schemaLines; - if (string.IsNullOrWhiteSpace(reg.Schema)) - { - schemaLines = [$" [{_mut}](no schema)[/]"]; - } - else - { - try - { - var pretty = JsonSerializer.Serialize( - JsonSerializer.Deserialize(reg.Schema), - _prettyJson); - schemaLines = [.. pretty.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")]; - } - catch - { - schemaLines = [.. reg.Schema.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")]; - } - } - - var schemaPanel = ScrollableText("JSON Schema", schemaLines, scrollOffset, OutputFormatter.Muted); - return new Rows(header, schemaPanel); - } - - static Rows BuildReadModelDetailPage(WorkbenchData data, string focusedId, int scrollOffset) - { - var def = data.ReadModelDefinitions - .FirstOrDefault(d => string.Equals(d.ContainerName, focusedId, StringComparison.OrdinalIgnoreCase)); - - var header = new Panel(new Markup( - $" [{_mut}]container[/] [bold]{focusedId.EscapeMarkup()}[/] [{_mut}]owner[/] {(def?.Owner ?? "—").EscapeMarkup()} [{_mut}]queryable[/] {(def?.IsQueryable == true ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]total[/] [bold]{data.ReadModelInstancesTotalCount:N0}[/]")) - .Header($"[{_acc}] {focusedId.EscapeMarkup()} — Instances [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - IReadOnlyList lines; - if (data.ReadModelInstancesError is not null) - { - lines = [$" [{_dan}]Error fetching instances: {data.ReadModelInstancesError.EscapeMarkup()}[/]"]; - } - else if (data.ReadModelInstances.Count == 0) - { - lines = [$" [{_mut}](no instances — model may be client-owned or empty)[/]"]; - } - else - { - var allLines = new List(); - for (var i = 0; i < data.ReadModelInstances.Count; i++) - { - if (i > 0) allLines.Add($" [{_mut}]─────────────────────────────────────────[/]"); - allLines.Add($" [{_acc}]Instance {i + 1}[/]"); - allLines.AddRange(data.ReadModelInstances[i].Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")); - } - if (data.ReadModelInstancesTotalCount > data.ReadModelInstances.Count) - allLines.Add($"\n [{_mut}]showing {data.ReadModelInstances.Count} of {data.ReadModelInstancesTotalCount:N0} total instances[/]"); - lines = allLines; - } - - var instancesPanel = ScrollableText("Instances (↑↓ scroll)", lines, scrollOffset, OutputFormatter.Muted); - return new Rows(header, instancesPanel); - } - - static Rows BuildProjectionDetailPage(WorkbenchData data, string focusedId, int scrollOffset) - { - var def = data.ProjectionDefinitions - .FirstOrDefault(d => string.Equals(d.Identifier, focusedId, StringComparison.OrdinalIgnoreCase)); - - if (def is null) - { - return new Rows(new Panel(new Markup($"[{_dan}]Projection '{focusedId.EscapeMarkup()}' not found.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()); - } - - data.ProjectionDeclarations.TryGetValue(def.Identifier, out var declaration); - - var header = new Panel(new Markup( - $" [{_mut}]readModel[/] {def.ReadModel.EscapeMarkup()} [{_mut}]active[/] {(def.IsActive ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]rewindable[/] {(def.IsRewindable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]autoMap[/] {def.AutoMap.ToString().EscapeMarkup()}")) - .Header($"[{_acc}] {def.Identifier.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - IReadOnlyList declLines = string.IsNullOrWhiteSpace(declaration) - ? [$" [{_mut}](no declaration available)[/]"] - : [.. declaration.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")]; - - var declPanel = ScrollableText("Declaration", declLines, scrollOffset, OutputFormatter.Muted); - return new Rows(header, declPanel); - } -} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.Views.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.Views.cs deleted file mode 100644 index 8db9711..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.Views.cs +++ /dev/null @@ -1,655 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using Cratis.Chronicle.Contracts.Jobs; -using Spectre.Console.Rendering; - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -public static partial class WorkbenchRenderer -{ - const int EventLogPageSize = 50; - - static Rows BuildOverview(WorkbenchData data) - { - var topRow = new Table().HideHeaders().NoBorder().Expand() - .AddColumn(new TableColumn(string.Empty)) - .AddColumn(new TableColumn(string.Empty)); - topRow.AddRow(BuildServerPanel(data), BuildObserverStatsPanel(data)); - - var contextPanel = new Panel(new Rows( - new Markup($" [{_mut}]event store[/] [bold {_acc}]{data.EventStore.EscapeMarkup()}[/] [{_mut}][[ E ]][/] change"), - new Markup($" [{_mut}]namespace[/] [bold {_acc}]{data.Namespace.EscapeMarkup()}[/] [{_mut}][[ N ]][/] change"))) - .Header($"[{_acc}] Active Context [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand(); - - var sections = new List { topRow, contextPanel }; - var attentionPanel = BuildAttentionPanel(data); - if (attentionPanel is not null) sections.Add(attentionPanel); - return new Rows(sections); - } - - static Panel BuildServerPanel(WorkbenchData data) - { - var connLine = data.IsConnected - ? $" [{_suc}]✓[/] connected [{_mut}]{(data.ServerVersion ?? "—").EscapeMarkup()}[/]" - : $" [{_dan}]✗[/] [{_dan}]disconnected[/]"; - var storesLine = $" [{_mut}]·[/] [{_mut}]{data.EventStoreNames.Count} event store{(data.EventStoreNames.Count == 1 ? string.Empty : "s")}[/]"; - var tailLine = data.TailSequenceNumber.HasValue - ? $" [{_mut}]·[/] [bold]{data.TailSequenceNumber.Value:N0}[/] [{_mut}]events in log[/]" - : $" [{_mut}]· event log unavailable[/]"; - - return new Panel(new Rows(new Markup(connLine), new Markup(storesLine), new Markup(tailLine))) - .Header($"[{_acc}] Server [/]") - .Border(BoxBorder.Rounded) - .BorderColor(OutputFormatter.Muted); - } - - static Panel BuildObserverStatsPanel(WorkbenchData data) - { - var total = data.Observers.Count; - var line1 = $" [{_suc}]●[/] [bold {_suc}]{data.ActiveObservers,3}[/] [{_suc}]Active[/] [{_war}]▲[/] [bold {_war}]{data.ReplayingObservers,3}[/] [{_war}]Replaying[/]"; - var line2 = $" [{_mut}]○[/] [bold {_mut}]{data.SuspendedObservers,3}[/] [{_mut}]Suspended[/] [{_mut}]⊘[/] [bold {_mut}]{data.DisconnectedObservers,3}[/] [{_mut}]Disconnected[/]"; - var failureLine = data.FailedPartitions.Count > 0 - ? $" [{_dan}]✗[/] [{_dan}]{data.FailedPartitions.Count} failed partition{(data.FailedPartitions.Count == 1 ? string.Empty : "s")}[/]" - : $" [{_suc}]✓[/] [{_suc}]no failed partitions[/]"; - - return new Panel(new Rows(new Markup(line1), new Markup(line2), new Markup(failureLine))) - .Header($"[{_acc}] Observers ({total}) [/]") - .Border(BoxBorder.Rounded) - .BorderColor(OutputFormatter.Muted); - } - - static Panel? BuildAttentionPanel(WorkbenchData data) - { - var items = new List(); - foreach (var fp in data.FailedPartitions.Take(5)) - { - var lastAttempt = fp.Attempts.MaxBy(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue); - var msg = (lastAttempt?.Messages.FirstOrDefault() ?? "unknown error").EscapeMarkup(); - if (msg.Length > 80) msg = msg[..80] + "…"; - var attempts = fp.Attempts.Count(); - items.Add($" [{_dan}]✗[/] {fp.ObserverId.EscapeMarkup()} [{_mut}]on[/] {fp.Partition.EscapeMarkup()} [{_dan}]{msg}[/] [{_mut}]({attempts} attempt{(attempts == 1 ? string.Empty : "s")})[/]"); - } - - foreach (var rec in data.Recommendations.Take(3)) - { - var desc = (rec.Description ?? string.Empty).EscapeMarkup(); - if (desc.Length > 80) desc = desc[..80] + "…"; - items.Add($" [{_war}]▲[/] [{_war}]{(rec.Name ?? string.Empty).EscapeMarkup()}[/] [{_mut}]{desc}[/]"); - } - - if (items.Count == 0) return null; - - var borderColor = data.FailedPartitions.Count > 0 ? OutputFormatter.Danger : OutputFormatter.Warning; - var headerColor = data.FailedPartitions.Count > 0 ? _dan : _war; - return new Panel(new Rows(items.ConvertAll(i => (IRenderable)new Markup(i)))) - .Header($"[{headerColor}] Attention Needed ({items.Count}) [/]") - .Border(BoxBorder.Rounded) - .BorderColor(borderColor); - } - - static Rows BuildObserversView(WorkbenchData data, int selectedIndex, string filterText) - { - var allObservers = data.Observers.OrderBy(o => StateOrder(o.RunningState)).ThenBy(o => o.Id).ToList(); - var observers = string.IsNullOrEmpty(filterText) - ? allObservers - : [.. allObservers.Where(o => o.Id.Contains(filterText, StringComparison.OrdinalIgnoreCase))]; - var effectiveIndex = observers.Count > 0 ? Math.Min(selectedIndex, observers.Count - 1) : -1; - - var table = new Table() - .Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Observer[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Type[/]").Padding(1, 0).Width(20).NoWrap()) - .AddColumn(new TableColumn("[bold]State[/]").Padding(1, 0).Width(18).NoWrap()); - - AddListRows(table, observers, effectiveIndex, data.TailSequenceNumber, 4); - - var header = string.IsNullOrEmpty(filterText) - ? $"[{_acc}] Observers ({data.Observers.Count}) [/]" - : $"[{_acc}] Observers ({observers.Count}/{data.Observers.Count}) [/]"; - var sections = new List - { - new Panel(table).Header(header).BorderColor(OutputFormatter.Accent).NoBorder() - }; - - if (effectiveIndex >= 0 && observers.Count > 0) - sections.Add(BuildObserverMiniDetail(observers[effectiveIndex], data.TailSequenceNumber)); - - return new Rows(sections); - } - - static void AddListRows(Table table, List observers, int effectiveIndex, ulong? tail, int colCount) - { - if (observers.Count == 0) - { - var empty = Enumerable.Range(0, colCount).Select(i => i == 1 ? (IRenderable)new Markup($"[{_mut}](none)[/]") : new Markup(string.Empty)).ToArray(); - table.AddRow(empty); - return; - } - - var (winStart, winEnd) = ListWindow(observers.Count, effectiveIndex < 0 ? 0 : effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty)); - - for (var i = winStart; i <= winEnd; i++) - { - var obs = observers[i]; - var isSelected = i == effectiveIndex; - var pointer = isSelected ? $"[bold {_acc}]▶[/]" : string.Empty; - table.AddRow( - new Markup(pointer), - new Markup(isSelected ? $"[bold {_acc}]{obs.Id.EscapeMarkup()}[/]" : obs.Id.EscapeMarkup()), - new Markup($"[{_mut}]{obs.Type.ToString().EscapeMarkup()}[/]"), - new Markup($"{StateIcon(obs.RunningState)} {StateName(obs.RunningState)}")); - } - - if (winEnd < observers.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {observers.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty)); - } - - static Panel BuildObserverMiniDetail(ObserverInformation obs, ulong? tail) - { - var line1 = $" [{_mut}]type[/] [bold]{obs.Type.ToString().EscapeMarkup()}[/] [{_mut}]state[/] {StateIcon(obs.RunningState)} {StateName(obs.RunningState)}"; - var line2 = $" [{_mut}]next[/] {FormatSeq(obs.NextEventSequenceNumber)} [{_mut}]handled[/] {FormatSeq(obs.LastHandledEventSequenceNumber)}"; - var line3 = $"\n [{_acc}][[ Enter ]][/] Full detail [{_acc}][[ R ]][/] Replay"; - return new Panel(new Rows(new Markup(line1), new Markup(line2), new Markup(line3))) - .Header($"[{_acc}] {obs.Id.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - } - - static Rows BuildFailedView(WorkbenchData data, int selectedIndex) - { - if (data.FailedPartitions.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_suc}]✓ No failed partitions.[/]\n")) - .Header($"[{_acc}] Failures [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Success)); - } - - var fps = data.FailedPartitions.OrderByDescending(fp => fp.Attempts.Count()).ToList(); - var effectiveIndex = Math.Min(selectedIndex, fps.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Observer[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Partition[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Attempts[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Last Failed[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(fps.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var fp = fps[i]; - var isSelected = i == effectiveIndex; - var lastAttempt = fp.Attempts.MaxBy(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue); - var obsId = fp.ObserverId.EscapeMarkup(); - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{obsId}[/]" : $"[{_dan}]{obsId}[/]"), - new Markup(fp.Partition.EscapeMarkup()), - new Markup(fp.Attempts.Count().ToString()), - new Markup($"[{_dan}]{(lastAttempt is not null ? lastAttempt.Occurred.ToString() : "—").EscapeMarkup()}[/]")); - } - - if (winEnd < fps.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {fps.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - - var selected = fps[effectiveIndex]; - var selLastAttempt = selected.Attempts.MaxBy(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue); - var msg = (selLastAttempt?.Messages.FirstOrDefault() ?? "—").EscapeMarkup(); - if (msg.Length > 80) msg = msg[..80] + "…"; - var miniDetail = new Panel(new Rows( - new Markup($" [{_mut}]partition[/] {selected.Partition.EscapeMarkup()} [{_mut}]attempts[/] [{_dan}]{selected.Attempts.Count()}[/] [{_mut}]last failed[/] [{_dan}]{(selLastAttempt is not null ? selLastAttempt.Occurred.ToString() : "—").EscapeMarkup()}[/]"), - new Markup($" [{_dan}]{msg}[/]"), - new Markup($"\n [{_acc}][[ Enter ]][/] Full detail [{_acc}][[ T ]][/] Retry [{_acc}][[ P ]][/] Replay partition"))) - .Header($"[{_dan}] Failure Detail [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand(); - - return new Rows( - new Panel(table).Header($"[{_dan}] Failures ({fps.Count}) [/]").BorderColor(OutputFormatter.Danger).NoBorder(), - miniDetail); - } - - static Rows BuildJobsView(WorkbenchData data, int selectedIndex) - { - if (data.Jobs.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No background jobs.[/]\n")) - .Header($"[{_acc}] Jobs [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var jobs = data.Jobs.OrderBy(j => j.Status.ToString()).ToList(); - var effectiveIndex = Math.Min(selectedIndex, jobs.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Type[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Status[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Progress[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Created[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(jobs.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var job = jobs[i]; - var isSelected = i == effectiveIndex; - var statusStr = job.Status.ToString(); - string statusColor; - if (statusStr.Contains("Running", StringComparison.OrdinalIgnoreCase)) statusColor = _suc; - else if (statusStr.Contains("Failed", StringComparison.OrdinalIgnoreCase)) statusColor = _dan; - else statusColor = _mut; - string progressCell; - if (job.Progress?.TotalSteps > 0) - { - var bar = ProgressBar(job.Progress.SuccessfulSteps, job.Progress.TotalSteps); - var pct = (int)Math.Min(100, (long)job.Progress.SuccessfulSteps * 100 / job.Progress.TotalSteps); - progressCell = $"{bar} {job.Progress.SuccessfulSteps:N0}/{job.Progress.TotalSteps:N0} [{_mut}]{pct}%[/]"; - } - else - { - progressCell = $"[{_mut}]{(job.Progress?.Message ?? "—").EscapeMarkup()}[/]"; - } - - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{(job.Type ?? "—").EscapeMarkup()}[/]" : (job.Type ?? "—").EscapeMarkup()), - new Markup($"[{statusColor}]{statusStr.EscapeMarkup()}[/]"), - new Markup(progressCell), - new Markup(job.Created.ToString().EscapeMarkup())); - } - - if (winEnd < jobs.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {jobs.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - - return new Rows(new Panel(table).Header($"[{_acc}] Jobs ({jobs.Count}) [/]").BorderColor(OutputFormatter.Accent).NoBorder()); - } - - static Rows BuildRecommendationsView(WorkbenchData data, int selectedIndex) - { - if (data.Recommendations.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_suc}]✓ No pending recommendations.[/]\n")) - .Header($"[{_acc}] Recommendations [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Success)); - } - - var recs = data.Recommendations.ToList(); - var effectiveIndex = Math.Min(selectedIndex, recs.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Warning).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Recommendation[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Type[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Occurred[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(recs.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var rec = recs[i]; - var isSelected = i == effectiveIndex; - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{(rec.Name ?? "—").EscapeMarkup()}[/]" : $"[{_war}]{(rec.Name ?? "—").EscapeMarkup()}[/]"), - new Markup($"[{_mut}]{(rec.Type ?? "—").EscapeMarkup()}[/]"), - new Markup($"[{_mut}]{rec.Occurred.ToString().EscapeMarkup()}[/]")); - } - - if (winEnd < recs.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {recs.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty)); - - var selRec = recs[effectiveIndex]; - var desc = (selRec.Description ?? string.Empty).EscapeMarkup(); - if (desc.Length > 100) desc = desc[..100] + "…"; - var detail = new Panel(new Rows( - new Markup($" [{_mut}]type[/] [{_war}]{(selRec.Type ?? "—").EscapeMarkup()}[/] [{_mut}]occurred[/] [{_mut}]{selRec.Occurred.ToString().EscapeMarkup()}[/]"), - new Markup($" [{_mut}]{desc}[/]"), - new Markup($"\n [{_acc}][[ A ]][/] Apply [{_acc}][[ I ]][/] Ignore"))) - .Header($"[{_war}] {(selRec.Name ?? "—").EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Warning).Expand(); - - return new Rows(new Panel(table).Header($"[{_war}] Recommendations ({recs.Count}) [/]").BorderColor(OutputFormatter.Warning).NoBorder(), detail); - } - - static Rows BuildEventLogView(WorkbenchData data, int selectedIndex, string filterText, bool ascending, int page) - { - if (data.RecentEvents.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No events in the event log yet.[/]\n")) - .Header($"[{_acc}] Event Log [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - // Sort the full event window, then filter, then page. - var sortedEvents = ascending - ? [.. data.RecentEvents.OrderBy(e => e.Context.SequenceNumber)] - : data.RecentEvents.ToList(); - - var filteredEvents = string.IsNullOrEmpty(filterText) - ? sortedEvents - : [.. sortedEvents.Where(e => (e.Context.EventType?.Id ?? string.Empty).Contains(filterText, StringComparison.OrdinalIgnoreCase))]; - - if (filteredEvents.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No events matching '{filterText.EscapeMarkup()}'.[/]\n")) - .Header($"[{_acc}] Event Log (0/{sortedEvents.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var totalPages = Math.Max(1, (filteredEvents.Count + EventLogPageSize - 1) / EventLogPageSize); - var effectivePage = Math.Min(page, totalPages - 1); - var pageStart = effectivePage * EventLogPageSize; - var pageEvents = filteredEvents.Skip(pageStart).Take(EventLogPageSize).ToList(); - var effectiveIndex = Math.Min(selectedIndex, Math.Max(0, pageEvents.Count - 1)); - - var sortIndicator = ascending ? $"[{_mut}]↑ oldest first[/]" : $"[{_mut}]↓ newest first[/]"; - var pageIndicator = totalPages > 1 - ? $" [{_mut}]page {effectivePage + 1}/{totalPages}[/]" - : string.Empty; - - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Seq#[/]").Padding(1, 0).RightAligned()) - .AddColumn(new TableColumn("[bold]EventType[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]EventSourceId[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Occurred[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(pageEvents.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var evt = pageEvents[i]; - var ctx = evt.Context; - var isSelected = i == effectiveIndex; - var typeId = ctx.EventType?.Id ?? "—"; - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup($"[{_mut}]{ctx.SequenceNumber:N0}[/]"), - new Markup(isSelected ? $"[bold {_acc}]{typeId.EscapeMarkup()}[/]" : typeId.EscapeMarkup()), - new Markup((ctx.EventSourceId ?? string.Empty).EscapeMarkup()), - new Markup($"[{_mut}]{ctx.Occurred.ToString().EscapeMarkup()}[/]")); - } - - if (winEnd < pageEvents.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup(string.Empty), new Markup($"[{_mut}]↓ {pageEvents.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty)); - - var selEvt = pageEvents[effectiveIndex]; - var selCtx = selEvt.Context; - var miniDetail = new Panel(new Markup( - $" [{_mut}]seq#[/] [bold]{selCtx.SequenceNumber:N0}[/] [{_mut}]eventSource[/] {(selCtx.EventSourceId ?? "—").EscapeMarkup()} [{_mut}]occurred[/] [{_mut}]{selCtx.Occurred.ToString().EscapeMarkup()}[/]\n" + - $"\n [{_acc}][[ Enter ]][/] Full event [{_acc}][[ T ]][/] View event type")) - .Header($"[{_acc}] {(selCtx.EventType?.Id ?? "—").EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - var filterSuffix = string.IsNullOrEmpty(filterText) - ? string.Empty - : $" ({filteredEvents.Count}/{sortedEvents.Count} matching)"; - var logHeader = $"[{_acc}] Event Log{filterSuffix} {sortIndicator}{pageIndicator} [[PgDn/PgUp]] page [/]"; - return new Rows( - new Panel(table).Header(logHeader).BorderColor(OutputFormatter.Accent).NoBorder(), - miniDetail); - } - - static Rows BuildEventTypesView(WorkbenchData data, int selectedIndex, string filterText) - { - if (data.EventTypeRegistrations.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No event types registered.[/]\n")) - .Header($"[{_acc}] Event Types [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var allTypes = data.EventTypeRegistrations.OrderBy(r => r.Type.Id, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Type.Generation).ToList(); - var types = string.IsNullOrEmpty(filterText) - ? allTypes - : [.. allTypes.Where(r => r.Type.Id.Contains(filterText, StringComparison.OrdinalIgnoreCase))]; - - if (types.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No event types matching '{filterText.EscapeMarkup()}'.[/]\n")) - .Header($"[{_acc}] Event Types (0/{allTypes.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var effectiveIndex = Math.Min(selectedIndex, types.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]EventType[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Gen[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Owner[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Source[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(types.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var reg = types[i]; - var isSelected = i == effectiveIndex; - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{reg.Type.Id.EscapeMarkup()}[/]" : reg.Type.Id.EscapeMarkup()), - new Markup($"[{_mut}]{reg.Type.Generation}[/]"), - new Markup($"[{_mut}]{reg.Owner.ToString().EscapeMarkup()}[/]"), - new Markup($"[{_mut}]{reg.Source.ToString().EscapeMarkup()}[/]")); - } - - if (winEnd < types.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {types.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - - var selType = types[effectiveIndex]; - var miniDetail = new Panel(new Markup( - $" [{_mut}]owner[/] {selType.Owner.ToString().EscapeMarkup()} [{_mut}]source[/] {selType.Source.ToString().EscapeMarkup()} [{_mut}]tombstone[/] {selType.Type.Tombstone}\n" + - $"\n [{_acc}][[ Enter ]][/] View schema")) - .Header($"[{_acc}] {selType.Type.Id.EscapeMarkup()} +{selType.Type.Generation} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - var typesHeader = string.IsNullOrEmpty(filterText) - ? $"[{_acc}] Event Types ({allTypes.Count}) [/]" - : $"[{_acc}] Event Types ({types.Count}/{allTypes.Count}) [/]"; - return new Rows( - new Panel(table).Header(typesHeader).BorderColor(OutputFormatter.Accent).NoBorder(), - miniDetail); - } - - static Rows BuildProjectionsView(WorkbenchData data, int selectedIndex, string filterText) - { - if (data.ProjectionDefinitions.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No projections registered.[/]\n")) - .Header($"[{_acc}] Projections [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var allProjections = data.ProjectionDefinitions.OrderBy(d => d.Identifier, StringComparer.OrdinalIgnoreCase).ToList(); - var projections = string.IsNullOrEmpty(filterText) - ? allProjections - : [.. allProjections.Where(d => d.Identifier.Contains(filterText, StringComparison.OrdinalIgnoreCase))]; - - if (projections.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No projections matching '{filterText.EscapeMarkup()}'.[/]\n")) - .Header($"[{_acc}] Projections (0/{allProjections.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var effectiveIndex = Math.Min(selectedIndex, projections.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Identifier[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]ReadModel[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Active[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Rewindable[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(projections.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var def = projections[i]; - var isSelected = i == effectiveIndex; - var activeIcon = def.IsActive ? $"[{_suc}]✓[/]" : $"[{_mut}]✗[/]"; - var rewindIcon = def.IsRewindable ? $"[{_suc}]✓[/]" : $"[{_mut}]✗[/]"; - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{def.Identifier.EscapeMarkup()}[/]" : def.Identifier.EscapeMarkup()), - new Markup($"[{_mut}]{def.ReadModel.EscapeMarkup()}[/]"), - new Markup(activeIcon), - new Markup(rewindIcon)); - } - - if (winEnd < projections.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {projections.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - - var selDef = projections[effectiveIndex]; - var miniDetail = new Panel(new Markup( - $" [{_mut}]readModel[/] {selDef.ReadModel.EscapeMarkup()} [{_mut}]active[/] {(selDef.IsActive ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]rewindable[/] {(selDef.IsRewindable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")}\n" + - $"\n [{_acc}][[ Enter ]][/] View declaration")) - .Header($"[{_acc}] {selDef.Identifier.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - var projHeader = string.IsNullOrEmpty(filterText) - ? $"[{_acc}] Projections ({allProjections.Count}) [/]" - : $"[{_acc}] Projections ({projections.Count}/{allProjections.Count}) [/]"; - return new Rows( - new Panel(table).Header(projHeader).BorderColor(OutputFormatter.Accent).NoBorder(), - miniDetail); - } - - static Rows BuildReadModelsView(WorkbenchData data, int selectedIndex, string filterText) - { - if (data.ReadModelDefinitions.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No read models registered.[/]\n")) - .Header($"[{_acc}] Read Models [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var allModels = data.ReadModelDefinitions.OrderBy(d => d.ContainerName, StringComparer.OrdinalIgnoreCase).ToList(); - var models = string.IsNullOrEmpty(filterText) - ? allModels - : [.. allModels.Where(d => d.ContainerName.Contains(filterText, StringComparison.OrdinalIgnoreCase) - || d.DisplayName.Contains(filterText, StringComparison.OrdinalIgnoreCase))]; - - if (models.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No read models matching '{filterText.EscapeMarkup()}'.[/]\n")) - .Header($"[{_acc}] Read Models (0/{allModels.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var effectiveIndex = Math.Min(selectedIndex, models.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Container[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Display Name[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Owner[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Queryable[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(models.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var rm = models[i]; - var isSelected = i == effectiveIndex; - var queryIcon = rm.IsQueryable ? $"[{_suc}]✓[/]" : $"[{_mut}]✗[/]"; - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{rm.ContainerName.EscapeMarkup()}[/]" : rm.ContainerName.EscapeMarkup()), - new Markup($"[{_mut}]{rm.DisplayName.EscapeMarkup()}[/]"), - new Markup($"[{_mut}]{rm.Owner.EscapeMarkup()}[/]"), - new Markup(queryIcon)); - } - - if (winEnd < models.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {models.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - - var selModel = models[effectiveIndex]; - var miniDetail = new Panel(new Markup( - $" [{_mut}]owner[/] {selModel.Owner.EscapeMarkup()} [{_mut}]source[/] {selModel.Source.EscapeMarkup()} [{_mut}]queryable[/] {(selModel.IsQueryable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")}\n" + - $" [{_mut}]identifier[/] {selModel.Identifier.EscapeMarkup()}\n" + - (selModel.IsQueryable ? $"\n [{_acc}][[ Enter ]][/] View instances" : $"\n [{_mut}](client-owned — instances are not stored on the server)[/]"))) - .Header($"[{_acc}] {selModel.ContainerName.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - var rmHeader = string.IsNullOrEmpty(filterText) - ? $"[{_acc}] Read Models ({allModels.Count}) [/]" - : $"[{_acc}] Read Models ({models.Count}/{allModels.Count}) [/]"; - return new Rows( - new Panel(table).Header(rmHeader).BorderColor(OutputFormatter.Accent).NoBorder(), - miniDetail); - } - - static Rows BuildEventStoresView(WorkbenchData data, int selectedIndex) - { - var stores = data.EventStoreNames.Order(StringComparer.OrdinalIgnoreCase).ToList(); - if (stores.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No event stores found.[/]\n")) - .Header($"[{_acc}] Event Stores [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var effectiveIndex = Math.Min(selectedIndex, stores.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Event Store[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(stores.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]")); - for (var i = winStart; i <= winEnd; i++) - { - var store = stores[i]; - var isSelected = i == effectiveIndex; - var isActive = string.Equals(store, data.EventStore, StringComparison.Ordinal); - var label = isActive ? $"[bold]{store.EscapeMarkup()}[/] [{_suc}]← active[/]" : store.EscapeMarkup(); - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{store.EscapeMarkup()}[/]" : label)); - } - - if (winEnd < stores.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {stores.Count - 1 - winEnd} more below[/]")); - - var hint = new Panel(new Markup($" [{_mut}]Press[/] [{_acc}][[ Enter ]][/] [{_mut}]to switch to the selected event store.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted); - - return new Rows( - new Panel(table).Header($"[{_acc}] Event Stores ({stores.Count}) active: {data.EventStore.EscapeMarkup()} [/]").BorderColor(OutputFormatter.Accent).NoBorder(), - hint); - } - - static Rows BuildNamespacesView(WorkbenchData data, int selectedIndex) - { - if (data.NamespaceNames.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No namespaces found (or data not yet loaded).[/]\n")) - .Header($"[{_acc}] Namespaces [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var nsList = data.NamespaceNames.Order(StringComparer.OrdinalIgnoreCase).ToList(); - var effectiveIndex = Math.Min(selectedIndex, nsList.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Namespace[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(nsList.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]")); - for (var i = winStart; i <= winEnd; i++) - { - var ns = nsList[i]; - var isSelected = i == effectiveIndex; - var isActive = string.Equals(ns, data.Namespace, StringComparison.Ordinal); - var label = isActive ? $"[bold]{ns.EscapeMarkup()}[/] [{_suc}]← active[/]" : ns.EscapeMarkup(); - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{ns.EscapeMarkup()}[/]" : label)); - } - - if (winEnd < nsList.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {nsList.Count - 1 - winEnd} more below[/]")); - - var hint = new Panel(new Markup($" [{_mut}]Press[/] [{_acc}][[ Enter ]][/] [{_mut}]to switch to the selected namespace.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted); - - return new Rows( - new Panel(table).Header($"[{_acc}] Namespaces ({nsList.Count}) active: {data.Namespace.EscapeMarkup()} [/]").BorderColor(OutputFormatter.Accent).NoBorder(), - hint); - } -} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.cs deleted file mode 100644 index 3fa62a6..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.cs +++ /dev/null @@ -1,316 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using Cratis.Chronicle.Contracts.Jobs; -using Spectre.Console.Rendering; - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -/// -/// Builds Spectre.Console renderables for the Chronicle workbench dashboard. -/// -public static partial class WorkbenchRenderer -{ - /// Maximum number of list rows shown before the sliding window kicks in. - const int MaxListRows = 10; - - /// Height of the scrollable text viewport in detail views (lines). - const int ScrollViewport = 12; - - static readonly string _acc = OutputFormatter.Accent.ToMarkup(); - static readonly string _mut = OutputFormatter.Muted.ToMarkup(); - static readonly string _suc = OutputFormatter.Success.ToMarkup(); - static readonly string _war = OutputFormatter.Warning.ToMarkup(); - static readonly string _dan = OutputFormatter.Danger.ToMarkup(); - - static readonly (WorkbenchView V, string Key, string Label)[] _views = - [ - (WorkbenchView.Overview, "1", "Overview"), - (WorkbenchView.Observers, "2", "Observers"), - (WorkbenchView.FailedPartitions, "3", "Failures"), - (WorkbenchView.Jobs, "4", "Jobs"), - (WorkbenchView.Recommendations, "5", "Recommendations"), - (WorkbenchView.EventLog, "6", "Event Log"), - (WorkbenchView.EventTypes, "7", "Event Types"), - (WorkbenchView.Projections, "8", "Projections"), - (WorkbenchView.ReadModels, "9", "Read Models"), - (WorkbenchView.EventStores, "0", "Event Stores"), - (WorkbenchView.Namespaces, string.Empty, "Namespaces") - ]; - - /// - /// Builds the full dashboard renderable for the given data and render state. - /// - /// The workbench snapshot to render. - /// All render-relevant state for this frame. - /// The renderable dashboard. - public static Rows Build(WorkbenchData data, WorkbenchRenderState state) => - new(BuildHeader(data, state), BuildBody(data, state), BuildFooter(data, state)); - - static Panel BuildHeader(WorkbenchData data, WorkbenchRenderState state) - { - var dot = data.IsConnected ? $"[{_suc}]●[/]" : $"[{_dan}]●[/]"; - var cs = DisplayConnectionString(data.ConnectionString); - var store = $"[bold]{data.EventStore.EscapeMarkup()}[/][{_mut}] / {data.Namespace.EscapeMarkup()}[/]"; - var titleLine = $" [{_acc}]◆[/] [bold]CHRONICLE WORKBENCH[/] [{_mut}]{cs.EscapeMarkup()}[/] {dot} {store}"; - - // Second line: breadcrumb when in a detail view, otherwise nav tabs. - string secondLine; - var breadcrumb = state.Breadcrumb; - if (breadcrumb is { Count: > 0 }) - { - var path = string.Join($" [{_mut}]›[/] ", breadcrumb.Select(s => s.EscapeMarkup())); - secondLine = $" [{_mut}]{path}[/] [{_mut}]↻ {state.Interval}s[/]"; - } - else - { - var failuresAlert = data.FailedPartitions.Count > 0; - var recsAlert = data.Recommendations.Count > 0; - var tabs = string.Join(" ", _views.Select(v => - { - var isActive = v.V == state.View; - var keyPart = string.IsNullOrEmpty(v.Key) ? string.Empty : $"[[ {v.Key} ]] "; - var alert = (v.V == WorkbenchView.FailedPartitions && failuresAlert) - || (v.V == WorkbenchView.Recommendations && recsAlert); - if (isActive) - return $"[bold {_acc}]{keyPart}{v.Label.EscapeMarkup()}[/]"; - if (alert) - return $"[bold {_war}]{keyPart}{v.Label.EscapeMarkup()}[/]"; - return string.IsNullOrEmpty(v.Key) - ? $"[{_mut}]{v.Label.EscapeMarkup()}[/]" - : $"[{_mut}]{keyPart}[/]{v.Label.EscapeMarkup()}"; - })); - var refreshIndicator = $"[{_mut}]↻ {state.Interval}s[/]"; - secondLine = $" {tabs} {refreshIndicator}"; - } - - return new Panel(new Rows(new Markup(titleLine), new Markup(secondLine))) - .Border(BoxBorder.Heavy) - .BorderColor(OutputFormatter.Accent) - .Padding(0, 0); - } - - static Rows BuildFooter(WorkbenchData data, WorkbenchRenderState state) - { - var sep = new Rule().RuleStyle(new Style(foreground: OutputFormatter.Muted)); - - Markup statusLine; - switch (state.ActionState) - { - case WorkbenchActionState.AwaitingConfirmation: - var desc = (state.PendingActionDescription ?? string.Empty).EscapeMarkup(); - statusLine = new Markup($" [{_war}]⚡ {desc}?[/] [{_war}][[ Y ]][/] Confirm [{_mut}][[ N ]][/] Cancel"); - break; - - case WorkbenchActionState.Executing: - var execDesc = (state.PendingActionDescription ?? "Working").EscapeMarkup(); - statusLine = new Markup($" [{_acc}]⟳ {execDesc}…[/]"); - break; - - case WorkbenchActionState.Completed: - var result = (state.ActionResult ?? string.Empty).EscapeMarkup(); - var resColor = state.IsActionError ? _dan : _suc; - var resIcon = state.IsActionError ? "✗" : "✓"; - statusLine = new Markup($" [{resColor}]{resIcon} {result}[/] [{_mut}](press any key to dismiss)[/]"); - break; - - default: - var isDetail = (int)state.View >= 100; - var connected = data.IsConnected ? $"[{_suc}]✓ connected[/]" : $"[{_dan}]✗ disconnected[/]"; - var seqTail = data.TailSequenceNumber.HasValue - ? $"seq# [bold]{data.TailSequenceNumber.Value:N0}[/]" - : $"[{_mut}]seq# —[/]"; - - if (isDetail) - { - var actions = GetActionHints(state.View); - var scrollHint = $"[{_mut}]↑↓ scroll [/]"; - statusLine = new Markup($" {connected} {seqTail} {scrollHint}{actions}[{_mut}][[ Esc ]][/] Back"); - } - else - { - var navHint = HasNavigation(state.View) ? $"[{_mut}]↑↓ select [/]" : string.Empty; - var enterHint = HasDrillDown(state.View) && !state.FilterInputMode ? $"[{_mut}][[ Enter ]][/] detail " : string.Empty; - var actions = !state.FilterInputMode ? GetActionHints(state.View) : string.Empty; - string filterIndicator; - if (state.FilterInputMode) - filterIndicator = $"[{_acc}]/ {state.FilterText.EscapeMarkup()}█[/] [{_mut}][[ Enter ]] confirm [[ Esc ]] cancel [/]"; - else if (!string.IsNullOrEmpty(state.FilterText)) - filterIndicator = $"[{_acc}]/{state.FilterText.EscapeMarkup()}[/] [{_mut}][[ Esc ]] clear [/]"; - else - filterIndicator = string.Empty; - var filterHint = !state.FilterInputMode && IsFilterableView(state.View) && string.IsNullOrEmpty(state.FilterText) - ? $"[{_mut}][[ / ]] filter [/]" - : string.Empty; - var quit = $"[{_mut}]← → views [[ +/- ]] interval ({state.Interval}s) [[ Q ]] Quit[/]"; - statusLine = new Markup($" {connected} {seqTail} {navHint}{enterHint}{filterHint}{actions}{filterIndicator}{quit}"); - } - - break; - } - - return new Rows(sep, statusLine); - } - - static IRenderable BuildBody(WorkbenchData data, WorkbenchRenderState state) => - state.View switch - { - WorkbenchView.Overview => BuildOverview(data), - WorkbenchView.Observers => BuildObserversView(data, state.SelectedIndex, state.FilterText), - WorkbenchView.FailedPartitions => BuildFailedView(data, state.SelectedIndex), - WorkbenchView.Jobs => BuildJobsView(data, state.SelectedIndex), - WorkbenchView.Recommendations => BuildRecommendationsView(data, state.SelectedIndex), - WorkbenchView.EventLog => BuildEventLogView(data, state.SelectedIndex, state.FilterText, state.EventLogAscending, state.EventLogPage), - WorkbenchView.EventTypes => BuildEventTypesView(data, state.SelectedIndex, state.FilterText), - WorkbenchView.Projections => BuildProjectionsView(data, state.SelectedIndex, state.FilterText), - WorkbenchView.ReadModels => BuildReadModelsView(data, state.SelectedIndex, state.FilterText), - WorkbenchView.EventStores => BuildEventStoresView(data, state.SelectedIndex), - WorkbenchView.Namespaces => BuildNamespacesView(data, state.SelectedIndex), - WorkbenchView.ObserverDetail => BuildObserverDetailPage(data, state.FocusedId, state.SelectedIndex), - WorkbenchView.FailedPartitionDetail => BuildFailedPartitionDetailPage(data, state.FocusedId, state.ScrollOffset), - WorkbenchView.EventDetail => BuildEventDetailPage(data, state.FocusedId, state.ScrollOffset), - WorkbenchView.EventTypeDetail => BuildEventTypeDetailPage(data, state.FocusedId, state.ScrollOffset), - WorkbenchView.ProjectionDetail => BuildProjectionDetailPage(data, state.FocusedId, state.ScrollOffset), - WorkbenchView.ReadModelDetail => BuildReadModelDetailPage(data, state.FocusedId, state.ScrollOffset), - _ => new Markup(string.Empty) - }; - - static bool IsFilterableView(WorkbenchView view) => - view is WorkbenchView.Observers or WorkbenchView.EventTypes - or WorkbenchView.EventLog or WorkbenchView.Projections - or WorkbenchView.ReadModels; - - static bool HasNavigation(WorkbenchView view) => - view is WorkbenchView.Observers or WorkbenchView.FailedPartitions - or WorkbenchView.Jobs or WorkbenchView.Recommendations - or WorkbenchView.EventLog or WorkbenchView.EventTypes - or WorkbenchView.Projections or WorkbenchView.ReadModels - or WorkbenchView.EventStores or WorkbenchView.Namespaces - or WorkbenchView.ObserverDetail; - - static bool HasDrillDown(WorkbenchView view) => - view is WorkbenchView.Observers or WorkbenchView.FailedPartitions - or WorkbenchView.EventLog or WorkbenchView.EventTypes - or WorkbenchView.Projections or WorkbenchView.ReadModels - or WorkbenchView.EventStores or WorkbenchView.Namespaces - or WorkbenchView.ObserverDetail; - - static string GetActionHints(WorkbenchView view) => view switch - { - WorkbenchView.Observers => $"[{_mut}][[ R ]][/] Replay ", - WorkbenchView.FailedPartitions => $"[{_mut}][[ T ]][/] Retry [{_mut}][[ P ]][/] Replay partition ", - WorkbenchView.Jobs => $"[{_mut}][[ S ]][/] Stop [{_mut}][[ U ]][/] Resume ", - WorkbenchView.Recommendations => $"[{_mut}][[ A ]][/] Apply [{_mut}][[ I ]][/] Ignore ", - WorkbenchView.EventLog => $"[{_mut}][[ S ]][/] Sort [{_mut}][[ PgDn ]][/] Older [{_mut}][[ PgUp ]][/] Newer ", - WorkbenchView.ObserverDetail => $"[{_mut}][[ R ]][/] Replay [{_mut}][[ P ]][/] Projection ", - WorkbenchView.FailedPartitionDetail => $"[{_mut}][[ T ]][/] Retry [{_mut}][[ P ]][/] Replay partition ", - WorkbenchView.EventDetail => $"[{_mut}][[ T ]][/] View event type ", - WorkbenchView.EventStores => $"[{_mut}][[ Enter ]][/] Switch store ", - WorkbenchView.Namespaces => $"[{_mut}][[ Enter ]][/] Switch namespace ", - _ => string.Empty - }; - - static string StateIcon(ObserverRunningState state) => state switch - { - ObserverRunningState.Active => $"[{_suc}]●[/]", - ObserverRunningState.Replaying => $"[{_war}]▲[/]", - ObserverRunningState.Suspended => $"[{_mut}]○[/]", - ObserverRunningState.Disconnected => $"[{_mut}]⊘[/]", - _ => $"[{_mut}]·[/]" - }; - - static string StateName(ObserverRunningState state) => state switch - { - ObserverRunningState.Active => $"[{_suc}]Active[/]", - ObserverRunningState.Replaying => $"[{_war}]Replaying[/]", - ObserverRunningState.Suspended => $"[{_mut}]Suspended[/]", - ObserverRunningState.Disconnected => $"[{_mut}]Disconnected[/]", - _ => state.ToString().EscapeMarkup() - }; - - static int StateOrder(ObserverRunningState state) => state switch - { - ObserverRunningState.Disconnected => 0, - ObserverRunningState.Replaying => 1, - ObserverRunningState.Active => 2, - ObserverRunningState.Suspended => 3, - _ => 4 - }; - - static ulong? ComputeLag(ObserverInformation obs, ulong? tail) - { - if (!tail.HasValue || obs.LastHandledEventSequenceNumber == ulong.MaxValue) - { - return tail; - } - - return tail.Value > obs.LastHandledEventSequenceNumber - ? tail.Value - obs.LastHandledEventSequenceNumber - : 0; - } - - static string ProgressBar(int success, int total, int width = 16) - { - if (total == 0) return $"[{_mut}]{new string('░', width)}[/]"; - var filled = (int)Math.Min(width, (long)success * width / total); - return $"[{_suc}]{new string('█', filled)}[/][{_mut}]{new string('░', width - filled)}[/]"; - } - - static string FormatSeq(ulong seq) => - seq == ulong.MaxValue ? $"[{_mut}]—[/]" : seq.ToString("N0"); - - /// - /// Renders a block of text with a scrollable viewport and a "lines X–Y of Z" indicator. - /// - /// The panel header title. - /// The full list of lines to display. - /// The current scroll position (first visible line index). - /// The panel border color. - /// A panel showing the visible lines with a scroll indicator. - static Panel ScrollableText(string title, IReadOnlyList lines, int scrollOffset, Color borderColor) - { - var totalLines = lines.Count; - var start = Math.Max(0, Math.Min(scrollOffset, Math.Max(0, totalLines - ScrollViewport))); - const int maxOffset = ScrollViewport - 1; - var end = Math.Min(totalLines - 1, start + maxOffset); - var visible = lines.Skip(start).Take(ScrollViewport).Select(l => (IRenderable)new Markup(l)).ToList(); - var indicator = totalLines > ScrollViewport - ? $"\n [{_mut}]lines {start + 1}–{end + 1} of {totalLines} ↑↓ scroll[/]" - : string.Empty; - - var content = visible.Count > 0 - ? (IRenderable)new Rows([.. visible, new Markup(indicator)]) - : new Markup($"[{_mut}](empty)[/]"); - - return new Panel(content) - .Header($"[{_acc}] {title.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded) - .BorderColor(borderColor) - .Expand(); - } - - static (int Start, int End) ListWindow(int count, int selectedIndex) - { - if (count <= MaxListRows) return (0, count - 1); - const int maxOffset = MaxListRows - 1; - var start = Math.Max(0, selectedIndex - (MaxListRows / 2)); - var end = Math.Min(count - 1, start + maxOffset); - start = Math.Max(0, end - maxOffset); - return (start, end); - } - - static string DisplayConnectionString(string connectionString) - { - try - { - var withoutScheme = connectionString.Replace("chronicle://", string.Empty, StringComparison.OrdinalIgnoreCase); - var afterAuth = withoutScheme.Contains('@') ? withoutScheme[(withoutScheme.IndexOf('@') + 1)..] : withoutScheme; - var hostPort = afterAuth.Split('?')[0].TrimEnd('/'); - return $"chronicle://{hostPort}"; - } - catch - { - return connectionString; - } - } -} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchSection.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchSection.cs new file mode 100644 index 0000000..b1f95cc --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchSection.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SColor = SharpConsoleUI.Color; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Identifies a navigation pane section header and its display color. +/// Instances are held as static readonly fields in +/// and compared by reference to detect section boundaries. +/// +/// The all-caps section header text shown in the nav pane. +/// The color used for the section header and its items. +public sealed record WorkbenchSection(string Title, SColor Color); diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchState.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchState.cs new file mode 100644 index 0000000..777a841 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchState.cs @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Persisted workbench state saved between sessions under ~/.cratis/workbench-state.json. +/// +public class WorkbenchState +{ + static readonly string _path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cratis", + "workbench-state.json"); + + static readonly JsonSerializerOptions _options = new() { WriteIndented = true }; + + /// Gets or sets the refresh interval in seconds. + public int Interval { get; set; } = 5; + + /// Gets or sets the index of the last active navigation item. + public int LastNavIndex { get; set; } + + /// + /// Loads the workbench state from disk, or returns defaults if the file does not exist. + /// + /// The persisted , or a new default instance. + public static WorkbenchState Load() + { + try + { + if (!File.Exists(_path)) return new WorkbenchState(); + var json = File.ReadAllText(_path); + return JsonSerializer.Deserialize(json) ?? new WorkbenchState(); + } + catch + { + return new WorkbenchState(); + } + } + + /// + /// Saves the current state to disk. Failures are silently ignored. + /// + public void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText(_path, JsonSerializer.Serialize(this, _options)); + } + catch + { + // Best-effort persistence — never throw from a background save. + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs index 6577181..286fee4 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs @@ -8,9 +8,7 @@ namespace Cratis.Cli.Commands.Chronicle.Workbench; /// public enum WorkbenchView { - // Primary views — cycled with ← → and accessed with keys 1–8. - - /// Overview showing server health, observer counts, and any failures. + /// Overview showing server health, observer counts, context, and any failures. Overview = 0, /// Full list of all observers with their running states and sequence lag. @@ -25,8 +23,8 @@ public enum WorkbenchView /// Pending recommendations from the Chronicle server, with apply and ignore actions. Recommendations = 4, - /// The live event log — last 50 events, newest first. - EventLog = 5, + /// The event sequences view — recent events from the event log, newest first. + EventSequences = 5, /// All registered event types with their JSON schemas. EventTypes = 6, @@ -43,8 +41,20 @@ public enum WorkbenchView /// All namespaces in the current event store, with the ability to switch the active namespace. Namespaces = 10, + /// Registered OAuth applications on the Chronicle server. + Applications = 11, + + /// Registered users on the Chronicle server. + Users = 12, + + /// Known identities (actors) in the current event store namespace. + Identities = 13, + + /// Event store subscriptions configured for the current event store. + Subscriptions = 14, + // Detail views — entered with Enter from a primary view, exited with Escape. - // Values ≥ 100 are never shown in the tab bar. + // Values ≥ 100 are never shown in the nav pane. /// Full-screen detail for a single observer, with a navigable event-type sub-list. ObserverDetail = 100, diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewDefinition.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewDefinition.cs new file mode 100644 index 0000000..f342f9d --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewDefinition.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Bundles everything that defines a single workbench navigation item in one place: +/// the view type, factory, nav item text/icon/subtitle, and the section it belongs to. +/// The position of this entry in is its view index. +/// +/// The concrete view type — used for O(n) index lookup by type without creating instances. +/// Creates a fresh instance of the view. Called once during startup. +/// The nav item label shown in the sidebar. +/// The single-character icon shown in compact sidebar mode. +/// The short subtitle shown below the label in expanded mode. +/// The section header this item lives under. Use the static readonly +/// instances from — reference equality is used to detect section boundaries. +public sealed record WorkbenchViewDefinition( + Type ViewType, + Func Factory, + string NavText, + string NavIcon, + string NavSubtitle, + WorkbenchSection Section); diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewRegistry.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewRegistry.cs new file mode 100644 index 0000000..fde8b57 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewRegistry.cs @@ -0,0 +1,103 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Single source of truth for all workbench views. +/// +/// Every view, its navigation sidebar entry, and its position index are defined here. +/// Nothing else in the codebase hardcodes view positions, nav labels, or icons. +/// +/// +/// To add a new view: create the view class, then add ONE to the +/// master list below in the desired position. Navigation and all index constants update automatically. +/// +/// +/// asserts at startup when nav item count and view count drift apart. +/// +/// +public static class WorkbenchViewRegistry +{ + // ── Section definitions ──────────────────────────────────────────────────── + // Static reference objects — WorkbenchNavigation uses ReferenceEquals to detect + // section boundaries when building the nav tree. + + /// Overview section header. + public static readonly WorkbenchSection SectionOverview = new("OVERVIEW", WorkbenchColors.Accent); + + /// Observation (observers, failures, jobs, recommendations) section header. + public static readonly WorkbenchSection SectionObservation = new("OBSERVATION", WorkbenchColors.Warning); + + /// Events (sequences, types) section header. + public static readonly WorkbenchSection SectionEvents = new("EVENTS", WorkbenchColors.Teal); + + /// Projections (projections, read models) section header. + public static readonly WorkbenchSection SectionProjections = new("PROJECTIONS", WorkbenchColors.Mauve); + + /// Server (event stores, namespaces, applications, users, identities, subscriptions) section header. + public static readonly WorkbenchSection SectionServer = new("SERVER", WorkbenchColors.Muted); + + static readonly WorkbenchViewDefinition[] _all = + [ + Entry(static () => new OverviewView(), "Overview", "◈", "Health & status", SectionOverview), + Entry(static () => new ObserversView(), "Observers", "◉", "Monitor & replay", SectionObservation), + Entry(static () => new FailedPartitionsView(), "Failures", "✕", "Retry failed partitions", SectionObservation), + Entry(static () => new JobsView(), "Jobs", "≋", "Background operations", SectionObservation), + Entry(static () => new RecommendationsView(), "Recommendations", "★", "Suggested actions", SectionObservation), + Entry(static () => new EventSequencesView(), "Event Sequences", "≡", "Appended events", SectionEvents), + Entry(static () => new EventTypesView(), "Event Types", "◇", "Registered schemas", SectionEvents), + Entry(static () => new ProjectionsView(), "Projections", "▷", "Running projections", SectionProjections), + Entry(static () => new ReadModelsView(), "Read Models", "▦", "Derived state", SectionProjections), + Entry(static () => new EventStoresView(), "Event Stores", "⊞", "Store management", SectionServer), + Entry(static () => new NamespacesView(), "Namespaces", "⊙", "Namespace context", SectionServer), + Entry(static () => new ApplicationsView(), "Applications", "⊕", "App registrations", SectionServer), + Entry(static () => new UsersView(), "Users", "♟", "User management", SectionServer), + Entry(static () => new IdentitiesView(), "Identities", "◎", "Identity records", SectionServer), + Entry(static () => new SubscriptionsView(), "Subscriptions", "⊗", "Active subscriptions", SectionServer), + ]; + + /// The ordered list of all registered view definitions. Position = view index. + public static IReadOnlyList All => _all; + + /// + /// Returns the zero-based view index for . + /// Throws if the type is not registered — this is a programming error. + /// + /// The view type to look up. + /// The view index matching the corresponding IndexXxx constant. + /// Thrown when is not in the registry. + public static int IndexOf() + where TView : IWorkbenchView + { + var target = typeof(TView); + for (var i = 0; i < _all.Length; i++) + { + if (_all[i].ViewType == target) + { + return i; + } + } + + throw new InvalidOperationException( + $"View type {typeof(TView).Name} is not registered in {nameof(WorkbenchViewRegistry)}. " + + $"Add an Entry<{typeof(TView).Name}>(...) to the master list."); + } + + /// + /// Creates one fresh instance of every registered view, in registry order. + /// The returned array is the shared _views[] for MainWindow and WorkbenchNavigation. + /// + /// New view instances in registry order. + public static IWorkbenchView[] CreateViews() => + [.. _all.Select(d => d.Factory())]; + + static WorkbenchViewDefinition Entry( + Func factory, + string navText, + string navIcon, + string navSubtitle, + WorkbenchSection section) + where TView : IWorkbenchView => + new(typeof(TView), factory, navText, navIcon, navSubtitle, section); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ApplicationsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ApplicationsView.cs new file mode 100644 index 0000000..ee79014 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ApplicationsView.cs @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Contracts.Security; +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Applications navigation item — filterable table of registered OAuth applications with a detail pane. +/// +public class ApplicationsView : FilterableTableView +{ + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("ClientId", TextJustification.Left, null), + ("Active", TextJustification.Left, 8), + ("Created", TextJustification.Left, 30) + ]; + + /// + protected override string DetailPanelHeader => "APPLICATION"; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.Applications.OrderBy(a => a.ClientId); + + /// + protected override string GetKey(Application item) => item.Id.ToString(); + + /// + protected override string[] BuildRow(Application item) + { + var activeColor = item.IsActive ? WorkbenchColors.Success.ToMarkup() : WorkbenchColors.Muted.ToMarkup(); + return + [ + item.ClientId, + $"[{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]", + item.CreatedAt.ToString() + ]; + } + + /// + protected override string RenderDetail(Application? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an application.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + var activeColor = item.IsActive ? suc : mut; + + return string.Join( + "\n", + $"[{mut}]Id[/] {item.Id}", + $"[{mut}]ClientId[/] {item.ClientId}", + $"[{mut}]Active[/] [{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]", + $"[{mut}]Created[/] {item.CreatedAt}"); + } + + /// + protected override bool MatchesFilter(Application item, string filter) => + item.ClientId.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs new file mode 100644 index 0000000..2b433af --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs @@ -0,0 +1,156 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Event Sequences navigation item — filterable, sortable table of recent events with a detail pane showing event content. +/// +public class EventSequencesView : FilterableTableView +{ + /// Gets the currently selected event, or if none is selected. + public AppendedEvent? SelectedEvent => SelectedItem; + + /// + /// Gets or sets the callback invoked when the user requests to view the event type definition. + /// + public Action? OnViewEventTypeDefinition { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests to view observers for this event type. + /// + public Action? OnViewObserversForType { get; set; } + + /// + public override string ViewHelp => + "Shows recent events appended to the event log.\n" + + " [D] Navigate to the selected event's type definition\n" + + " [V] Find observers subscribed to this event type"; + + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("#", TextJustification.Right, 14), + ("Occurred", TextJustification.Left, 22), + ("Event Type", TextJustification.Left, null), + ("Source", TextJustification.Left, 30) + ]; + + /// + protected override string DetailPanelHeader => "EVENT"; + + /// Uses teal to match the EVENTS section color. + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Teal; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => data.RecentEvents; + + /// + protected override string GetKey(AppendedEvent item) => item.Context.SequenceNumber.ToString(); + + /// + protected override string[] BuildRow(AppendedEvent item) => + [ + item.Context.SequenceNumber.ToString().PadLeft(14), + FormatRelativeTime(item.Context.Occurred), + item.Context.EventType.Id, + item.Context.EventSourceId ?? string.Empty + ]; + + /// + protected override IReadOnlyList GetAvailableActions(AppendedEvent item) + { + List actions = []; + if (OnViewEventTypeDefinition is not null) + { + actions.Add(new ViewAction("View event type definition", "D", ConsoleKey.D, default, () => OnViewEventTypeDefinition(item))); + } + + if (OnViewObserversForType is not null) + { + actions.Add(new ViewAction("View observers for this type", "V", ConsoleKey.V, default, () => OnViewObserversForType(item))); + } + + return actions; + } + + /// + protected override IComparer GetColumnComparer(int columnIndex) => columnIndex switch + { + 0 => Comparer.Create((a, b) => + a.Context.SequenceNumber.CompareTo(b.Context.SequenceNumber)), + 1 => Comparer.Create((a, b) => + ((DateTimeOffset)a.Context.Occurred).CompareTo((DateTimeOffset)b.Context.Occurred)), + _ => base.GetColumnComparer(columnIndex) + }; + + /// + protected override string RenderDetail(AppendedEvent? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an event.[/]"; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var contentText = !string.IsNullOrEmpty(item.Content) + ? JsonYamlFormatter.FormatAsYaml(item.Content, mut) + : $"[{mut}](no content)[/]"; + + return string.Join('\n', new[] + { + $"[{mut}]Seq#[/] {item.Context.SequenceNumber:N0}", + $"[{mut}]Type[/] [{acc}]{item.Context.EventType.Id}[/] gen {item.Context.EventType.Generation}", + $"[{mut}]Source[/] {item.Context.EventSourceId ?? "—"}", + $"[{mut}]Occurred[/] {item.Context.Occurred}", + $"[{mut}]Correlation[/] {item.Context.CorrelationId}", + string.Empty, + $"[{acc}]Content:[/]", + $" [{mut}][D][/] View event type definition", + $" [{mut}][V][/] View observers for this type", + contentText + }); + } + + /// + protected override bool MatchesFilter(AppendedEvent item, string filter) + { + if (filter.StartsWith("type:", StringComparison.OrdinalIgnoreCase)) + { + return item.Context.EventType.Id.Contains(filter[5..], StringComparison.OrdinalIgnoreCase); + } + + return item.Context.EventType.Id.Contains(filter, StringComparison.OrdinalIgnoreCase) || + (item.Context.EventSourceId ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase); + } + + /// + protected override IEnumerable GetCompletions(string input) + { + var items = PendingData?.RecentEvents ?? []; + return items + .Select(e => $"type:{e.Context.EventType.Id}") + .Distinct() + .Where(c => c.Contains(input, StringComparison.OrdinalIgnoreCase)) + .Order(); + } + + /// + /// Formats a as a human-readable relative time (e.g., "3s ago", "12m ago"). + /// Falls back to an absolute format for timestamps older than 7 days. + /// + /// The event timestamp. + /// A relative or absolute time string. + static string FormatRelativeTime(DateTimeOffset occurred) + { + var diff = DateTimeOffset.UtcNow - occurred.ToUniversalTime(); + if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s ago"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; + if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; + return occurred.LocalDateTime.ToString("yyyy-MM-dd HH:mm"); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs new file mode 100644 index 0000000..f771dcd --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs @@ -0,0 +1,130 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Event Stores tab — list of available event stores with a Switch action. +/// Selecting an entry and pressing Enter switches the active event store. +/// +public class EventStoresView : IWorkbenchView +{ + TableControl? _table; + MarkupControl? _helpPane; + WorkbenchData? _pendingData; + + /// + public bool IsActive { get; set; } + + /// + /// Gets or sets the callback invoked when the user switches to a different event store. + /// + public Action? OnSwitch { get; set; } + + /// + public void Dispose() + { + _table?.Dispose(); + _helpPane?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("Event Store", SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithFiltering() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnRowActivated((_, _) => SwitchToSelected()) + .WithName("EventStoresTable") + .Build(); + + _helpPane = new MarkupControl( + [ + $"[{WorkbenchColors.Accent.ToMarkup()}][bold]SWITCH EVENT STORE[/][/]", + string.Empty, + $" [{WorkbenchColors.Muted.ToMarkup()}]Select a store and press[/] [bold]Enter[/] [{WorkbenchColors.Muted.ToMarkup()}]to switch.[/]" + ]) + { Name = "EventStoresHelp" }; + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(_table)) + .WithSplitterAfter(0) + .Column(c => c.Width(44).Add(_helpPane)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + var selectedKey = _table.SelectedRow?.Tag as string; + + _table.ClearRows(); + foreach (var name in data.EventStoreNames.Order()) + { + _table.AddRow(new UITableRow([name]) { Tag = name }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + if (_helpPane is null) return; + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + + var lines = new List + { + $"[{acc}][bold]SWITCH EVENT STORE[/][/]", + string.Empty, + $"[{mut}]Active[/] [{suc}]{data.EventStore}[/]", + $"[{mut}]Available[/] {data.EventStoreNames.Count}", + string.Empty, + $" [{mut}]Select a store and press[/] [bold]Enter[/]", + $" [{mut}]to make it the active event store.[/]" + }; + + _helpPane.Text = string.Join('\n', lines); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is string name && name == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void SwitchToSelected() + { + if (_table?.SelectedRow?.Tag is string storeName) + { + OnSwitch?.Invoke(storeName); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs new file mode 100644 index 0000000..1e53332 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs @@ -0,0 +1,127 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI.Controls; +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Event Types navigation item — filterable table of registered event types with schema details in the right pane. +/// +public class EventTypesView : FilterableTableView +{ + /// Gets the currently selected event type registration, or if none is selected. + public EventTypeRegistration? SelectedEventType => SelectedItem; + + /// + /// Gets or sets the callback invoked when the user requests to view observers for the selected event type. + /// + public Action? OnViewObservers { get; set; } + + /// + public override string ViewHelp => + "Lists all registered event types and their schemas.\n" + + " [V] Find observers subscribed to the selected event type"; + + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Id", TextJustification.Left, null), + ("Gen", TextJustification.Right, 6), + ("Owner", TextJustification.Left, 20) + ]; + + /// + protected override string DetailPanelHeader => "EVENT TYPE"; + + /// Uses teal to match the EVENTS section color. + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Teal; + + /// + protected override int DefaultSortColumn => 0; + + /// + protected override SortDirection DefaultSortDirection => SortDirection.Ascending; + + /// + protected override bool IsSortableColumn(int columnIndex) => columnIndex == 0; + + /// + protected override IReadOnlyList GetAvailableActions(EventTypeRegistration item) + { + List actions = []; + if (OnViewObservers is not null) + { + actions.Add(new ViewAction("View observers for this type", "V", ConsoleKey.V, default, () => OnViewObservers(item))); + } + + return actions; + } + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.EventTypeRegistrations.OrderBy(r => r.Type.Id).ThenBy(r => r.Type.Generation); + + /// + protected override string GetKey(EventTypeRegistration item) => $"{item.Type.Id}+{item.Type.Generation}"; + + /// + protected override string[] BuildRow(EventTypeRegistration item) => + [item.Type.Id, item.Type.Generation.ToString().PadLeft(6), item.Owner.ToString()]; + + /// + protected override string RenderDetail(EventTypeRegistration? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an event type.[/]"; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var schemaContent = !string.IsNullOrEmpty(item.Schema) + ? JsonYamlFormatter.FormatAsYaml(item.Schema, mut) + : $"[{mut}](no schema)[/]"; + + return string.Join('\n', new[] + { + $"[{mut}]Id[/] {item.Type.Id}", + $"[{mut}]Generation[/] {item.Type.Generation}", + $"[{mut}]Owner[/] {item.Owner}", + $"[{mut}]Source[/] {item.Source}", + $"[{mut}]Tombstone[/] {item.Type.Tombstone}", + string.Empty, + $"[{acc}]Schema:[/]", + schemaContent, + string.Empty, + $"[{acc}]Actions:[/]", + $" [{mut}][V][/] View observers for this type" + }); + } + + /// + protected override bool MatchesFilter(EventTypeRegistration item, string filter) + { + if (filter.StartsWith("owner:", StringComparison.OrdinalIgnoreCase)) + { + return item.Owner.ToString().Contains(filter[6..], StringComparison.OrdinalIgnoreCase); + } + + if (filter.StartsWith("gen:", StringComparison.OrdinalIgnoreCase)) + { + return item.Type.Generation.ToString().Contains(filter[4..], StringComparison.OrdinalIgnoreCase); + } + + return item.Type.Id.Contains(filter, StringComparison.OrdinalIgnoreCase); + } + + /// + protected override IEnumerable GetCompletions(string input) => + [ + "owner:client", + "owner:server", + "gen:1", + "gen:2" + ]; +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs new file mode 100644 index 0000000..7f62b73 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs @@ -0,0 +1,158 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Failed Partitions navigation item — filterable table of failed partitions with retry/replay actions. +/// +public class FailedPartitionsView : FilterableTableView +{ + /// Gets the currently selected failed partition, or if none is selected. + public FailedPartition? SelectedPartition => SelectedItem; + + /// + public override string ViewHelp => + "Lists partitions that have failed during event processing.\n" + + " [T] Retry the selected partition (re-process from last failure)\n" + + " [P] Replay the selected partition from the beginning\n" + + " [Space] Check / uncheck row for bulk operations\n" + + " [T] / [P] (with 2+ checked) Bulk retry / replay all checked partitions"; + + /// + /// Gets or sets the callback invoked when the user requests a partition retry. + /// + public Action? OnRetryPartition { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a partition replay. + /// + public Action? OnReplayPartition { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk retry of all checked partitions. + /// + public Action>? OnRetryAll { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk replay of all checked partitions. + /// + public Action>? OnReplayAll { get; set; } + + /// + /// Gets all failed partitions that are currently checked (checkbox mode). + /// + public IReadOnlyList Checked => CheckedItems; + + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Observer", TextJustification.Left, null), + ("Partition", TextJustification.Left, 30), + ("Attempts", TextJustification.Right, 10) + ]; + + /// + protected override string DetailPanelHeader => "FAILED PARTITION"; + + /// + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Danger; + + /// + protected override bool HasCheckboxMode => true; + + /// + protected override IReadOnlyList GetAvailableActions(FailedPartition item) + { + List actions = []; + if (OnRetryPartition is not null) + { + actions.Add(new ViewAction("Retry partition", "T", ConsoleKey.T, default, () => OnRetryPartition(item))); + } + + if (OnReplayPartition is not null) + { + actions.Add(new ViewAction("Replay partition", "P", ConsoleKey.P, default, () => OnReplayPartition(item))); + } + + var checkedItems = Checked; + if (OnRetryAll is not null && checkedItems.Count > 1) + { + actions.Add(new ViewAction($"Retry {checkedItems.Count} checked", null, null, default, () => OnRetryAll(checkedItems))); + } + + if (OnReplayAll is not null && checkedItems.Count > 1) + { + actions.Add(new ViewAction($"Replay {checkedItems.Count} checked", null, null, default, () => OnReplayAll(checkedItems))); + } + + return actions; + } + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.FailedPartitions.OrderByDescending(p => p.Attempts.Count()); + + /// + protected override string GetKey(FailedPartition item) => $"{item.ObserverId}/{item.Partition}"; + + /// + protected override string[] BuildRow(FailedPartition item) => + [item.ObserverId, item.Partition, item.Attempts.Count().ToString().PadLeft(10)]; + + /// + protected override string RenderDetail(FailedPartition? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a failed partition.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var dan = WorkbenchColors.Danger.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var lines = new List + { + $"[{mut}]Observer[/] {item.ObserverId}", + $"[{mut}]Partition[/] [{dan}]{item.Partition}[/]", + $"[{mut}]Attempts[/] {item.Attempts.Count()}", + string.Empty, + $"[{acc}]Last Attempts:[/]" + }; + + foreach (var attempt in item.Attempts.OrderByDescending(a => a.Occurred).Take(5)) + { + lines.Add($" [{mut}]{attempt.Occurred}[/]"); + var firstMessage = attempt.Messages?.FirstOrDefault(); + if (!string.IsNullOrEmpty(firstMessage)) + { + var msg = firstMessage.Length > 80 ? firstMessage[..77] + "…" : firstMessage; + lines.Add($" [{dan}]{msg}[/]"); + } + } + + if (OnRetryPartition is not null || OnReplayPartition is not null) + { + lines.Add(string.Empty); + if (OnRetryPartition is not null) + { + lines.Add($"[{mut}]Press[/] [bold]T[/] [{mut}]to retry[/]"); + } + + if (OnReplayPartition is not null) + { + lines.Add($"[{mut}]Press[/] [bold]P[/] [{mut}]to replay[/]"); + } + } + + return string.Join('\n', lines); + } + + /// + protected override bool MatchesFilter(FailedPartition item, string filter) => + item.ObserverId.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Partition.Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs new file mode 100644 index 0000000..42ce0ab --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs @@ -0,0 +1,708 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Animation; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Events; +using SharpConsoleUI.Helpers; +using SharpConsoleUI.Layout; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Abstract base for workbench views that display a filterable, sortable, paginated table with a detail panel. +/// Sorting is applied to the full filtered dataset before pagination — sort order is always consistent +/// across all pages. Per-column CustomRowComparer delegates ensure typed (not string) comparison. +/// Subclasses only implement domain-specific concerns. +/// +/// The domain item type displayed in each row. +public abstract class FilterableTableView : IWorkbenchView +{ + /// Rows consumed by non-table chrome. Subtracted from terminal height to compute page size. + const int NonTableRowOverhead = 14; + + /// Minimum number of table rows to show regardless of terminal height. + const int MinPageSize = 5; + + ConsoleWindowSystem? _windowSystem; + HorizontalGridControl? _root; + TableControl? _table; + PanelControl? _detailPanel; + PromptControl? _filterPrompt; + MarkupControl? _pageIndicator; + ButtonControl? _prevPageButton; + ButtonControl? _nextPageButton; + bool _detailPaneVisible = true; + WorkbenchData? _pendingData; + string _currentFilter = string.Empty; + List _allItems = []; + int _pageIndex; + + int _lastAppliedSortColumn = -1; + SortDirection _lastAppliedSortDirection = SortDirection.None; + + /// + public Action? OnFilterFocusChanged { get; set; } + + /// + public bool IsActive { get; set; } + + /// Gets the primary focus target for this view (the main table). + public IWindowControl? PrimaryFocusTarget => _table; + + /// + public string? DetailContent => _detailPanel?.Content; + + /// Gets the per-view help text shown in the help overlay. + public virtual string ViewHelp => string.Empty; + + /// + public IReadOnlyList ViewActions => + SelectedItem is TItem item ? GetAvailableActions(item) : []; + + /// Gets column definitions: (name, justification, fixed width or null for flex). + protected abstract IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns { get; } + + /// Gets the header label shown on the right detail panel. + protected virtual string DetailPanelHeader => "DETAIL"; + + /// Gets the border color for the right detail panel. + protected virtual SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Accent; + + /// Gets a value indicating whether to enable checkbox multi-select mode on the table. + protected virtual bool HasCheckboxMode => false; + + /// Gets the zero-based column index to use for the initial sort. -1 = no initial sort. + protected virtual int DefaultSortColumn => -1; + + /// Gets the sort direction applied alongside on first display. + protected virtual SortDirection DefaultSortDirection => SortDirection.None; + + /// Gets the width of the right-hand detail pane in character columns. + protected virtual int DetailPaneWidth => Math.Max(30, Console.WindowWidth / 3); + + /// Gets the pending data snapshot. + protected WorkbenchData? PendingData => _pendingData; + + /// Gets the currently selected item, or if no row is selected. + protected TItem? SelectedItem => + _table?.SelectedRow?.Tag is TItem item ? item : default; + + /// Gets all items that are currently checked (checkbox mode only). + protected IReadOnlyList CheckedItems + { + get + { + if (_table is null) + { + return []; + } + + return [.. _table.GetCheckedRows().Select(r => r.Tag).OfType()]; + } + } + + int PageSize => Math.Max(MinPageSize, Console.WindowHeight - NonTableRowOverhead); + + /// + public virtual IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _windowSystem = windowSystem; + var tableBuilder = Controls.Table(); + + foreach (var (name, justify, width) in Columns) + { + tableBuilder.AddColumn(name, justify, width); + } + + tableBuilder = tableBuilder + .Interactive() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnSelectedRowChanged((_, _) => RefreshDetail()) + .OnRowActivated((_, _) => ActivateSelected()) + .WithName($"{GetType().Name}Table"); + + if (HasCheckboxMode) + { + tableBuilder = tableBuilder.WithCheckboxMode(); + } + + _table = tableBuilder.Build(); + + // SortByColumn (called on header click) does NOT fire PropertyChanged for SortColumnIndex + // or CurrentSortDirection — those properties have no setters. MouseClick fires after + // SortByColumn completes, giving us the correct new sort state to detect header clicks. + _table.MouseClick += OnTableMouseClick; + _table.MouseRightClick += OnTableRightClick; + + // Wire per-column typed comparers so SortByColumn uses our comparers, not string comparison. + // This ensures the sort map produced by SortByColumn matches our pre-sorted page data. + var columns = _table.Columns; + for (var i = 0; i < columns.Count; i++) + { + var colIndex = i; + var col = columns[i]; + col.IsSortable = IsSortableColumn(colIndex); + var comparer = GetColumnComparer(colIndex); + col.CustomRowComparer = (a, b) => + a.Tag is TItem ta && b.Tag is TItem tb ? comparer.Compare(ta, tb) : 0; + } + + _pageIndicator = new MarkupControl([string.Empty]) { Name = $"{GetType().Name}Page" }; + + _prevPageButton = Controls.Button(" ◄ ") + .OnClick((_, _) => PreviousPage()) + .WithName($"{GetType().Name}PrevPage") + .Build(); + + _nextPageButton = Controls.Button(" ► ") + .OnClick((_, _) => NextPage()) + .WithName($"{GetType().Name}NextPage") + .Build(); + + _filterPrompt = Controls.Prompt("/ filter: ") + .WithHistory(true) + .WithTabCompleter((input, _) => GetCompletions(input)) + .OnInputChanged((_, text) => + { + _currentFilter = text ?? string.Empty; + _pageIndex = 0; + RebuildRows(); + }) + .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) + .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) + .WithName($"{GetType().Name}Filter") + .Build(); + + _detailPanel = Controls.Panel() + .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select an item.[/]") + .WithHeader($" {DetailPanelHeader} ") + .Rounded() + .WithBorderColor(DetailBorderColor) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName($"{GetType().Name}Detail") + .Build(); + + var pageNavRow = HorizontalGridControl.Create() + .Column(c => c.Width(5).Add(_prevPageButton)) + .Column(c => c.Add(_pageIndicator)) + .Column(c => c.Width(5).Add(_nextPageButton)) + .Build(); + + var leftPane = Controls.ScrollablePanel() + .AddControl(_filterPrompt) + .AddControl(_table) + .AddControl(pageNavRow) + .WithVerticalScroll(ScrollMode.None) + .Build(); + + _root = HorizontalGridControl.Create() + .Column(c => c.Add(leftPane)) + .WithSplitterAfter(0) + .Column(c => c.Width(DetailPaneWidth).Add(_detailPanel)) + .Build(); + + if (_pendingData is not null) + { + var wasActive = IsActive; + IsActive = false; + UpdateData(_pendingData); + IsActive = wasActive; + _filterPrompt.Input = _currentFilter; + } + + return _root; + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + + if (_table is null) + { + return; + } + + _allItems = [.. GetItems(data)]; + + if (!IsActive) + { + RebuildRows(); + } + } + + /// + public void ActivateFilter(Window window) + { + if (_filterPrompt is not null) + { + window.FocusControl(_filterPrompt); + } + } + + /// + public void ClearFilter() + { + _currentFilter = string.Empty; + SetFilterInput(string.Empty); + RebuildRows(); + } + + /// + public void ToggleDetailPane() + { + if (_root is null) + { + return; + } + + _detailPaneVisible = !_detailPaneVisible; + var targetWidth = _detailPaneVisible ? DetailPaneWidth : 0; + _root.AnimateColumnWidth(1, targetWidth, TimeSpan.FromMilliseconds(180), EasingFunctions.EaseInOut); + } + + /// + public void MoveSelectionDown() + { + if (_table is null || _table.SelectedRowIndex >= _table.Rows.Count - 1) + { + return; + } + + _table.SelectedRowIndex++; + } + + /// + public void MoveSelectionUp() + { + if (_table is null || _table.SelectedRowIndex <= 0) + { + return; + } + + _table.SelectedRowIndex--; + } + + /// + public void JumpToFirstRow() + { + if (_table is null || _table.Rows.Count == 0) + { + return; + } + + _table.SelectedRowIndex = 0; + } + + /// + public void JumpToLastRow() + { + if (_table is null || _table.Rows.Count == 0) + { + return; + } + + _table.SelectedRowIndex = _table.Rows.Count - 1; + } + + /// + public void NextPage() + { + if (_table is null) + { + return; + } + + var totalPages = ComputeTotalPages(GetFiltered().Count); + if (_pageIndex < totalPages - 1) + { + _pageIndex++; + RebuildRows(); + } + } + + /// + public void PreviousPage() + { + if (_table is null || _pageIndex <= 0) + { + return; + } + + _pageIndex--; + RebuildRows(); + } + + /// + public void Dispose() + { + if (_table is not null) + { + _table.MouseClick -= OnTableMouseClick; + _table.MouseRightClick -= OnTableRightClick; + } + + _root?.Dispose(); + _table?.Dispose(); + _detailPanel?.Dispose(); + _filterPrompt?.Dispose(); + _pageIndicator?.Dispose(); + _prevPageButton?.Dispose(); + _nextPageButton?.Dispose(); + } + + /// Sets the filter text and rebuilds the table rows. + /// The filter string to apply. + public void SetFilter(string filter) + { + _currentFilter = filter; + _pageIndex = 0; + SetFilterInput(filter); + RebuildRows(); + } + + /// Extracts the relevant items from the snapshot. + /// The current workbench data snapshot. + /// The items to display in the table. + protected abstract IEnumerable GetItems(WorkbenchData data); + + /// Returns a stable string key that uniquely identifies this item. + /// The item. + /// A unique string key. + protected abstract string GetKey(TItem item); + + /// Returns cell values for the table row (must match count). + /// The item to render. + /// Cell values for each column, may contain markup. + protected abstract string[] BuildRow(TItem item); + + /// Returns the markup string shown in the right detail panel for the selected item. + /// The selected item, or if nothing is selected. + /// The current workbench data snapshot. + /// Markup content for the detail panel. + protected abstract string RenderDetail(TItem? item, WorkbenchData? data); + + /// Returns true if the item matches the given filter text. + /// The item to test. + /// The current filter string. + /// if the item passes the filter. + protected abstract bool MatchesFilter(TItem item, string filter); + + /// + /// Returns the comparer used to sort the given column. + /// Default: OrdinalIgnoreCase string comparison on the rendered cell text. + /// Override for numeric, date, or enum columns to get correct cross-page sort order. + /// + /// The zero-based column index being sorted. + /// A comparer for . + protected virtual IComparer GetColumnComparer(int columnIndex) => + Comparer.Create((a, b) => string.Compare( + Markup.Remove(BuildRow(a).ElementAtOrDefault(columnIndex) ?? string.Empty), + Markup.Remove(BuildRow(b).ElementAtOrDefault(columnIndex) ?? string.Empty), + StringComparison.OrdinalIgnoreCase)); + + /// Tab-completion tokens for the filter prompt. Default: none. + /// The current user input. + /// Completion suggestions. + protected virtual IEnumerable GetCompletions(string input) => []; + + /// Called when Enter is pressed on a row. Default: no-op. + /// The activated item. + protected virtual void OnRowActivated(TItem item) + { + } + + /// + /// Returns the actions available when is selected. + /// Override in action views to expose view-specific actions to the keyboard dispatcher + /// and right-click context menu. + /// + /// The currently selected item. + /// The list of actions the user can invoke on this item. + protected virtual IReadOnlyList GetAvailableActions(TItem item) => []; + + /// + /// Returns when the user may sort by the given column. + /// Override to restrict sorting to specific columns only. + /// + /// The zero-based column index to test. + /// if the column is sortable. + protected virtual bool IsSortableColumn(int columnIndex) => true; + + static int ContextMenuWidth(List actions) + { + var maxLabel = actions.Max(a => a.Label.Length); + var maxHint = actions.Max(a => a.KeyHint?.Length ?? 0); + return maxLabel + (maxHint > 0 ? maxHint + 4 : 0) + 4; + } + + static void SetButtonEnabled(ButtonControl? button, bool enabled) + { + if (button is null) + { + return; + } + + button.IsEnabled = enabled; + } + + int ComputeTotalPages(int itemCount) + { + var pageSize = PageSize; + return Math.Max(1, (itemCount + pageSize - 1) / pageSize); + } + + List GetFiltered() => + string.IsNullOrEmpty(_currentFilter) + ? _allItems + : [.. _allItems.Where(item => MatchesFilter(item, _currentFilter))]; + + List ApplySort(List items, int sortCol, SortDirection sortDir) + { + var col = sortCol >= 0 ? sortCol : DefaultSortColumn; + var dir = sortDir != SortDirection.None ? sortDir : DefaultSortDirection; + + if (col < 0 || dir == SortDirection.None) + { + return items; + } + + var comparer = GetColumnComparer(col); + return dir == SortDirection.Ascending + ? [.. items.Order(comparer)] + : [.. items.OrderDescending(comparer)]; + } + + void SetFilterInput(string value) + { + if (_filterPrompt is null) + { + return; + } + + _filterPrompt.Input = value; + } + + /// + /// Fires after every left-click on the table, including header clicks. + /// SortByColumn runs before MouseClick fires, so we can compare the table's new + /// sort state against what we applied last time. When it differs, a header click changed the + /// sort and we rebuild the full dataset with the new sort applied across all pages. + /// + /// The event source. + /// Mouse event args. + void OnTableMouseClick(object? sender, MouseEventArgs e) + { + if (_table is null) + { + return; + } + + var newCol = _table.SortColumnIndex; + var newDir = _table.CurrentSortDirection; + + if (newCol != _lastAppliedSortColumn || newDir != _lastAppliedSortDirection) + { + RebuildRows(); + } + } + + void OnTableRightClick(object? sender, MouseEventArgs e) + { + if (_windowSystem is null || SelectedItem is not TItem item) + { + return; + } + + var actions = GetAvailableActions(item).ToList(); + if (actions.Count == 0) + { + return; + } + + ShowContextMenu(e.AbsolutePosition.X, e.AbsolutePosition.Y, actions); + } + + void ShowContextMenu(int x, int y, List actions) + { + if (_windowSystem is null) + { + return; + } + + var menuBuilder = Controls.Menu().Vertical() + .WithMenuBarColors(WorkbenchColors.Background, WorkbenchColors.Foreground, WorkbenchColors.Accent, WorkbenchColors.Background) + .WithDropdownColors(WorkbenchColors.Background, WorkbenchColors.Foreground, WorkbenchColors.Accent, WorkbenchColors.Background); + + foreach (var action in actions) + { + menuBuilder.AddItem(action.Label, action.KeyHint ?? string.Empty, action.Execute); + } + + var menu = menuBuilder.Build(); + Window? contextWindow = null; + + menu.ItemSelected += (_, _) => _windowSystem.CloseWindow(contextWindow, activateParent: true, force: false); + + var width = Math.Max(20, ContextMenuWidth(actions)); + var height = actions.Count + 2; + var clampedX = Math.Max(0, Math.Min(x, Console.WindowWidth - width)); + var clampedY = Math.Max(0, Math.Min(y, Console.WindowHeight - height)); + + contextWindow = new WindowBuilder(_windowSystem) + .WithTitle(string.Empty) + .HideTitle() + .HideCloseButton() + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(width, height) + .AtPosition(clampedX, clampedY) + .WithCloseOnDeactivate(true) + .AddControl(menu) + .OnKeyPressed((_, ke) => + { + if (ke.KeyInfo.Key == ConsoleKey.Escape) + { + _windowSystem.CloseWindow(contextWindow, activateParent: true, force: false); + ke.Handled = true; + } + }) + .Build(); + + _windowSystem.AddWindow(contextWindow, activateWindow: true); + } + + void RebuildRows() + { + if (_table is null) + { + return; + } + + // Capture sort state before clearing rows. + var sortCol = _table.SortColumnIndex; + var sortDir = _table.CurrentSortDirection; + + var selectedKey = SelectedItem is TItem sel ? GetKey(sel) : null; + var pageSize = PageSize; + + // Sort the FULL filtered dataset first, then paginate. + // This guarantees consistent cross-page order regardless of column type. + var filtered = GetFiltered(); + var sorted = ApplySort(filtered, sortCol, sortDir); + var totalPages = ComputeTotalPages(sorted.Count); + + if (_pageIndex >= totalPages) + { + _pageIndex = Math.Max(0, totalPages - 1); + } + + _table.ClearRows(); + + foreach (var item in sorted.Skip(_pageIndex * pageSize).Take(pageSize)) + { + _table.AddRow(new UITableRow(BuildRow(item)) { Tag = item }); + } + + // Restore sort indicator on the column header. Rows are already in sorted order from + // ApplySort above; CustomRowComparer matches that comparer so SortByColumn produces + // an identity map — the visual indicator is set without re-ordering rows. + // ClearSort and SortByColumn do NOT fire PropertyChanged, so no recursion risk. + _table.ClearSort(); + if (sortCol >= 0 && sortDir != SortDirection.None) + { + _table.SortByColumn(sortCol); + if (sortDir == SortDirection.Descending) + { + _table.SortByColumn(sortCol); + } + } + else if (DefaultSortColumn >= 0 && DefaultSortDirection != SortDirection.None && sortCol < 0) + { + _table.SortByColumn(DefaultSortColumn); + if (DefaultSortDirection == SortDirection.Descending) + { + _table.SortByColumn(DefaultSortColumn); + } + } + + // Record the sort state we applied so OnTableMouseClick can detect real header-click changes. + _lastAppliedSortColumn = _table.SortColumnIndex; + _lastAppliedSortDirection = _table.CurrentSortDirection; + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + else if (_table.Rows.Count > 0) + { + // ClearRows() resets SelectedRowIndex to -1. Pre-select the first row so + // SelectedItem is always non-null and ViewActions always returns actions. + _table.SelectedRowIndex = 0; + } + + UpdatePageIndicator(sorted.Count, totalPages, pageSize); + RefreshDetail(); + } + + void UpdatePageIndicator(int totalItems, int totalPages, int pageSize) + { + if (_pageIndicator is not null) + { + var mut = WorkbenchColors.Muted.ToMarkup(); + _pageIndicator.Text = totalItems <= pageSize + ? $"[{mut}]{totalItems} item{(totalItems == 1 ? string.Empty : "s")}[/]" + : $"[{mut}]{(_pageIndex * pageSize) + 1}–{Math.Min((_pageIndex + 1) * pageSize, totalItems)} of {totalItems}[/]"; + } + + SetButtonEnabled(_prevPageButton, _pageIndex > 0); + SetButtonEnabled(_nextPageButton, _pageIndex < totalPages - 1); + } + + void RestoreSelection(string key) + { + if (_table is null) + { + return; + } + + // Iterate display positions to account for the active sort map. + var count = _table.Rows.Count; + for (var dispIdx = 0; dispIdx < count; dispIdx++) + { + _table.SelectedRowIndex = dispIdx; + if (_table.SelectedRow?.Tag is TItem item && GetKey(item) == key) + { + return; + } + } + + if (count > 0) + { + _table.SelectedRowIndex = 0; + } + } + + void RefreshDetail() + { + if (_detailPanel is null) + { + return; + } + + _detailPanel.Content = RenderDetail(SelectedItem, _pendingData); + } + + void ActivateSelected() + { + if (SelectedItem is TItem item) + { + OnRowActivated(item); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs new file mode 100644 index 0000000..c85e021 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs @@ -0,0 +1,141 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Controls; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Contract for a workbench view — a navigation-item content pane that can receive live data updates. +/// +public interface IWorkbenchView : IDisposable +{ + /// + /// Gets or sets the callback invoked when the filter input gains or loses focus. + /// = filter focused; = filter unfocused. + /// Wired by MainWindow to gate global keyboard shortcuts. + /// Views that do not have a filter bar may leave this as a no-op. + /// + Action? OnFilterFocusChanged + { + get => null; + set { } + } + + /// + /// Gets the primary focus target for this view (filter prompt or table). + /// Returns for views that do not support keyboard focus routing. + /// + IWindowControl? PrimaryFocusTarget => null; + + /// + /// Gets or sets whether this view is currently visible to the user. + /// When , background data refreshes update the internal cache but do not + /// rebuild the table — preserving the user's sort order, selection, and scroll position. + /// + bool IsActive { get; set; } + + /// + /// Gets the current content of the detail panel, or if no detail is shown. + /// Returns for views that do not have a detail panel. + /// + string? DetailContent => null; + + /// + /// Gets the per-view help text shown in the help overlay — a brief description plus key shortcuts. + /// Returns an empty string when the view provides no extra help. + /// + string ViewHelp => string.Empty; + + /// + /// Gets the actions available for the currently selected row. + /// Consumed by the keyboard dispatcher, right-click context menu, and the Actions menu bar item. + /// Returns an empty list when no item is selected or the view has no actions. + /// + IReadOnlyList ViewActions => []; + + /// + /// Moves the selection one row toward the end of the table. No-op for views without a table. + /// + void MoveSelectionDown() + { + } + + /// + /// Moves the selection one row toward the top of the table. No-op for views without a table. + /// + void MoveSelectionUp() + { + } + + /// + /// Jumps the selection to the first row in the table. No-op for views without a table. + /// + void JumpToFirstRow() + { + } + + /// + /// Jumps the selection to the last row in the table. No-op for views without a table. + /// + void JumpToLastRow() + { + } + + /// + /// Toggles the detail pane open or closed with an animation. + /// Views without a detail pane may leave this as a no-op. + /// + void ToggleDetailPane() + { + } + + /// + /// Builds the initial control hierarchy for this view. Called once during window construction. + /// + /// The SharpConsoleUI window system. + /// The root to embed in the navigation pane. + IWindowControl BuildContent(ConsoleWindowSystem windowSystem); + + /// + /// Called on the background refresh thread whenever new data arrives. + /// Implementations must only update control properties — never create new controls here. + /// + /// The latest workbench data snapshot. + void UpdateData(WorkbenchData data); + + /// + /// Activates the filter bar for this view, if the view supports filtering. + /// Default implementation is a no-op for views that do not have a filter bar. + /// + /// The host used to set focus. + void ActivateFilter(Window window) + { + } + + /// + /// Clears the current filter and returns focus to the table. No-op for views without a filter. + /// + void ClearFilter() + { + } + + /// + /// Applies the given filter string and rebuilds the table rows. No-op for views without a filter. + /// + /// The filter text to apply. + void SetFilter(string filter) + { + } + + /// Advances to the next page of results. No-op for views without pagination. + void NextPage() + { + } + + /// Goes back to the previous page of results. No-op for views without pagination. + void PreviousPage() + { + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/IdentitiesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/IdentitiesView.cs new file mode 100644 index 0000000..f24da01 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/IdentitiesView.cs @@ -0,0 +1,58 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Contracts.Identities; +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Identities navigation item — filterable table of known identities with a detail pane. +/// +public class IdentitiesView : FilterableTableView +{ + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Name", TextJustification.Left, null), + ("UserName", TextJustification.Left, 30), + ("Subject", TextJustification.Left, 40) + ]; + + /// + protected override string DetailPanelHeader => "IDENTITY"; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.Identities.OrderBy(i => i.Name); + + /// + protected override string GetKey(Identity item) => item.Subject; + + /// + protected override string[] BuildRow(Identity item) => + [item.Name, item.UserName, item.Subject]; + + /// + protected override string RenderDetail(Identity? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an identity.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + + return string.Join( + "\n", + $"[{mut}]Subject[/] {item.Subject}", + $"[{mut}]Name[/] {item.Name}", + $"[{mut}]UserName[/] {item.UserName}"); + } + + /// + protected override bool MatchesFilter(Identity item, string filter) => + item.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.UserName.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Subject.Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs new file mode 100644 index 0000000..69e1817 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs @@ -0,0 +1,185 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Contracts.Jobs; +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Jobs tab — filterable table of background jobs with stop/resume actions in the detail pane. +/// +public class JobsView : FilterableTableView +{ + /// Gets the currently selected job, or if none is selected. + public Job? SelectedJob => SelectedItem; + + /// + public override string ViewHelp => + "Lists all background jobs and their current status.\n" + + " [S] Stop the selected job\n" + + " [U] Resume a stopped job\n" + + " [Space] Check / uncheck row for bulk operations\n" + + " [S] / [U] (with 2+ checked) Bulk stop / resume all checked jobs"; + + /// + /// Gets or sets the callback invoked when the user requests to stop a job. + /// + public Action? OnStopJob { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests to resume a job. + /// + public Action? OnResumeJob { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk stop of all checked jobs. + /// + public Action>? OnStopAll { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk resume of all checked jobs. + /// + public Action>? OnResumeAll { get; set; } + + /// + /// Gets all jobs that are currently checked (checkbox mode). + /// + public IReadOnlyList Checked => CheckedItems; + + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Status", TextJustification.Left, 22), + ("Type", TextJustification.Left, null), + ("Progress", TextJustification.Right, 14) + ]; + + /// + protected override string DetailPanelHeader => "JOB"; + + /// + protected override bool HasCheckboxMode => true; + + /// + protected override IReadOnlyList GetAvailableActions(Job item) + { + List actions = []; + if (OnStopJob is not null) + { + actions.Add(new ViewAction("Stop job", "S", ConsoleKey.S, default, () => OnStopJob(item))); + } + + if (OnResumeJob is not null) + { + actions.Add(new ViewAction("Resume job", "U", ConsoleKey.U, default, () => OnResumeJob(item))); + } + + var checkedItems = Checked; + if (OnStopAll is not null && checkedItems.Count > 1) + { + actions.Add(new ViewAction($"Stop {checkedItems.Count} checked", null, null, default, () => OnStopAll(checkedItems))); + } + + if (OnResumeAll is not null && checkedItems.Count > 1) + { + actions.Add(new ViewAction($"Resume {checkedItems.Count} checked", null, null, default, () => OnResumeAll(checkedItems))); + } + + return actions; + } + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.Jobs.OrderBy(j => j.Status.ToString()); + + /// + protected override string GetKey(Job item) => item.Id.ToString(); + + /// + protected override string[] BuildRow(Job item) + { + var statusColor = GetJobStatusColor(item.Status); + return + [ + $"[{statusColor}]{item.Status}[/]", + item.Type ?? item.Id.ToString(), + FormatProgress(item.Progress) + ]; + } + + /// + protected override string RenderDetail(Job? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a job.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var statusColor = GetJobStatusColor(item.Status); + + var lines = new List + { + $"[{mut}]Id[/] {item.Id}", + $"[{mut}]Type[/] {item.Type ?? "—"}", + $"[{mut}]Status[/] [{statusColor}]{item.Status}[/]", + $"[{mut}]Progress[/] {FormatProgress(item.Progress)}" + }; + + if (item.Progress is not null) + { + lines.Add($"[{mut}]Steps[/] {item.Progress.SuccessfulSteps}/{item.Progress.TotalSteps}"); + if (item.Progress.FailedSteps > 0) + { + lines.Add($"[{WorkbenchColors.Danger.ToMarkup()}]Failed[/] {item.Progress.FailedSteps}"); + } + + if (!string.IsNullOrEmpty(item.Progress.Message)) + { + lines.Add($"[{mut}]Message[/] {item.Progress.Message}"); + } + } + + if (OnStopJob is not null || OnResumeJob is not null) + { + lines.Add(string.Empty); + if (OnStopJob is not null) + { + lines.Add($"[{mut}]Press[/] [bold]S[/] [{mut}]to stop[/]"); + } + + if (OnResumeJob is not null) + { + lines.Add($"[{mut}]Press[/] [bold]U[/] [{mut}]to resume[/]"); + } + } + + return string.Join('\n', lines); + } + + /// + protected override bool MatchesFilter(Job item, string filter) => + (item.Type ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Status.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase); + + static string GetJobStatusColor(JobStatus status) => status switch + { + JobStatus.Running => WorkbenchColors.Success.ToMarkup(), + JobStatus.Failed => WorkbenchColors.Danger.ToMarkup(), + JobStatus.Stopped => WorkbenchColors.Warning.ToMarkup(), + JobStatus.CompletedWithFailures => WorkbenchColors.Warning.ToMarkup(), + _ => WorkbenchColors.Muted.ToMarkup() + }; + + static string FormatProgress(JobProgress? p) + { + if (p is null || p.TotalSteps == 0) + { + return "—"; + } + + return $"{p.SuccessfulSteps}/{p.TotalSteps}"; + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/JsonYamlFormatter.cs b/Source/Cli/Commands/Chronicle/Workbench/views/JsonYamlFormatter.cs new file mode 100644 index 0000000..3c9af78 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/JsonYamlFormatter.cs @@ -0,0 +1,102 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using System.Text.Json; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Formats JSON strings as human-readable YAML-style markup for display in workbench detail panes. +/// +static class JsonYamlFormatter +{ + /// + /// Formats a JSON string as an indented, human-readable YAML-style representation. + /// Falls back to wrapping the raw string in muted markup when parsing fails. + /// + /// The JSON string to format. + /// The SharpConsoleUI markup tag applied to property key labels. + /// A formatted markup string. + internal static string FormatAsYaml(string json, string mutedMarkup) + { + if (string.IsNullOrWhiteSpace(json)) + { + return $"[{mutedMarkup}](empty)[/]"; + } + + try + { + var doc = JsonDocument.Parse(json); + var sb = new StringBuilder(); + AppendElement(doc.RootElement, sb, 0, mutedMarkup); + return sb.ToString().TrimEnd(); + } + catch + { + return $"[{mutedMarkup}]{json}[/]"; + } + } + + static void AppendElement(JsonElement element, StringBuilder sb, int indent, string mut) + { + var pad = new string(' ', indent * 2); + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var prop in element.EnumerateObject()) + { + // Skip JSON Schema noise that clutters the display without adding value. + if (IsJsonSchemaNoise(prop.Name)) + { + continue; + } + + if (prop.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + { + sb.AppendLine($"{pad}[{mut}]{prop.Name}:[/]"); + AppendElement(prop.Value, sb, indent + 1, mut); + } + else + { + sb.AppendLine($"{pad}[{mut}]{prop.Name}:[/] {ScalarText(prop.Value)}"); + } + } + + break; + + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + { + sb.AppendLine($"{pad}-"); + AppendElement(item, sb, indent + 1, mut); + } + else + { + sb.AppendLine($"{pad}- {ScalarText(item)}"); + } + } + + break; + + default: + sb.AppendLine($"{pad}{ScalarText(element)}"); + break; + } + } + + static bool IsJsonSchemaNoise(string key) => + string.Equals(key, "required", StringComparison.Ordinal) || + string.Equals(key, "$schema", StringComparison.Ordinal) || + string.Equals(key, "definitions", StringComparison.Ordinal) || + string.Equals(key, "$defs", StringComparison.Ordinal); + + static string ScalarText(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? string.Empty, + JsonValueKind.Null => "~", + _ => element.GetRawText() + }; +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs new file mode 100644 index 0000000..a98677a --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs @@ -0,0 +1,131 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Namespaces tab — list of available namespaces in the current event store with a Switch action. +/// Selecting an entry and pressing Enter switches the active namespace. +/// +public class NamespacesView : IWorkbenchView +{ + TableControl? _table; + MarkupControl? _helpPane; + WorkbenchData? _pendingData; + + /// + public bool IsActive { get; set; } + + /// + /// Gets or sets the callback invoked when the user switches to a different namespace. + /// + public Action? OnSwitch { get; set; } + + /// + public void Dispose() + { + _table?.Dispose(); + _helpPane?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("Namespace", SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithFiltering() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnRowActivated((_, _) => SwitchToSelected()) + .WithName("NamespacesTable") + .Build(); + + _helpPane = new MarkupControl( + [ + $"[{WorkbenchColors.Accent.ToMarkup()}][bold]SWITCH NAMESPACE[/][/]", + string.Empty, + $" [{WorkbenchColors.Muted.ToMarkup()}]Select a namespace and press[/] [bold]Enter[/] [{WorkbenchColors.Muted.ToMarkup()}]to switch.[/]" + ]) + { Name = "NamespacesHelp" }; + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(_table)) + .WithSplitterAfter(0) + .Column(c => c.Width(44).Add(_helpPane)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + var selectedKey = _table.SelectedRow?.Tag as string; + + _table.ClearRows(); + foreach (var name in data.NamespaceNames.Order()) + { + _table.AddRow(new UITableRow([name]) { Tag = name }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + if (_helpPane is null) return; + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + + var lines = new List + { + $"[{acc}][bold]SWITCH NAMESPACE[/][/]", + string.Empty, + $"[{mut}]Active[/] [{suc}]{data.Namespace}[/]", + $"[{mut}]Store[/] [{mut}]{data.EventStore}[/]", + $"[{mut}]Available[/] {data.NamespaceNames.Count}", + string.Empty, + $" [{mut}]Select a namespace and press[/] [bold]Enter[/]", + $" [{mut}]to make it the active namespace.[/]" + }; + + _helpPane.Text = string.Join('\n', lines); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is string name && name == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void SwitchToSelected() + { + if (_table?.SelectedRow?.Tag is string nsName) + { + OnSwitch?.Invoke(nsName); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs new file mode 100644 index 0000000..9cf3725 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs @@ -0,0 +1,195 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Observers navigation item — sortable, filterable table with a detail pane showing observer state and event types. +/// +public class ObserversView : FilterableTableView +{ + /// Gets the currently selected observer, or if none is selected. + public ObserverInformation? SelectedObserver => SelectedItem; + + /// + public override string ViewHelp => + "Lists all registered observers and their current running state.\n" + + " [R] Replay the selected observer from the beginning\n" + + " [Space] Check / uncheck row for bulk operations\n" + + " [R] (with 2+ checked) Replay all checked observers"; + + /// + /// Gets or sets the callback invoked when the user requests a replay of the selected observer. + /// + public Action? OnReplay { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk replay of all checked observers. + /// + public Action>? OnReplayAll { get; set; } + + /// + /// Gets all observers that are currently checked (checkbox mode). + /// + public IReadOnlyList Checked => CheckedItems; + + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("State", TextJustification.Left, 18), + ("Id", TextJustification.Left, null), + ("Type", TextJustification.Left, 16) + ]; + + /// + protected override string DetailPanelHeader => "OBSERVER"; + + /// Uses the warning amber to match the OBSERVATION section color. + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Warning; + + /// + protected override bool HasCheckboxMode => true; + + /// + protected override IReadOnlyList GetAvailableActions(ObserverInformation item) + { + List actions = []; + if (OnReplay is not null) + { + actions.Add(new ViewAction("Replay observer", "R", ConsoleKey.R, default, () => OnReplay(item))); + } + + var checkedItems = Checked; + if (OnReplayAll is not null && checkedItems.Count > 1) + { + actions.Add(new ViewAction($"Replay {checkedItems.Count} checked", null, null, default, () => OnReplayAll(checkedItems))); + } + + return actions; + } + + /// + protected override IComparer GetColumnComparer(int columnIndex) => columnIndex switch + { + 0 => Comparer.Create((a, b) => ObserverSortOrder(a).CompareTo(ObserverSortOrder(b))), + _ => base.GetColumnComparer(columnIndex) + }; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.Observers.OrderBy(ObserverSortOrder).ThenBy(o => o.Id); + + /// + protected override string GetKey(ObserverInformation item) => item.Id; + + /// + protected override string[] BuildRow(ObserverInformation item) + { + var stateColor = GetObserverStateColor(item); + var icon = GetObserverIcon(item); + + return + [ + $"[{stateColor}]{icon} {item.RunningState}[/]", + item.Id, + item.Type.ToString() + ]; + } + + /// + protected override string RenderDetail(ObserverInformation? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an observer.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var stateColor = GetObserverStateColor(item); + + var lines = new List + { + $"[{mut}]Id[/] {item.Id}", + $"[{mut}]Type[/] {item.Type}", + $"[{mut}]State[/] [{stateColor}]{item.RunningState}[/]", + $"[{mut}]Last seq[/] {(item.LastHandledEventSequenceNumber == ulong.MaxValue ? "N/A" : item.LastHandledEventSequenceNumber.ToString("N0"))}", + string.Empty, + $"[{mut}]Event Types:[/]" + }; + + foreach (var et in (item.EventTypes ?? []).OrderBy(e => e.Id)) + { + lines.Add($" • {et.Id} gen {et.Generation}"); + } + + if (OnReplay is not null) + { + lines.Add(string.Empty); + lines.Add($"[{mut}]Press[/] [bold]R[/] [{mut}]to replay[/]"); + } + + return string.Join('\n', lines); + } + + /// + protected override bool MatchesFilter(ObserverInformation item, string filter) + { + if (filter.StartsWith("state:", StringComparison.OrdinalIgnoreCase)) + { + return item.RunningState.ToString().Contains(filter[6..], StringComparison.OrdinalIgnoreCase); + } + + if (filter.StartsWith("type:", StringComparison.OrdinalIgnoreCase)) + { + return item.Type.ToString().Contains(filter[5..], StringComparison.OrdinalIgnoreCase); + } + + // event:TypeId — match observers that subscribe to the given event type ID + if (filter.StartsWith("event:", StringComparison.OrdinalIgnoreCase)) + { + var eventTypeId = filter[6..]; + return (item.EventTypes ?? []).Any(et => et.Id.Contains(eventTypeId, StringComparison.OrdinalIgnoreCase)); + } + + return item.Id.Contains(filter, StringComparison.OrdinalIgnoreCase); + } + + /// + protected override IEnumerable GetCompletions(string input) => + [ + "state:active", + "state:replaying", + "state:disconnected", + "state:suspended", + "type:projection", + "type:reducer", + "type:reactor" + ]; + + static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch + { + ObserverRunningState.Disconnected => 0, + ObserverRunningState.Replaying => 1, + ObserverRunningState.Active => 2, + ObserverRunningState.Suspended => 3, + _ => 4 + }; + + static string GetObserverStateColor(ObserverInformation obs) => obs.RunningState switch + { + ObserverRunningState.Active => WorkbenchColors.Success.ToMarkup(), + ObserverRunningState.Replaying => WorkbenchColors.Warning.ToMarkup(), + ObserverRunningState.Disconnected => WorkbenchColors.Danger.ToMarkup(), + _ => WorkbenchColors.Muted.ToMarkup() + }; + + static string GetObserverIcon(ObserverInformation obs) => obs.RunningState switch + { + ObserverRunningState.Active => "●", + ObserverRunningState.Replaying => "▲", + ObserverRunningState.Disconnected => "⊘", + _ => "○" + }; +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs new file mode 100644 index 0000000..8e47178 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs @@ -0,0 +1,179 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Overview tab — server health card, observer stats, and attention items (failures + recommendations), +/// laid out as three bordered rounded panels side by side. +/// +public class OverviewView : IWorkbenchView +{ + readonly Queue _observerHistory = new(capacity: 10); + PanelControl? _healthPanel; + PanelControl? _observerPanel; + PanelControl? _attentionPanel; + SparklineControl? _observerSparkline; + WorkbenchData? _pendingData; + + /// + public bool IsActive { get; set; } + + /// + public void Dispose() + { + _healthPanel?.Dispose(); + _observerPanel?.Dispose(); + _attentionPanel?.Dispose(); + _observerSparkline?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _healthPanel = Controls.Panel() + .WithContent("Loading...") + .WithHeader(" SERVER HEALTH ") + .Rounded() + .WithBorderColor(WorkbenchColors.Accent) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("OverviewHealthPanel") + .Build(); + + _observerSparkline = new SparklineBuilder() + .WithHeight(3) + .WithBarColor(WorkbenchColors.Accent) + .WithTitle("observer count history", WorkbenchColors.Muted) + .WithData([0]) + .Build(); + + _observerPanel = Controls.Panel() + .WithContent("Loading...") + .WithHeader(" OBSERVERS ") + .Rounded() + .WithBorderColor(WorkbenchColors.Accent) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("OverviewObserversPanel") + .Build(); + + _attentionPanel = Controls.Panel() + .WithContent("Loading...") + .WithHeader(" ATTENTION ") + .Rounded() + .WithBorderColor(WorkbenchColors.Accent) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("OverviewAttentionPanel") + .Build(); + + var root = Controls.HorizontalGrid() + .Column(col => col.Add(_healthPanel)) + .Column(col => col.Add(_observerPanel).Add(_observerSparkline)) + .Column(col => col.Add(_attentionPanel)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_healthPanel is null) return; + + var suc = WorkbenchColors.Success.ToMarkup(); + var dan = WorkbenchColors.Danger.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var war = WorkbenchColors.Warning.ToMarkup(); + + var connStatus = data.IsConnected + ? $"[{suc}]● Connected[/]" + : $"[{dan}]● Disconnected[/]"; + var version = data.ServerVersion is not null + ? $"[{mut}]v{data.ServerVersion}[/]" + : $"[{mut}]unknown[/]"; + var seq = data.TailSequenceNumber.HasValue + ? $"[bold]#{data.TailSequenceNumber.Value:N0}[/]" + : $"[{mut}]—[/]"; + + var acc = WorkbenchColors.Accent.ToMarkup(); + _healthPanel.Content = + "[bold]CONTEXT[/]\n" + + $" [{mut}]Store[/] [{acc}]{data.EventStore}[/]\n" + + $" [{mut}]Namespace[/] [{acc}]{data.Namespace}[/]\n" + + "\n" + + $"[{mut}]Status[/] {connStatus}\n" + + $"[{mut}]Version[/] {version}\n" + + $"[{mut}]Tail seq[/] {seq}\n" + + $"[{mut}]Server[/] [{mut}]{data.ConnectionString}[/]"; + + string obsColor; + if (data.DisconnectedObservers > 0) obsColor = dan; + else if (data.ReplayingObservers > 0) obsColor = war; + else obsColor = suc; + + _observerPanel!.Content = + $"[{suc}]●[/] Active [bold]{data.ActiveObservers}[/]\n" + + $"[{war}]▲[/] Replaying [bold]{data.ReplayingObservers}[/]\n" + + $"[{mut}]○[/] Suspended [bold]{data.SuspendedObservers}[/]\n" + + $"[{dan}]⊘[/] Disconnected [bold]{data.DisconnectedObservers}[/]\n" + + $"[{mut}]━[/] Total [{obsColor}][bold]{data.Observers.Count}[/][/]"; + + UpdateObserverSparkline(data.Observers.Count); + + var hasAttention = data.FailedPartitions.Count > 0 || data.Recommendations.Count > 0; + if (hasAttention) + { + _attentionPanel!.BorderColor = WorkbenchColors.Warning; + } + else + { + _attentionPanel!.BorderColor = WorkbenchColors.Accent; + } + + var attentionLines = new List(); + + if (data.FailedPartitions.Count > 0) + { + attentionLines.Add($"[{dan}]⚠[/] [bold]{data.FailedPartitions.Count}[/] failed partition{(data.FailedPartitions.Count == 1 ? string.Empty : "s")} [{mut}]→ press 3[/]"); + } + else + { + attentionLines.Add($"[{suc}]✓[/] No failed partitions"); + } + + if (data.Recommendations.Count > 0) + { + attentionLines.Add($"[{war}]![/] [bold]{data.Recommendations.Count}[/] pending recommendation{(data.Recommendations.Count == 1 ? string.Empty : "s")} [{mut}]→ press 5[/]"); + } + else + { + attentionLines.Add($"[{suc}]✓[/] No pending recommendations"); + } + + _attentionPanel!.Content = string.Join('\n', attentionLines); + } + + void UpdateObserverSparkline(int totalObservers) + { + if (_observerSparkline is null) return; + + _observerHistory.Enqueue(totalObservers); + while (_observerHistory.Count > 10) + { + _observerHistory.Dequeue(); + } + + _observerSparkline.SetDataPoints(_observerHistory); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs new file mode 100644 index 0000000..dc07e7b --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs @@ -0,0 +1,80 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Projections navigation item — filterable table of projection definitions with declaration preview in the detail pane. +/// +public class ProjectionsView : FilterableTableView +{ + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Identifier", TextJustification.Left, null), + ("Read Model", TextJustification.Left, 35) + ]; + + /// + protected override string DetailPanelHeader => "PROJECTION"; + + /// + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Mauve; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.ProjectionDefinitions.OrderBy(d => d.Identifier); + + /// + protected override string GetKey(ProjectionDefinition item) => item.Identifier; + + /// + protected override string[] BuildRow(ProjectionDefinition item) => + [item.Identifier, item.ReadModel ?? string.Empty]; + + /// + protected override string RenderDetail(ProjectionDefinition? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a projection.[/]"; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + + var lines = new List + { + $"[{acc}][bold]PROJECTION[/][/]", + string.Empty, + $" [{mut}]Identifier[/] {item.Identifier}", + $" [{mut}]Read Model[/] {item.ReadModel ?? "—"}", + $" [{mut}]Active[/] [{(item.IsActive ? suc : mut)}]{(item.IsActive ? "Yes" : "No")}[/]", + $" [{mut}]Rewindable[/] [{(item.IsRewindable ? suc : mut)}]{(item.IsRewindable ? "Yes" : "No")}[/]", + string.Empty, + $" [{acc}]Declaration (preview):[/]" + }; + + var declarations = data?.ProjectionDeclarations ?? new Dictionary(); + if (declarations.TryGetValue(item.Identifier, out var declaration) && !string.IsNullOrEmpty(declaration)) + { + foreach (var line in declaration.Split('\n').Take(40)) + { + lines.Add($" [{mut}]{line.TrimEnd()}[/]"); + } + } + else + { + lines.Add($" [{mut}](no declaration available)[/]"); + } + + return string.Join('\n', lines); + } + + /// + protected override bool MatchesFilter(ProjectionDefinition item, string filter) => + item.Identifier.Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs new file mode 100644 index 0000000..b304829 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs @@ -0,0 +1,194 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Read Models navigation item — filterable table of read model definitions with metadata in the detail pane. +/// Pressing Enter on a selected row opens a detail overlay showing definition info and live instances. +/// +public class ReadModelsView : FilterableTableView +{ + ConsoleWindowSystem? _windowSystem; + + /// + /// Gets or sets the callback invoked when the user activates a read model row (Enter). + /// Receives the container name and a cancellation token; returns a + /// snapshot with read model instances populated. + /// + public Func>? OnFetchInstances { get; set; } + + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Container", TextJustification.Left, null), + ("Owner", TextJustification.Left, 12), + ("Source", TextJustification.Left, 14) + ]; + + /// + protected override string DetailPanelHeader => "READ MODEL"; + + /// + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Mauve; + + /// + public override IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _windowSystem = windowSystem; + return base.BuildContent(windowSystem); + } + + /// + /// Opens a detail overlay for the currently selected read model row, if any. + /// + public void OpenSelectedDetailOverlay() + { + if (SelectedItem is WorkbenchReadModel rm) + { + OpenDetailOverlay(rm); + } + } + + /// + /// Opens a detail overlay for the given read model, fetching instances if is wired. + /// + /// The read model to display. + public void OpenDetailOverlay(WorkbenchReadModel rm) + { + if (_windowSystem is null) + { + return; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + var queryableColor = rm.IsQueryable ? suc : mut; + + var infoContent = string.Join( + "\n", + $"[{acc}][bold]{rm.ContainerName}[/][/]", + string.Empty, + $"[{mut}]Container[/] {rm.ContainerName}", + $"[{mut}]Display Name[/] {rm.DisplayName}", + $"[{mut}]Owner[/] {rm.Owner}", + $"[{mut}]Source[/] {rm.Source}", + $"[{mut}]Queryable[/] [{queryableColor}]{(rm.IsQueryable ? "Yes" : "No")}[/]", + $"[{mut}]Identifier[/] {rm.Identifier}"); + + var instancesContent = BuildInstancesContent(rm); + + List<(string TabName, string Content)> tabs = + [ + ("Info", infoContent), + ("Instances", instancesContent) + ]; + + var overlay = new DetailOverlayWindow(); + var window = overlay.Build(_windowSystem, $" {rm.ContainerName} ", tabs, []); + _windowSystem.AddWindow(window, activateWindow: true); + } + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.ReadModelDefinitions.OrderBy(d => d.ContainerName); + + /// + protected override string GetKey(WorkbenchReadModel item) => item.ContainerName; + + /// + protected override string[] BuildRow(WorkbenchReadModel item) => + [item.ContainerName, item.Owner, item.Source]; + + /// + protected override string RenderDetail(WorkbenchReadModel? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a read model.[/]"; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + var queryableColor = item.IsQueryable ? suc : mut; + + return string.Join( + "\n", + $"[{acc}][bold]READ MODEL[/][/]", + string.Empty, + $" [{mut}]Container[/] {item.ContainerName}", + $" [{mut}]Display Name[/] {item.DisplayName}", + $" [{mut}]Owner[/] {item.Owner}", + $" [{mut}]Source[/] {item.Source}", + $" [{mut}]Queryable[/] [{queryableColor}]{(item.IsQueryable ? "Yes" : "No")}[/]", + $" [{mut}]Identifier[/] {item.Identifier}", + string.Empty, + $"[{mut}]Press[/] [bold]Enter[/] [{mut}]to view instances[/]"); + } + + /// + protected override bool MatchesFilter(WorkbenchReadModel item, string filter) + { + if (filter.StartsWith("owner:", StringComparison.OrdinalIgnoreCase)) + { + return item.Owner.Contains(filter[6..], StringComparison.OrdinalIgnoreCase); + } + + return item.ContainerName.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase); + } + + /// + protected override IEnumerable GetCompletions(string input) => + [ + "owner:client", + "owner:server" + ]; + + /// + protected override void OnRowActivated(WorkbenchReadModel item) => OpenDetailOverlay(item); + + string BuildInstancesContent(WorkbenchReadModel rm) + { + var mut = WorkbenchColors.Muted.ToMarkup(); + var dan = WorkbenchColors.Danger.ToMarkup(); + + if (OnFetchInstances is null) + { + return $"[{mut}](No instance loader configured)[/]"; + } + + try + { + var data = OnFetchInstances(rm.ContainerName, CancellationToken.None) + .GetAwaiter().GetResult(); + + if (data.ReadModelInstancesError is not null) + { + return $"[{dan}]Error: {data.ReadModelInstancesError}[/]"; + } + + if (data.ReadModelInstances.Count == 0) + { + return $"[{mut}](No instances found)[/]"; + } + + var separator = $"\n[{mut}]{new string('─', 60)}[/]\n"; + var total = data.ReadModelInstancesTotalCount; + var shown = data.ReadModelInstances.Count; + var header = $"[{mut}]Showing {shown} of {total} instance{(total == 1 ? string.Empty : "s")}[/]\n"; + + return header + separator + string.Join(separator, data.ReadModelInstances); + } + catch (Exception ex) + { + return $"[{dan}]Error fetching instances: {ex.Message}[/]"; + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs new file mode 100644 index 0000000..67a17a6 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs @@ -0,0 +1,145 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Recommendations tab — filterable table of pending recommendations with apply/ignore actions. +/// +public class RecommendationsView : FilterableTableView +{ + /// Gets the currently selected recommendation, or if none is selected. + public Recommendation? SelectedRecommendation => SelectedItem; + + /// + public override string ViewHelp => + "Lists pending recommendations suggested by Chronicle.\n" + + " [A] Apply the selected recommendation\n" + + " [I] Ignore the selected recommendation\n" + + " [Space] Check / uncheck row for bulk operations\n" + + " [A] / [I] (with 2+ checked) Bulk apply / ignore all checked"; + + /// + /// Gets or sets the callback invoked when the user applies a recommendation. + /// + public Action? OnApply { get; set; } + + /// + /// Gets or sets the callback invoked when the user ignores a recommendation. + /// + public Action? OnIgnore { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk apply of all checked recommendations. + /// + public Action>? OnApplyAll { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk ignore of all checked recommendations. + /// + public Action>? OnIgnoreAll { get; set; } + + /// + /// Gets all recommendations that are currently checked (checkbox mode). + /// + public IReadOnlyList Checked => CheckedItems; + + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Name", TextJustification.Left, null), + ("Type", TextJustification.Left, 20) + ]; + + /// + protected override string DetailPanelHeader => "RECOMMENDATION"; + + /// + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Warning; + + /// + protected override bool HasCheckboxMode => true; + + /// + protected override IReadOnlyList GetAvailableActions(Recommendation item) + { + List actions = []; + if (OnApply is not null) + { + actions.Add(new ViewAction("Apply recommendation", "A", ConsoleKey.A, default, () => OnApply(item))); + } + + if (OnIgnore is not null) + { + actions.Add(new ViewAction("Ignore recommendation", "I", ConsoleKey.I, default, () => OnIgnore(item))); + } + + var checkedItems = Checked; + if (OnApplyAll is not null && checkedItems.Count > 1) + { + actions.Add(new ViewAction($"Apply {checkedItems.Count} checked", null, null, default, () => OnApplyAll(checkedItems))); + } + + if (OnIgnoreAll is not null && checkedItems.Count > 1) + { + actions.Add(new ViewAction($"Ignore {checkedItems.Count} checked", null, null, default, () => OnIgnoreAll(checkedItems))); + } + + return actions; + } + + /// + protected override IEnumerable GetItems(WorkbenchData data) => data.Recommendations; + + /// + protected override string GetKey(Recommendation item) => item.Id.ToString(); + + /// + protected override string[] BuildRow(Recommendation item) => + [item.Name ?? item.Id.ToString(), item.Type ?? "—"]; + + /// + protected override string RenderDetail(Recommendation? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a recommendation.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + + var lines = new List + { + $"[{mut}]Name[/] {item.Name ?? item.Id.ToString()}", + $"[{mut}]Type[/] {item.Type ?? "—"}" + }; + + if (!string.IsNullOrEmpty(item.Description)) + { + lines.Add(string.Empty); + lines.Add($"[{mut}]Description:[/]"); + lines.Add($" {item.Description}"); + } + + lines.Add(string.Empty); + if (OnApply is not null) + { + lines.Add($"[{mut}]Press[/] [bold]A[/] [{mut}]to apply[/]"); + } + + if (OnIgnore is not null) + { + lines.Add($"[{mut}]Press[/] [bold]I[/] [{mut}]to ignore[/]"); + } + + return string.Join('\n', lines); + } + + /// + protected override bool MatchesFilter(Recommendation item, string filter) => + (item.Name ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) || + (item.Type ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/SubscriptionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/SubscriptionsView.cs new file mode 100644 index 0000000..b6ef277 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/SubscriptionsView.cs @@ -0,0 +1,71 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Contracts.Observation.EventStoreSubscriptions; +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Subscriptions navigation item — filterable table of event store subscriptions with a detail pane. +/// +public class SubscriptionsView : FilterableTableView +{ + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Identifier", TextJustification.Left, null), + ("Source Store", TextJustification.Left, 30), + ("Event Types", TextJustification.Right, 12) + ]; + + /// + protected override string DetailPanelHeader => "SUBSCRIPTION"; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.EventStoreSubscriptions.OrderBy(s => s.Identifier); + + /// + protected override string GetKey(EventStoreSubscriptionDefinition item) => item.Identifier; + + /// + protected override string[] BuildRow(EventStoreSubscriptionDefinition item) => + [ + item.Identifier, + item.SourceEventStore, + (item.EventTypes?.Count ?? 0).ToString() + ]; + + /// + protected override string RenderDetail(EventStoreSubscriptionDefinition? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a subscription.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var lines = new List + { + $"[{mut}]Identifier[/] {item.Identifier}", + $"[{mut}]Source Store[/] {item.SourceEventStore}", + string.Empty, + $"[{acc}]Event Types:[/]" + }; + + foreach (var et in item.EventTypes ?? []) + { + lines.Add($" • {et.Id}"); + } + + return string.Join('\n', lines); + } + + /// + protected override bool MatchesFilter(EventStoreSubscriptionDefinition item, string filter) => + item.Identifier.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.SourceEventStore.Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/UsersView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/UsersView.cs new file mode 100644 index 0000000..51c2936 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/UsersView.cs @@ -0,0 +1,70 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Users navigation item — filterable table of registered users with a detail pane. +/// +public class UsersView : FilterableTableView +{ + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Username", TextJustification.Left, null), + ("Email", TextJustification.Left, 30), + ("Active", TextJustification.Left, 8) + ]; + + /// + protected override string DetailPanelHeader => "USER"; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.Users.OrderBy(u => u.Username); + + /// + protected override string GetKey(User item) => item.Id.ToString(); + + /// + protected override string[] BuildRow(User item) + { + var activeColor = item.IsActive ? WorkbenchColors.Success.ToMarkup() : WorkbenchColors.Muted.ToMarkup(); + return + [ + item.Username, + item.Email ?? string.Empty, + $"[{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]" + ]; + } + + /// + protected override string RenderDetail(User? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a user.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + var activeColor = item.IsActive ? suc : mut; + + return string.Join( + "\n", + $"[{mut}]Id[/] {item.Id}", + $"[{mut}]Username[/] {item.Username}", + $"[{mut}]Email[/] {item.Email ?? "—"}", + $"[{mut}]Active[/] [{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]", + $"[{mut}]Has Logged In[/] {item.HasLoggedIn}", + $"[{mut}]Created[/] {item.CreatedAt}"); + } + + /// + protected override bool MatchesFilter(User item, string filter) => + item.Username.Contains(filter, StringComparison.OrdinalIgnoreCase) || + (item.Email ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs new file mode 100644 index 0000000..f0c7610 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs @@ -0,0 +1,89 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Builds a modal overlay window for displaying item detail in tabbed panes with optional action buttons. +/// +public class DetailOverlayWindow +{ + Window? _window; + + /// + /// Builds a detail overlay window with the specified title, tabbed content, and action buttons. + /// + /// The SharpConsoleUI window system used to close the window on Escape. + /// The overlay window title. + /// + /// A list of (TabName, Content) tuples. Each tab displays its markup content in a scrollable panel. + /// + /// + /// A list of (Label, Execute) tuples. Each entry produces a toolbar button that invokes the callback. + /// + /// A configured ready to be passed to windowSystem.AddWindow. + public Window Build( + ConsoleWindowSystem windowSystem, + string title, + IReadOnlyList<(string TabName, string Content)> tabs, + IReadOnlyList<(string Label, Action Execute)> actions) + { + var tabBuilder = Controls.TabControl(); + + foreach (var (tabName, content) in tabs) + { + // Use a read-only MultilineEdit so text can be selected with mouse/keyboard and copied. + // Markup is stripped to plain text — colors are not rendered in editable controls. + var plainText = Markup.Remove(content); + var editor = Controls.MultilineEdit(plainText) + .AsReadOnly(true) + .WrapWords() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithSelectionColors(WorkbenchColors.Accent, WorkbenchColors.Background) + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithFocusedColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .Build(); + + tabBuilder.AddTab(tabName, editor); + } + + var tabControl = tabBuilder.Fill().Build(); + + var toolbarBuilder = Controls.Toolbar() + .WithBelowLine(false) + .WithAboveLine(true) + .WithAboveLineColor(WorkbenchColors.Muted); + + foreach (var (label, execute) in actions) + { + toolbarBuilder.AddButton(label, (_, _) => execute()); + } + + var toolbar = toolbarBuilder.Build(); + + _window = new WindowBuilder(windowSystem) + .WithTitle(title) + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .Centered() + .WithSize(120, 35) + .AddControl(tabControl) + .AddControl(toolbar) + .OnKeyPressed(HandleKeyPress) + .Build(); + + return _window; + + void HandleKeyPress(object? sender, KeyPressedEventArgs e) + { + if (e.KeyInfo.Key == ConsoleKey.Escape && _window is not null) + { + windowSystem.CloseWindow(_window, activateParent: true, force: false); + e.Handled = true; + } + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs new file mode 100644 index 0000000..e6da66b --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs @@ -0,0 +1,372 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Contracts.Jobs; +using Cratis.Chronicle.Contracts.Observation; +using Cratis.Chronicle.Contracts.Recommendations; +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Helpers; +using SharpConsoleUI.Rendering; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Composition root for the Chronicle Workbench TUI — creates and wires all subsystems, then builds the main window. +/// +/// The SharpConsoleUI window system. +/// The Chronicle data service. +/// The workbench settings. +/// The Chronicle gRPC service clients. +/// Pre-fetched snapshot used to populate all views before the first frame is rendered. +/// Persisted workbench state from the previous session (last nav index, interval). +public class MainWindow( + ConsoleWindowSystem windowSystem, + WorkbenchDataService dataService, + WorkbenchSettings settings, + IServices services, + WorkbenchData initialData, + WorkbenchState state) +{ + readonly IWorkbenchView[] _views = WorkbenchViewRegistry.CreateViews(); + + string? _activeEventStore; + string? _activeNamespace; + Window? _window; + + WorkbenchActionHandler? _actionHandler; + WorkbenchNavigation? _navigation; + WorkbenchRefreshLoop? _refreshLoop; + WorkbenchOverlays? _overlays; + + /// + /// Builds the main window, composes all workbench subsystems, and returns the ready-to-show window. + /// + /// The fully configured . + public Window Build() + { + _actionHandler = new WorkbenchActionHandler( + windowSystem, + text => + { + if (string.IsNullOrEmpty(text)) + _refreshLoop?.UpdateTopPanel(); + else + windowSystem.PanelStateService.TopStatus = text; + }); + + _navigation = new WorkbenchNavigation( + windowSystem, + _views, + settings, + () => _activeEventStore, + () => _activeNamespace, + storeName => + { + _activeEventStore = storeName; + _activeNamespace = null; + _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview); + _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)); + }, + nsName => + { + _activeNamespace = nsName; + _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview); + _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)); + }, + () => _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)), + () => _refreshLoop?.CurrentData); + + _refreshLoop = new WorkbenchRefreshLoop( + dataService, + settings, + _views, + _navigation, + windowSystem, + () => _activeEventStore, + () => _activeNamespace); + + WireViewCallbacks(); + + _overlays = new WorkbenchOverlays(windowSystem, _views, _navigation, _actionHandler, _refreshLoop); + var overlays = _overlays; + var keyDispatcher = new WorkbenchKeyDispatcher( + _navigation, _views, _actionHandler, windowSystem, overlays, settings, state, () => _window); + var menuBar = new WorkbenchMenuBar(_navigation, overlays, windowSystem, settings, state).Build(); + var navView = _navigation.BuildNavigationView(); + + _refreshLoop.Initialize(initialData); + + var builtWindow = new WindowBuilder(windowSystem) + .WithTitle(string.Empty) + .Maximized() + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithBackgroundGradient( + ColorGradient.FromColors([ + WorkbenchColors.Background, + WorkbenchColors.Surface, + WorkbenchColors.Background + ]), + GradientDirection.DiagonalDown) + .Borderless() // cspell:ignore Borderless + .HideTitle() + .HideCloseButton() + .AddControl(menuBar) + .AddControl(navView) + .OnKeyPressed((_, e) => keyDispatcher.Dispatch(e)) + .WithAsyncWindowThread(_refreshLoop.RunAsync) + .Build(); + + _window = builtWindow; + + // The menu bar is the first added control so the window system gives it initial focus. + // Move focus to the nav view so arrow keys, action keys, and shortcuts work immediately. + _window.FocusControl(navView); + + if (state.LastNavIndex > 0 && state.LastNavIndex < _views.Length) + { + _navigation.NavigateTo(state.LastNavIndex); + } + + return builtWindow; + } + + static string TruncateId(string s) => s.Length <= 40 ? s : s[..37] + "…"; + + void OpenObserversForEventTypeOverlay(string eventTypeId) + { + var snapshot = _refreshLoop?.CurrentData; + if (snapshot is null || _overlays is null) + { + return; + } + + var matching = snapshot.Observers + .Where(o => (o.EventTypes ?? []).Any(et => + string.Equals(et.Id, eventTypeId, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + _overlays.OpenObserversForEventType( + eventTypeId, + matching, + obs => + { + _navigation!.NavigateTo(WorkbenchNavigation.IndexObservers); + if (_views[WorkbenchNavigation.IndexObservers] is ObserversView ov) + { + ov.SetFilter(obs.Id); + } + }); + } + + void WireViewCallbacks() + { + if (_views[WorkbenchNavigation.IndexObservers] is ObserversView ov) + { + ov.OnReplay = obs => _actionHandler!.ExecuteAction( + $"Replay observer '{TruncateId(obs.Id)}'", + () => services.Observers.Replay(new Replay + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = obs.Id, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + + ov.OnReplayAll = observers => _actionHandler!.ConfirmThenExecuteAll( + $"Replay {observers.Count} observer{(observers.Count == 1 ? string.Empty : "s")}", + observers, + obs => services.Observers.Replay(new Replay + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = obs.Id, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + } + + if (_views[WorkbenchNavigation.IndexFailures] is FailedPartitionsView fv) + { + fv.OnRetryPartition = fp => _actionHandler!.ExecuteAction( + $"Retry partition '{fp.Partition}'", + () => services.Observers.RetryPartition(new RetryPartition + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = fp.ObserverId, + Partition = fp.Partition, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + + fv.OnReplayPartition = fp => _actionHandler!.ExecuteAction( + $"Replay partition '{fp.Partition}'", + () => services.Observers.ReplayPartition(new ReplayPartition + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = fp.ObserverId, + Partition = fp.Partition, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + + fv.OnRetryAll = partitions => _actionHandler!.ConfirmThenExecuteAll( + $"Retry {partitions.Count} partition{(partitions.Count == 1 ? string.Empty : "s")}", + partitions, + fp => services.Observers.RetryPartition(new RetryPartition + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = fp.ObserverId, + Partition = fp.Partition, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + + fv.OnReplayAll = partitions => _actionHandler!.ConfirmThenExecuteAll( + $"Replay {partitions.Count} partition{(partitions.Count == 1 ? string.Empty : "s")}", + partitions, + fp => services.Observers.ReplayPartition(new ReplayPartition + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = fp.ObserverId, + Partition = fp.Partition, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + } + + if (_views[WorkbenchNavigation.IndexJobs] is JobsView jv) + { + jv.OnStopJob = job => _actionHandler!.ExecuteAction( + $"Stop job '{TruncateId(job.Type ?? job.Id.ToString())}'", + () => services.Jobs.Stop(new StopJob + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + JobId = job.Id + })); + + jv.OnResumeJob = job => _actionHandler!.ExecuteAction( + $"Resume job '{TruncateId(job.Type ?? job.Id.ToString())}'", + () => services.Jobs.Resume(new ResumeJob + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + JobId = job.Id + })); + + jv.OnStopAll = jobs => _actionHandler!.ConfirmThenExecuteAll( + $"Stop {jobs.Count} job{(jobs.Count == 1 ? string.Empty : "s")}", + jobs, + job => services.Jobs.Stop(new StopJob + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + JobId = job.Id + })); + + jv.OnResumeAll = jobs => _actionHandler!.ConfirmThenExecuteAll( + $"Resume {jobs.Count} job{(jobs.Count == 1 ? string.Empty : "s")}", + jobs, + job => services.Jobs.Resume(new ResumeJob + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + JobId = job.Id + })); + } + + if (_views[WorkbenchNavigation.IndexRecommendations] is RecommendationsView rv) + { + rv.OnApply = rec => _actionHandler!.ExecuteAction( + $"Apply recommendation '{TruncateId(rec.Name ?? rec.Id.ToString())}'", + () => services.Recommendations.Perform(new Perform + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + RecommendationId = rec.Id + })); + + rv.OnIgnore = rec => _actionHandler!.ExecuteAction( + $"Ignore recommendation '{TruncateId(rec.Name ?? rec.Id.ToString())}'", + () => services.Recommendations.Ignore(new Perform + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + RecommendationId = rec.Id + })); + + rv.OnApplyAll = recs => _actionHandler!.ConfirmThenExecuteAll( + $"Apply {recs.Count} recommendation{(recs.Count == 1 ? string.Empty : "s")}", + recs, + rec => services.Recommendations.Perform(new Perform + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + RecommendationId = rec.Id + })); + + rv.OnIgnoreAll = recs => _actionHandler!.ConfirmThenExecuteAll( + $"Ignore {recs.Count} recommendation{(recs.Count == 1 ? string.Empty : "s")}", + recs, + rec => services.Recommendations.Ignore(new Perform + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + RecommendationId = rec.Id + })); + } + + if (_views[WorkbenchNavigation.IndexReadModels] is ReadModelsView rmv) + { + rmv.OnFetchInstances = async (containerName, ct) => + await dataService.FetchAsync( + _activeEventStore, + _activeNamespace, + readModelContainerName: containerName, + ct); + } + + if (_views[WorkbenchNavigation.IndexEventStores] is EventStoresView esv) + { + esv.OnSwitch = storeName => + { + _activeEventStore = storeName; + _activeNamespace = null; + _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview); + _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)); + }; + } + + if (_views[WorkbenchNavigation.IndexNamespaces] is NamespacesView nsv) + { + nsv.OnSwitch = nsName => + { + _activeNamespace = nsName; + _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview); + _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)); + }; + } + + if (_views[WorkbenchNavigation.IndexEventSequences] is EventSequencesView seqView) + { + seqView.OnViewEventTypeDefinition = evt => + _overlays?.OpenEventTypeDefinition( + evt.Context.EventType.Id, + _refreshLoop?.CurrentData); + + seqView.OnViewObserversForType = evt => + OpenObserversForEventTypeOverlay(evt.Context.EventType.Id); + } + + if (_views[WorkbenchNavigation.IndexEventTypes] is EventTypesView etView) + { + etView.OnViewObservers = reg => + OpenObserversForEventTypeOverlay(reg.Type.Id); + } + + foreach (var view in _views) + { + view.OnFilterFocusChanged = focused => _actionHandler!.TextInputFocused = focused; + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/ViewAction.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/ViewAction.cs new file mode 100644 index 0000000..e1543f1 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/ViewAction.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Describes a single user-triggerable action that a view exposes. +/// The same record is consumed by the keyboard dispatcher, the right-click context menu, +/// and the Actions menu bar item — defining the action once eliminates duplication across all three. +/// +/// Display text shown in menus and the context menu. +/// Short key hint shown alongside the label (e.g. "R"). Display only — not handled here. +/// The that activates this action, or for menu-only actions. +/// Required modifier keys. Use for no modifiers. +/// The delegate invoked when the action is activated. +public record ViewAction( + string Label, + string? KeyHint, + ConsoleKey? TriggerKey, + ConsoleModifiers TriggerModifiers, + Action Execute); diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs new file mode 100644 index 0000000..2e11337 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs @@ -0,0 +1,116 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Shows centered confirmation dialogs for destructive workbench actions and executes them on confirmation. +/// +/// The SharpConsoleUI window system — used to open the confirmation modal. +/// Callback invoked to display progress / success / error messages in the top panel. +public class WorkbenchActionHandler(ConsoleWindowSystem windowSystem, Action updateStatus) +{ + /// + /// Gets or sets a value indicating whether a text input control currently has keyboard focus. + /// When , most shortcut keys are suppressed so the user can type freely. + /// + public bool TextInputFocused { get; set; } + + /// + /// Opens a centered confirmation dialog for . + /// Executes when the user confirms; dismisses on cancel. + /// + /// Short human-readable description of the action. + /// Async delegate that performs the action when confirmed. + public void ExecuteAction(string description, Func action) => + ShowConfirmationDialog(description, action); + + /// + /// Opens a confirmation dialog for a bulk operation over . + /// Calls for each item in sequence when confirmed. + /// + /// The element type. + /// Short human-readable description of the bulk action. + /// The collection of items to act on. + /// Async delegate called once per item. + public void ConfirmThenExecuteAll(string description, IReadOnlyList items, Func perItem) + { + ExecuteAction(description, async () => + { + foreach (var item in items) + { + await perItem(item); + } + }); + } + + void ShowConfirmationDialog(string description, Func action) + { + var mut = WorkbenchColors.Muted.ToMarkup(); + var warn = WorkbenchColors.Warning.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var body = Controls.Markup() + .AddEmptyLine() + .AddLine($" [{warn}]⚡[/] {description}") + .AddEmptyLine() + .AddLine($" [{mut}]This action cannot be undone.[/]") + .AddEmptyLine() + .AddLine($" [{mut}]Press[/] [bold {acc}]Enter[/] [{mut}]or[/] [bold {acc}]Y[/] [{mut}]to confirm, or[/] [bold {acc}]Escape[/] [{mut}]/ [bold {acc}]N[/] [{mut}]to cancel.[/]") + .AddEmptyLine() + .Build(); + + Window? dialog = null; + dialog = new WindowBuilder(windowSystem) + .WithTitle(" Confirm Action ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Surface) + .WithSize(64, 10) + .Centered() + .AddControl(body) + .OnKeyPressed((_, e) => + { + switch (e.KeyInfo.Key) + { + case ConsoleKey.Enter: + case ConsoleKey.Y: + windowSystem.CloseWindow(dialog, activateParent: true, force: false); + RunAction(description, action); + e.Handled = true; + break; + + case ConsoleKey.Escape: + case ConsoleKey.N: + windowSystem.CloseWindow(dialog, activateParent: true, force: false); + e.Handled = true; + break; + } + }) + .Build(); + + windowSystem.AddWindow(dialog, activateWindow: true); + } + + void RunAction(string description, Func action) + { + _ = Task.Run(async () => + { + try + { + updateStatus($"⟳ {description}…"); + await action(); + updateStatus("✓ Done"); + await Task.Delay(3000); + updateStatus(string.Empty); + } + catch (Exception ex) + { + var msg = ex.Message.Length > 80 ? ex.Message[..80] : ex.Message; + updateStatus($"✗ {msg}"); + } + }); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchKeyDispatcher.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchKeyDispatcher.cs new file mode 100644 index 0000000..ba7e0dd --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchKeyDispatcher.cs @@ -0,0 +1,318 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Themes; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Translates raw key-press events from the main window into workbench actions. +/// +/// Wired to Window.KeyPressed (fires after the focused control processes each key). +/// All shortcuts live here because MenuControl only handles arrow keys, Enter, Escape, +/// Home and End — it does NOT intercept letters or Ctrl+letter combinations, so those always +/// bubble to this handler regardless of whether the menu bar currently has focus. +/// +/// +/// Left and Right arrows use an guard so the +/// NavigationView nav pane (collapse/expand headers) and menu bar (navigate items) can handle +/// them without our focus-switch logic interfering. +/// +/// +/// Navigation — used for view jumping, sidebar mode, and picker overlays. +/// All view instances — used for jumping, filter, and view-action dispatch. +/// Action handler — owns the text-input focus state flag. +/// The window system — used for theme switching. +/// Overlays — invoked for help, command palette, read model detail, and clipboard copy. +/// Workbench settings — used by the Quit action to persist the refresh interval. +/// Workbench state — used by the Quit action to persist the last active navigation index. +/// Returns the main , used for focus control targeting. +public class WorkbenchKeyDispatcher( + WorkbenchNavigation navigation, + IWorkbenchView[] views, + WorkbenchActionHandler actionHandler, + ConsoleWindowSystem windowSystem, + WorkbenchOverlays overlays, + WorkbenchSettings settings, + WorkbenchState state, + Func getWindow) +{ + bool _sidebarExpanded = true; + + /// + /// Dispatches a key press to the appropriate workbench action. + /// Wired to Window.KeyPressed which fires after the focused control has processed the key. + /// + /// The key-press event. + public void Dispatch(KeyPressedEventArgs e) + { + if (navigation.NavView is null) + { + return; + } + + // Suppress all shortcuts while the filter prompt is active so the user can type freely. + // Escape is the only exception — it exits filter mode. + if (actionHandler.TextInputFocused) + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + var filterIdx = navigation.CurrentViewIndex; + if (filterIdx >= 0 && filterIdx < views.Length) + { + views[filterIdx].ClearFilter(); + } + + e.Handled = true; + } + + return; + } + + var idx = navigation.CurrentViewIndex; + + switch (e.KeyInfo.Key) + { + // Left/Right: only act when the focused control did NOT already handle them. + // The NavigationView nav pane handles Left (collapse header) and the menu bar handles + // Left/Right (navigate items). We skip FocusNavigation/FocusContent in those cases. + case ConsoleKey.LeftArrow: + if (!e.AlreadyHandled) + { + FocusNavigation(); + e.Handled = true; + } + + break; + + case ConsoleKey.RightArrow: + if (!e.AlreadyHandled) + { + FocusContent(); + e.Handled = true; + } + + break; + + case ConsoleKey.B when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + ToggleSidebar(); + e.Handled = true; + break; + + case ConsoleKey.Oem5 when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + ToggleDetailPane(); + e.Handled = true; + break; + + case ConsoleKey.E when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + navigation.OpenEventStorePicker(); + e.Handled = true; + break; + + case ConsoleKey.N when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + navigation.OpenNamespacePicker(); + e.Handled = true; + break; + + case ConsoleKey.C when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + overlays.CopyDetailToClipboard(); + e.Handled = true; + break; + + case ConsoleKey.Enter when idx == WorkbenchNavigation.IndexReadModels: + overlays.OpenReadModelDetail(); + e.Handled = true; + break; + + case ConsoleKey.Oem2 when e.KeyInfo.Modifiers == ConsoleModifiers.Shift: + if (!e.AlreadyHandled) + { + overlays.OpenHelpOverlay(); + e.Handled = true; + } + + break; + + case ConsoleKey.P when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + overlays.OpenCommandPalette(); + e.Handled = true; + break; + + // Theme switching + case ConsoleKey.F9: + ApplyTheme(new ModernGrayTheme()); + e.Handled = true; + break; + + case ConsoleKey.F10: + ApplyTheme(new ClassicTheme()); + e.Handled = true; + break; + + case ConsoleKey.F11: + ApplyThemeByName("SharpConsoleUI.Plugins.DeveloperTools.DevDarkTheme, SharpConsoleUI"); + e.Handled = true; + break; + + // Page navigation + case ConsoleKey.Oem6: // ] + if (idx >= 0 && idx < views.Length) views[idx].NextPage(); + e.Handled = true; + break; + + case ConsoleKey.Oem4: // [ + if (idx >= 0 && idx < views.Length) views[idx].PreviousPage(); + e.Handled = true; + break; + + // Row jumping — guarded so Shift+G and Home in the filter prompt aren't intercepted. + case ConsoleKey.G when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Shift): + if (!e.AlreadyHandled) + { + if (idx >= 0 && idx < views.Length) views[idx].JumpToLastRow(); + e.Handled = true; + } + + break; + + case ConsoleKey.Home: + if (!e.AlreadyHandled) + { + if (idx >= 0 && idx < views.Length) views[idx].JumpToFirstRow(); + e.Handled = true; + } + + break; + + // Single-character shortcuts: only act when the focused control did NOT already consume + // the key. When the filter prompt is active and the user types 'f', 'q', 'v', etc., + // the prompt's ProcessKey returns true (alreadyHandled = true) and we skip these cases, + // so the character is inserted into the filter instead of triggering a shortcut. + case ConsoleKey.F: + if (!e.AlreadyHandled) + { + ActivateCurrentFilter(); + e.Handled = true; + } + + break; + + case ConsoleKey.Q: + if (!e.AlreadyHandled) + { + state.Interval = settings.Interval; + state.LastNavIndex = navigation.CurrentViewIndex; + state.Save(); + Environment.Exit(0); + } + + break; + + default: + // OCP: view-scoped action keys (R, V, D, T, P, S, U, A, I, etc.) + // Also guarded by AlreadyHandled — when the filter prompt is active, + // it consumes every printable key, so these action shortcuts are naturally + // suppressed without needing any separate TextInputFocused flag. + if (!e.AlreadyHandled && DispatchCurrentViewAction(e.KeyInfo.Key, e.KeyInfo.Modifiers)) + { + e.Handled = true; + } + + break; + } + } + + void FocusNavigation() + { + var window = getWindow(); + if (window is null || navigation.NavView is null) + { + return; + } + + window.FocusControl(navigation.NavView); + } + + void FocusContent() + { + var window = getWindow(); + if (window is null) + { + return; + } + + var idx = navigation.CurrentViewIndex; + if (idx >= 0 && idx < views.Length && views[idx].PrimaryFocusTarget is IInteractiveControl ic) + { + window.FocusControl(ic); + } + } + + void ToggleSidebar() + { + if (navigation.NavView is null) + { + return; + } + + _sidebarExpanded = !_sidebarExpanded; + navigation.NavView.PaneDisplayMode = _sidebarExpanded + ? NavigationViewDisplayMode.Expanded + : NavigationViewDisplayMode.Compact; + } + + void ToggleDetailPane() + { + var idx = navigation.CurrentViewIndex; + if (idx >= 0 && idx < views.Length) + { + views[idx].ToggleDetailPane(); + } + } + + void ActivateCurrentFilter() + { + var idx = navigation.CurrentViewIndex; + var window = getWindow(); + if (idx < 0 || idx >= views.Length || window is null) + { + return; + } + + views[idx].ActivateFilter(window); + } + + bool DispatchCurrentViewAction(ConsoleKey key, ConsoleModifiers modifiers) + { + var idx = navigation.CurrentViewIndex; + if (idx < 0 || idx >= views.Length) + { + return false; + } + + var match = views[idx].ViewActions.FirstOrDefault( + a => a.TriggerKey == key && a.TriggerModifiers == modifiers); + + if (match is null) + { + return false; + } + + match.Execute(); + return true; + } + + void ApplyTheme(ITheme theme) => + windowSystem.ThemeStateService.SetTheme(theme); + + void ApplyThemeByName(string typeName) + { + var type = Type.GetType(typeName); + if (type is not null && Activator.CreateInstance(type) is ITheme theme) + { + ApplyTheme(theme); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchMenuBar.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchMenuBar.cs new file mode 100644 index 0000000..c42c9c0 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchMenuBar.cs @@ -0,0 +1,70 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Themes; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Builds the horizontal sticky menu bar displayed at the top of the workbench window. +/// +/// Navigation — wired to Ctrl+E / Ctrl+N quick-switch actions. +/// Overlays — wired to Help menu items. +/// The window system — used for theme switching. +/// Workbench settings — used by the Quit action to persist the refresh interval. +/// Workbench state — used by the Quit action to persist the last active navigation index. +public class WorkbenchMenuBar( + WorkbenchNavigation navigation, + WorkbenchOverlays overlays, + ConsoleWindowSystem windowSystem, + WorkbenchSettings settings, + WorkbenchState state) +{ + /// + /// Builds and returns the configured horizontal sticky . + /// + /// The fully wired menu bar control. + public MenuControl Build() + { + var menu = Controls.Menu() + .Horizontal() + .Sticky() + .WithName("WorkbenchMenuBar") + .AddItem("File", m => m + .AddItem("Switch Event Store", "Ctrl+E", () => navigation.OpenEventStorePicker()) + .AddItem("Switch Namespace", "Ctrl+N", () => navigation.OpenNamespacePicker()) + .AddSeparator() + .AddItem("Quit", "Q", () => + { + state.Interval = settings.Interval; + state.LastNavIndex = navigation.CurrentViewIndex; + state.Save(); + Environment.Exit(0); + })) + .AddItem("Help", m => m + .AddItem("Keyboard Shortcuts", "?", () => overlays.OpenHelpOverlay()) + .AddSeparator() + .AddItem("Theme: Modern Gray", "F9", () => ApplyTheme(new ModernGrayTheme())) + .AddItem("Theme: Classic", "F10", () => ApplyTheme(new ClassicTheme())) + .AddItem("Theme: Dev Dark", "F11", () => ApplyThemeByName("SharpConsoleUI.Plugins.DeveloperTools.DevDarkTheme, SharpConsoleUI"))) + .Build(); + + menu.StickyPosition = StickyPosition.Top; + return menu; + } + + void ApplyTheme(ITheme theme) => + windowSystem.ThemeStateService.SetTheme(theme); + + void ApplyThemeByName(string typeName) + { + var type = Type.GetType(typeName); + if (type is not null && Activator.CreateInstance(type) is ITheme theme) + { + ApplyTheme(theme); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs new file mode 100644 index 0000000..d894be0 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs @@ -0,0 +1,412 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Builds and manages the navigation side pane, badge counts, and the event store / namespace picker overlays. +/// All navigation items are driven by — add a view there and it appears here automatically. +/// +/// The SharpConsoleUI window system. +/// The ordered array of instances — must match order. +/// Workbench settings — used to resolve the active event store and namespace. +/// Returns the currently active event store name, or for the default. +/// Returns the currently active namespace name, or for the default. +/// Invoked with the newly selected event store name when the user picks one from the picker overlay. +/// Invoked with the newly selected namespace name when the user picks one from the picker overlay. +/// Invoked when navigation changes require a data refresh. +/// Returns the latest cached snapshot, or if none available yet. +public class WorkbenchNavigation( + ConsoleWindowSystem windowSystem, + IWorkbenchView[] views, + WorkbenchSettings settings, + Func getActiveEventStore, + Func getActiveNamespace, + Action onStoreSwitch, + Action onNamespaceSwitch, + Action onDataNeeded, + Func getLatestData) +{ + // ── View index constants ─────────────────────────────────────────────────── + // Derived from WorkbenchViewRegistry — the registry position IS the index. + // Never hardcode these values; never pass them to NavigationView.SelectedIndex directly. + // Always navigate via NavigateTo(IndexXxx) which converts to the header-inclusive index. + + /// View index for Overview. + public static readonly int IndexOverview = WorkbenchViewRegistry.IndexOf(); + + /// View index for Observers. + public static readonly int IndexObservers = WorkbenchViewRegistry.IndexOf(); + + /// View index for Failures. + public static readonly int IndexFailures = WorkbenchViewRegistry.IndexOf(); + + /// View index for Jobs. + public static readonly int IndexJobs = WorkbenchViewRegistry.IndexOf(); + + /// View index for Recommendations. + public static readonly int IndexRecommendations = WorkbenchViewRegistry.IndexOf(); + + /// View index for Event Sequences. + public static readonly int IndexEventSequences = WorkbenchViewRegistry.IndexOf(); + + /// View index for Event Types. + public static readonly int IndexEventTypes = WorkbenchViewRegistry.IndexOf(); + + /// View index for Projections. + public static readonly int IndexProjections = WorkbenchViewRegistry.IndexOf(); + + /// View index for Read Models. + public static readonly int IndexReadModels = WorkbenchViewRegistry.IndexOf(); + + /// View index for Event Stores. + public static readonly int IndexEventStores = WorkbenchViewRegistry.IndexOf(); + + /// View index for Namespaces. + public static readonly int IndexNamespaces = WorkbenchViewRegistry.IndexOf(); + + const int PickerOverlayWidth = 54; + const int MaxPickerOverlayHeight = 24; + const int PickerOverlayHeightPadding = 6; + const int NavExpandedThreshold = 90; + const int NavCompactThreshold = 40; + + NavigationItem? _observersItem; + NavigationItem? _failuresItem; + NavigationItem? _recommendationsItem; + NavigationView? _navView; + int _currentViewIndex; + + /// Gets the built control. Only available after has been called. + public NavigationView? NavView => _navView; + + /// + /// Gets the zero-based item-only index of the currently active view. + /// Excludes header entries, aligns with IndexXxx constants, and can be used directly to index into views[]. + /// + public int CurrentViewIndex => _currentViewIndex; + + /// Gets the Observers navigation item (used to set badge counts). Only available after . + public NavigationItem? ObserversItem => _observersItem; + + /// Gets the Failures navigation item (used to set badge counts). Only available after . + public NavigationItem? FailuresItem => _failuresItem; + + /// Gets the Recommendations navigation item (used to set badge counts). Only available after . + public NavigationItem? RecommendationsItem => _recommendationsItem; + + /// + /// Builds the navigation view from — headers and items are derived automatically. + /// Wires the selection-changed callback and captures the badge item references. + /// + /// The fully configured . + public NavigationView BuildNavigationView() + { + // Declare navView before the builder chain so the lambda can capture it. + // It will be null when OnSelectedItemChanged fires during the initial build-time + // auto-selection; the guard below handles that case gracefully. + NavigationView? navView = null; + + navView = Controls.NavigationView() + .WithNavWidth(28) + .WithPaneHeader($"[bold {WorkbenchColors.Accent.ToMarkup()}] ◆ CHRONICLE[/]") + .WithSelectedColors(new SharpConsoleUI.Color(255, 255, 255, 255), WorkbenchColors.SelectedBg) + .WithContentBorder(BorderStyle.Rounded) + .WithContentBorderColor(WorkbenchColors.ContentBorder) + .WithContentBackground(WorkbenchColors.Surface) + .WithContentPadding(1, 0, 1, 0) + .WithPaneDisplayMode(NavigationViewDisplayMode.Auto) + .WithExpandedThreshold(NavExpandedThreshold) + .WithCompactThreshold(NavCompactThreshold) + .WithName("MainNav") + .Fill() + .OnSelectedItemChanged((_, e) => + { + // navView may be null while Build() is still executing (first auto-selection). + // In that case _currentViewIndex stays at its default of 0 (Overview), which is correct. + if (navView is null) + { + return; + } + + var oldViewIdx = ToViewIndex(navView, e.OldIndex); + if (oldViewIdx >= 0 && oldViewIdx < views.Length) + { + views[oldViewIdx].IsActive = false; + } + + var idx = ToViewIndex(navView, e.NewIndex); + _currentViewIndex = idx >= 0 ? idx : 0; + + if (idx < 0 || idx >= views.Length) + { + return; + } + + var snapshot = getLatestData(); + if (snapshot is not null) + { + views[idx].UpdateData(snapshot); + } + else + { + onDataNeeded(); + } + + views[idx].IsActive = true; + }) + .Build(); + + // Add headers and items driven entirely by the registry. + // Adding a new view to WorkbenchViewRegistry.All automatically adds it here. + WorkbenchSection? lastSection = null; + NavigationItem? currentHeader = null; + + for (var i = 0; i < WorkbenchViewRegistry.All.Count; i++) + { + var def = WorkbenchViewRegistry.All[i]; + var viewIndex = i; + + if (!ReferenceEquals(def.Section, lastSection)) + { + currentHeader = navView!.AddHeader(def.Section.Title, def.Section.Color); + lastSection = def.Section; + } + + var navItem = navView!.AddItemToHeader(currentHeader!, def.NavText, def.NavIcon, def.NavSubtitle); + navView.SetItemContent(navItem, panel => panel.AddControl(views[viewIndex].BuildContent(windowSystem))); + } + + var allItems = navView!.Items; + _observersItem = FindItemByText(allItems, WorkbenchViewRegistry.All[IndexObservers].NavText); + _failuresItem = FindItemByText(allItems, WorkbenchViewRegistry.All[IndexFailures].NavText); + _recommendationsItem = FindItemByText(allItems, WorkbenchViewRegistry.All[IndexRecommendations].NavText); + + _navView = navView; + + // Guard: registry count must equal views.Length — they're both built from the same registry. + var nonHeaderCount = allItems.Count(i => i.ItemType != NavigationItemType.Header); + System.Diagnostics.Debug.Assert( + nonHeaderCount == views.Length, + $"Nav has {nonHeaderCount} selectable items but _views has {views.Length}. Both must match {nameof(WorkbenchViewRegistry)}."); + + return navView; + } + + /// Navigates to the specified view by index. No-op when the index is out of range. + /// Zero-based item-only view index (use IndexXxx constants). + public void NavigateTo(int viewIndex) + { + if (_navView is null || viewIndex < 0 || viewIndex >= views.Length) + { + return; + } + + var navIndex = ToNavIndex(_navView, viewIndex); + if (navIndex >= 0) + { + _navView.SelectedIndex = navIndex; + } + } + + /// Updates the badge subtitles on the Observers, Failures, and Recommendations nav items. + /// The latest workbench data snapshot. + public void UpdateNavBadges(WorkbenchData data) + { + var problemCount = data.DisconnectedObservers + data.ReplayingObservers; + + if (_observersItem is NavigationItem observersItem) + { + observersItem.Subtitle = problemCount > 0 ? $"⚠{problemCount}" : string.Empty; + } + + if (_failuresItem is NavigationItem failuresItem) + { + failuresItem.Subtitle = data.FailedPartitions.Count > 0 + ? data.FailedPartitions.Count.ToString() + : string.Empty; + } + + if (_recommendationsItem is NavigationItem recommendationsItem) + { + recommendationsItem.Subtitle = data.Recommendations.Count > 0 + ? data.Recommendations.Count.ToString() + : string.Empty; + } + + _navView?.Invalidate(); + } + + /// Opens a modal picker that lets the user select a different event store. + public void OpenEventStorePicker() + { + var snapshot = getLatestData(); + if (snapshot is null) + { + return; + } + + ShowStringPickerOverlay( + " Switch Event Store ", + "Event Store", + "EventStorePickerTable", + [.. snapshot.EventStoreNames.Order()], + getActiveEventStore() ?? settings.ResolveEventStore(), + onStoreSwitch); + } + + /// Opens a modal picker that lets the user select a different namespace. + public void OpenNamespacePicker() + { + var snapshot = getLatestData(); + if (snapshot is null) + { + return; + } + + ShowStringPickerOverlay( + " Switch Namespace ", + "Namespace", + "NamespacePickerTable", + [.. snapshot.NamespaceNames.Order()], + getActiveNamespace() ?? settings.ResolveNamespace(), + onNamespaceSwitch); + } + + /// + /// Converts a header-inclusive NavigationView item index to a zero-based view-only index. + /// Takes the nav view directly so it works inside lambdas before _navView is assigned. + /// Returns -1 for header entries or out-of-range indices. + /// + /// The NavigationView to query. + /// The header-inclusive index from the NavigationView. + /// The zero-based view-only index, or -1 if not applicable. + static int ToViewIndex(NavigationView nav, int navIndex) + { + if (navIndex < 0) + { + return -1; + } + + var items = nav.Items; + if (navIndex >= items.Count || items[navIndex].ItemType == NavigationItemType.Header) + { + return -1; + } + + var viewIdx = 0; + for (var i = 0; i < navIndex; i++) + { + if (items[i].ItemType != NavigationItemType.Header) + { + viewIdx++; + } + } + + return viewIdx; + } + + /// + /// Converts a zero-based view-only index to the header-inclusive NavigationView item index + /// required by . + /// Returns -1 when the view index is not found. + /// + /// The NavigationView to query. + /// The zero-based view-only index. + /// The header-inclusive NavigationView index, or -1 if not found. + static int ToNavIndex(NavigationView nav, int viewIndex) + { + if (viewIndex < 0) + { + return -1; + } + + var items = nav.Items; + var itemCount = 0; + for (var navIdx = 0; navIdx < items.Count; navIdx++) + { + if (items[navIdx].ItemType != NavigationItemType.Header) + { + if (itemCount == viewIndex) + { + return navIdx; + } + + itemCount++; + } + } + + return -1; + } + + static NavigationItem? FindItemByText(IReadOnlyList items, string text) + { + foreach (var item in items) + { + if (item.Text == text) + { + return item; + } + } + + return null; + } + + void ShowStringPickerOverlay( + string title, + string columnHeader, + string tableName, + List items, + string activeItem, + Action onSelected) + { + var acc = WorkbenchColors.Accent.ToMarkup(); + + var pickerTable = Controls.Table() + .AddColumn(columnHeader, SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithName(tableName) + .Build(); + + foreach (var name in items) + { + var label = name == activeItem ? $"[{acc}]► {name}[/]" : name; + pickerTable.AddRow(new UITableRow([label]) { Tag = name }); + } + + Window? picker = null; + var height = Math.Min(items.Count + PickerOverlayHeightPadding, MaxPickerOverlayHeight); + picker = new WindowBuilder(windowSystem) + .WithTitle(title) + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(PickerOverlayWidth, height) + .Centered() + .AddControl(pickerTable) + .OnKeyPressed((_, ke) => + { + if (ke.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(picker, activateParent: true, force: false); + ke.Handled = true; + } + }) + .Build(); + + pickerTable.RowActivated += (_, _) => + { + if (pickerTable.SelectedRow?.Tag is string selected) + { + windowSystem.CloseWindow(picker, activateParent: true, force: false); + onSelected(selected); + } + }; + + windowSystem.AddWindow(picker, activateWindow: true); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchOverlays.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchOverlays.cs new file mode 100644 index 0000000..3c58471 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchOverlays.cs @@ -0,0 +1,535 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Helpers; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Opens and manages modal popup windows: keyboard-shortcuts help, command palette, and read model detail. +/// Also owns clipboard copy, which is triggered from both the keyboard and the menu bar. +/// +/// The SharpConsoleUI window system. +/// All view instances — used to read current-view help and selected items. +/// Navigation — provides the current view index and navigate methods. +/// Action handler — owns the TextInputFocused flag used by the command palette prompt. +/// Refresh loop — provides the latest data snapshot and temporary panel messages. +public class WorkbenchOverlays( + ConsoleWindowSystem windowSystem, + IWorkbenchView[] views, + WorkbenchNavigation navigation, + WorkbenchActionHandler actionHandler, + WorkbenchRefreshLoop refreshLoop) +{ + const int HelpOverlayWidth = 72; + const int HelpOverlayHeight = 40; + const int CommandPaletteWidth = 80; + const int CommandPaletteHeight = 18; + const int MaxCommandPaletteResults = 10; + + /// + /// Opens the keyboard-shortcuts help overlay. Shows a view-specific section at the top when the active + /// view exposes text. + /// + public void OpenHelpOverlay() + { + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var activeIdx = navigation.CurrentViewIndex; + var currentViewHelp = string.Empty; + if (activeIdx >= 0 && activeIdx < views.Length && !string.IsNullOrEmpty(views[activeIdx].ViewHelp)) + { + currentViewHelp = + $"[bold {acc}]THIS VIEW[/]\n" + + string.Join('\n', views[activeIdx].ViewHelp.Split('\n').Select(l => $" {l}")) + + "\n\n"; + } + + var helpText = + currentViewHelp + + $"[bold {acc}]NAVIGATION[/]\n" + + $" [{mut}]↑ ↓[/] Move selection up / down\n" + + $" [{mut}]← / →[/] Focus sidebar / content pane\n" + + $" [{mut}]Home / Shift+G[/] Jump to first / last row\n" + + $" [{mut}]Ctrl+B[/] Toggle sidebar expand / compact\n" + + $" [{mut}]Ctrl+\\[/] Toggle detail pane\n" + + $" [{mut}]Enter[/] Open detail overlay\n" + + $" [{mut}]Esc[/] Close overlay\n" + + "\n" + + $"[bold {acc}]QUICK SWITCH[/]\n" + + $" [{mut}]Ctrl+E[/] Switch event store\n" + + $" [{mut}]Ctrl+N[/] Switch namespace\n" + + "\n" + + $"[bold {acc}]FILTER & SEARCH[/]\n" + + $" [{mut}]F[/] Focus filter prompt for current view\n" + + $" [{mut}]Ctrl+P[/] Open command palette\n" + + $" [{mut}]Escape[/] Clear filter and return focus to table\n" + + "\n" + + $"[bold {acc}]VIEW ACTIONS (when row selected)[/]\n" + + $" [{mut}]?[/] See this screen — actions vary per view\n" + + $" [{mut}]Y / N[/] Confirm / Cancel pending action\n" + + "\n" + + $"[bold {acc}]CLIPBOARD[/]\n" + + $" [{mut}]Ctrl+C[/] Copy detail pane content to clipboard\n" + + "\n" + + $"[bold {acc}]THEMES[/]\n" + + $" [{mut}]F9 / F10 / F11[/] Modern Gray / Classic / Dev Dark theme\n" + + "\n" + + $"[bold {acc}]GENERAL[/]\n" + + $" [{mut}]+ / -[/] Increase / decrease refresh interval\n" + + $" [{mut}]?[/] This help screen\n" + + $" [{mut}]Q[/] Quit"; + + var markup = new MarkupControl([helpText]) { Wrap = true }; + var content = Controls.ScrollablePanel() + .AddControl(markup) + .WithVerticalScroll(ScrollMode.Scroll) + .WithPadding(2, 1, 2, 1) + .Build(); + + Window? helpWindow = null; + helpWindow = new WindowBuilder(windowSystem) + .WithTitle(" Keyboard Shortcuts ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(HelpOverlayWidth, HelpOverlayHeight) + .Centered() + .AddControl(content) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape || + (e.KeyInfo.Key == ConsoleKey.Oem2 && e.KeyInfo.Modifiers == ConsoleModifiers.Shift)) + { + windowSystem.CloseWindow(helpWindow, activateParent: true, force: false); + } + + e.Handled = true; + }) + .Build(); + + windowSystem.AddWindow(helpWindow, activateWindow: true); + } + + /// + /// Opens the command palette overlay for searching observers, event types, projections, read models, and failures. + /// + public void OpenCommandPalette() + { + var snapshot = refreshLoop.CurrentData; + if (snapshot is null) + { + return; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + + var resultsTable = Controls.Table() + .AddColumn("Kind", SharpConsoleUI.Layout.TextJustification.Left, 20) + .AddColumn("Name", SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithName("CommandPaletteResults") + .Build(); + + var searchPrompt = Controls.Prompt($"[{acc}]>[/] ") + .WithName("CommandPaletteSearch") + .OnGotFocus((_, _) => actionHandler.TextInputFocused = true) + .OnLostFocus((_, _) => actionHandler.TextInputFocused = false) + .Build(); + + void PopulateResults(string query) + { + resultsTable.ClearRows(); + + if (string.IsNullOrWhiteSpace(query)) + { + return; + } + + var matches = new List<(string Kind, string Label, Action Navigate)>(); + + foreach (var obs in snapshot.Observers) + { + if (obs.Id.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var obsId = obs.Id; + matches.Add(("Observer", $"{obs.Id} [{obs.RunningState}]", () => + { + navigation.NavigateTo(WorkbenchNavigation.IndexObservers); + NavigateAndFilter(WorkbenchNavigation.IndexObservers, obsId); + })); + } + } + + foreach (var et in snapshot.EventTypeRegistrations) + { + if (et.Type.Id.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var typeId = et.Type.Id; + matches.Add(("Event Type", $"{et.Type.Id} gen {et.Type.Generation}", () => + { + navigation.NavigateTo(WorkbenchNavigation.IndexEventTypes); + NavigateAndFilter(WorkbenchNavigation.IndexEventTypes, typeId); + })); + } + } + + foreach (var pd in snapshot.ProjectionDefinitions) + { + if (pd.Identifier.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var id = pd.Identifier; + matches.Add(("Projection", pd.Identifier, () => + { + navigation.NavigateTo(WorkbenchNavigation.IndexProjections); + NavigateAndFilter(WorkbenchNavigation.IndexProjections, id); + })); + } + } + + foreach (var rm in snapshot.ReadModelDefinitions) + { + if (rm.ContainerName.Contains(query, StringComparison.OrdinalIgnoreCase) || + rm.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var label = rm.DisplayName.Length > 0 ? rm.DisplayName : rm.ContainerName; + matches.Add(("Read Model", label, () => + { + navigation.NavigateTo(WorkbenchNavigation.IndexReadModels); + NavigateAndFilter(WorkbenchNavigation.IndexReadModels, label); + })); + } + } + + foreach (var fp in snapshot.FailedPartitions) + { + if (fp.ObserverId.Contains(query, StringComparison.OrdinalIgnoreCase) || + fp.Partition.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var obsId = fp.ObserverId; + matches.Add(("Failure", $"{fp.ObserverId}/{fp.Partition}", () => + { + navigation.NavigateTo(WorkbenchNavigation.IndexFailures); + NavigateAndFilter(WorkbenchNavigation.IndexFailures, obsId); + })); + } + } + + foreach (var (kind, label, navigate) in matches.Take(MaxCommandPaletteResults)) + { + resultsTable.AddRow(new UITableRow([kind, label]) { Tag = navigate }); + } + } + + searchPrompt.InputChanged += (_, text) => PopulateResults(text ?? string.Empty); + + Window? paletteWindow = null; + paletteWindow = new WindowBuilder(windowSystem) + .WithTitle(" Command Palette ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(CommandPaletteWidth, CommandPaletteHeight) + .Centered() + .AddControl(searchPrompt) + .AddControl(resultsTable) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(paletteWindow, activateParent: true, force: false); + e.Handled = true; + return; + } + + if (e.KeyInfo.Key == ConsoleKey.Enter) + { + var selectedRow = resultsTable.SelectedRow; + if (selectedRow?.Tag is Action navigate) + { + windowSystem.CloseWindow(paletteWindow, activateParent: true, force: false); + navigate(); + } + + e.Handled = true; + } + }) + .Build(); + + windowSystem.AddWindow(paletteWindow, activateWindow: true); + } + + /// + /// Opens the detail overlay for the currently selected read model entry. + /// No-op when the Read Models view is not active or nothing is selected. + /// + public void OpenReadModelDetail() + { + if (views[WorkbenchNavigation.IndexReadModels] is ReadModelsView rmv) + { + rmv.OpenSelectedDetailOverlay(); + } + } + + /// + /// Copies the detail pane content of the active view to the system clipboard. + /// Shows a brief confirmation message in the top panel. + /// No-op when no content is available. + /// + public void CopyDetailToClipboard() + { + var idx = navigation.CurrentViewIndex; + if (idx < 0 || idx >= views.Length) + { + return; + } + + var content = views[idx].DetailContent; + if (string.IsNullOrEmpty(content)) + { + return; + } + + ClipboardHelper.SetText(Markup.Remove(content)); + refreshLoop.ShowTemporaryMessage("✓ Copied to clipboard"); + } + + /// + /// Opens a modal overlay listing all that subscribe to + /// . The user can inspect each observer's detail, press Enter to + /// navigate directly to it in the Observers view, or Escape to close. + /// + /// The event type identifier — shown in the window title. + /// Pre-filtered list of observers subscribed to this event type. + /// Invoked with the selected observer when the user confirms navigation. + public void OpenObserversForEventType( + string eventTypeId, + IReadOnlyList matchingObservers, + Action navigateToObserver) + { + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + var warn = WorkbenchColors.Warning.ToMarkup(); + + var table = Controls.Table() + .AddColumn("State", SharpConsoleUI.Layout.TextJustification.Left, 18) + .AddColumn("Observer", SharpConsoleUI.Layout.TextJustification.Left, null) + .AddColumn("Type", SharpConsoleUI.Layout.TextJustification.Left, 14) + .Interactive() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithName("ObserversForEventTypeTable") + .Build(); + + var detailPanel = Controls.Panel() + .WithContent($"[{mut}]Select an observer.[/]") + .WithHeader(" OBSERVER ") + .Rounded() + .WithBorderColor(WorkbenchColors.Warning) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("ObserversForEventTypeDetail") + .Build(); + + foreach (var obs in matchingObservers.OrderBy(ObserverSortOrder).ThenBy(o => o.Id)) + { + var icon = ObserverIcon(obs); + var color = ObserverStateColor(obs); + table.AddRow(new UITableRow([$"[{color}]{icon} {obs.RunningState}[/]", obs.Id, obs.Type.ToString()]) { Tag = obs }); + } + + if (matchingObservers.Count == 0) + { + table.AddRow(new UITableRow([$"[{mut}]—[/]", $"[{mut}]No observers found for this event type[/]", string.Empty])); + } + + table.SelectedRowChanged += (_, _) => + { + if (table.SelectedRow?.Tag is ObserverInformation obs) + { + detailPanel.Content = RenderObserverDetail(obs, mut, acc, warn); + } + }; + + // Select first row to populate the detail panel immediately + if (table.Rows.Count > 0 && table.Rows[0].Tag is ObserverInformation first) + { + table.SelectedRowIndex = 0; + detailPanel.Content = RenderObserverDetail(first, mut, acc, warn); + } + + var layout = HorizontalGridControl.Create() + .Column(c => c.Add(table)) + .WithSplitterAfter(0) + .Column(c => c.Width(38).Add(detailPanel)) + .Build(); + + var width = Math.Min(96, Console.WindowWidth - 4); + var height = Math.Min(28, Console.WindowHeight - 4); + + Window? overlayWindow = null; + overlayWindow = new WindowBuilder(windowSystem) + .WithTitle($" Observers for {eventTypeId} ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(width, height) + .Centered() + .AddControl(layout) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(overlayWindow, activateParent: true, force: false); + e.Handled = true; + return; + } + + if (e.KeyInfo.Key == ConsoleKey.Enter && + table.SelectedRow?.Tag is ObserverInformation obs) + { + windowSystem.CloseWindow(overlayWindow, activateParent: true, force: false); + navigateToObserver(obs); + e.Handled = true; + } + }) + .Build(); + + windowSystem.AddWindow(overlayWindow, activateWindow: true); + } + + /// + /// Opens a modal overlay showing the full definition — name, generation, owner, and schema — for + /// the event type identified by . + /// + /// The event type identifier to look up. + /// The current data snapshot used to locate the registration. + public void OpenEventTypeDefinition(string eventTypeId, WorkbenchData? snapshot) + { + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + var teal = WorkbenchColors.Teal.ToMarkup(); + + EventTypeRegistration? reg = null; + if (snapshot is not null) + { + reg = snapshot.EventTypeRegistrations + .FirstOrDefault(r => string.Equals(r.Type.Id, eventTypeId, StringComparison.OrdinalIgnoreCase)); + } + + string content; + if (reg is null) + { + content = $"[{mut}]No registration found for[/] {eventTypeId}"; + } + else + { + var schemaContent = !string.IsNullOrEmpty(reg.Schema) + ? JsonYamlFormatter.FormatAsYaml(reg.Schema, mut) + : $"[{mut}](no schema)[/]"; + + content = string.Join( + '\n', + [ + $"[bold {teal}]{reg.Type.Id}[/] [{mut}]gen {reg.Type.Generation}[/]", + string.Empty, + $"[{mut}]Owner[/] {reg.Owner}", + $"[{mut}]Source[/] {reg.Source}", + $"[{mut}]Tombstone[/] {reg.Type.Tombstone}", + string.Empty, + $"[{acc}]Schema:[/]", + schemaContent + ]); + } + + var markup = new MarkupControl([content]) { Wrap = true }; + var scrollable = Controls.ScrollablePanel() + .AddControl(markup) + .WithVerticalScroll(ScrollMode.Scroll) + .WithPadding(2, 1, 2, 1) + .Build(); + + var width = Math.Min(80, Console.WindowWidth - 4); + var height = Math.Min(32, Console.WindowHeight - 4); + + Window? defWindow = null; + defWindow = new WindowBuilder(windowSystem) + .WithTitle($" Event Type: {eventTypeId} ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(width, height) + .Centered() + .AddControl(scrollable) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(defWindow, activateParent: true, force: false); + e.Handled = true; + } + }) + .Build(); + + windowSystem.AddWindow(defWindow, activateWindow: true); + } + + static string RenderObserverDetail(ObserverInformation obs, string mut, string acc, string warn) + { + var color = ObserverStateColor(obs); + var lastSeq = obs.LastHandledEventSequenceNumber == ulong.MaxValue + ? "N/A" + : obs.LastHandledEventSequenceNumber.ToString("N0"); + + var lines = new List + { + $"[{mut}]Id[/] {obs.Id}", + $"[{mut}]Type[/] {obs.Type}", + $"[{mut}]State[/] [{color}]{obs.RunningState}[/]", + $"[{mut}]Next #[/] {obs.NextEventSequenceNumber:N0}", + $"[{mut}]Last #[/] {lastSeq}", + string.Empty, + $"[{acc}]Event Types:[/]" + }; + + foreach (var et in (obs.EventTypes ?? []).OrderBy(e => e.Id)) + { + lines.Add($" [{mut}]•[/] {et.Id} [{mut}]gen {et.Generation}[/]"); + } + + lines.Add(string.Empty); + lines.Add($"[{mut}]Enter[/] → go to observer in main view"); + + return string.Join('\n', lines); + } + + static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch + { + ObserverRunningState.Disconnected => 0, + ObserverRunningState.Replaying => 1, + ObserverRunningState.Active => 2, + ObserverRunningState.Suspended => 3, + _ => 4 + }; + + static string ObserverStateColor(ObserverInformation obs) => obs.RunningState switch + { + ObserverRunningState.Active => WorkbenchColors.Success.ToMarkup(), + ObserverRunningState.Replaying => WorkbenchColors.Warning.ToMarkup(), + ObserverRunningState.Disconnected => WorkbenchColors.Danger.ToMarkup(), + _ => WorkbenchColors.Muted.ToMarkup() + }; + + static string ObserverIcon(ObserverInformation obs) => obs.RunningState switch + { + ObserverRunningState.Active => "●", + ObserverRunningState.Replaying => "▲", + ObserverRunningState.Disconnected => "⊘", + _ => "○" + }; + + void NavigateAndFilter(int viewIndex, string filter) + { + if (viewIndex >= 0 && viewIndex < views.Length) + { + views[viewIndex].SetFilter(filter); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchRefreshLoop.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchRefreshLoop.cs new file mode 100644 index 0000000..fc44221 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchRefreshLoop.cs @@ -0,0 +1,205 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Manages the periodic data refresh loop, pushes snapshots to views, and keeps the top status panel current. +/// +/// The Chronicle data service. +/// Workbench settings — controls the refresh interval and connection string. +/// All view instances — each receives every fresh snapshot. +/// Navigation — used to update badge counts after each refresh. +/// The window system — used to write to the top panel. +/// Returns the currently active event store name, or for the default. +/// Returns the currently active namespace name, or for the default. +public class WorkbenchRefreshLoop( + WorkbenchDataService dataService, + WorkbenchSettings settings, + IWorkbenchView[] views, + WorkbenchNavigation navigation, + ConsoleWindowSystem windowSystem, + Func getActiveEventStore, + Func getActiveNamespace) +{ + readonly object _dataLock = new(); + WorkbenchData? _currentData; + bool _wasDisconnected; + + /// + /// Gets the most recently fetched snapshot, or if no fetch has completed yet. + /// Thread-safe — acquires the internal data lock on every access. + /// + public WorkbenchData? CurrentData + { + get + { + lock (_dataLock) + { + return _currentData; + } + } + } + + /// + /// Seeds the loop with a pre-fetched snapshot: pushes it to all views, updates the top panel, and + /// updates navigation badge counts. Call once, before the window is shown. + /// + /// The pre-fetched initial snapshot. + public void Initialize(WorkbenchData data) + { + lock (_dataLock) + { + _currentData = data; + } + + PushDataToViews(data); + UpdateTopPanel(data); + navigation.UpdateNavBadges(data); + } + + /// + /// Runs the periodic refresh loop as a SharpConsoleUI async window thread. + /// Performs an immediate fetch on entry, then repeats on the configured interval until cancellation. + /// + /// The host window (unused — required by the window-thread delegate signature). + /// Cancellation token that signals the window is closing. + public async Task RunAsync(Window window, CancellationToken ct) + { + await FetchAndUpdate(ct); + + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(settings.Interval), ct); + } + catch (OperationCanceledException) + { + break; + } + + await FetchAndUpdate(ct); + } + } + + /// + /// Fetches a fresh snapshot and distributes it to all views, the top panel, and navigation badges. + /// + /// Cancellation token. + public async Task FetchAndUpdate(CancellationToken ct) + { + try + { + SetPanelText("↻ refreshing…"); + + var data = await dataService.FetchAsync( + getActiveEventStore(), + getActiveNamespace(), + readModelContainerName: null, + ct); + + lock (_dataLock) + { + _currentData = data; + } + + PushDataToViews(data); + + if (_wasDisconnected && data.IsConnected) + { + _ = Task.Run( + async () => + { + SetPanelText("✓ Reconnected"); + await Task.Delay(3000, ct); + UpdateTopPanel(_currentData); + }, + ct); + } + + _wasDisconnected = !data.IsConnected; + + UpdateTopPanel(data); + navigation.UpdateNavBadges(data); + } + catch (OperationCanceledException) + { + } + catch + { + // Swallow — connectivity errors are surfaced via IsConnected on the next successful fetch. + } + } + + /// + /// Displays a temporary message in the top panel, then resets to the normal connection summary after two seconds. + /// + /// The temporary text to display. + public void ShowTemporaryMessage(string text) + { + SetPanelText(text); + _ = Task.Delay(2000).ContinueWith(_ => UpdateTopPanel(CurrentData), TaskScheduler.Default); + } + + /// + /// Updates the top panel with the current connection / store / namespace summary. + /// Falls back to when is . + /// + /// Optional snapshot to use; falls back to when omitted. + public void UpdateTopPanel(WorkbenchData? data = null) + { + data ??= CurrentData; + if (data is null) + { + return; + } + + var host = ExtractHostFromConnectionString(settings.ResolveConnectionString()); + var eventStore = getActiveEventStore() ?? settings.ResolveEventStore(); + var ns = getActiveNamespace() ?? settings.ResolveNamespace(); + var connDot = data.IsConnected ? "●" : "○"; + var seqText = data.TailSequenceNumber.HasValue ? $" seq#{data.TailSequenceNumber.Value:N0}" : string.Empty; + + windowSystem.PanelStateService.TopStatus = + $"◆ CHRONICLE WORKBENCH · {host} · {eventStore}/{ns} · ↻{settings.Interval}s{seqText} {connDot}"; + } + + static string ExtractHostFromConnectionString(string connectionString) + { + const string scheme = "chronicle://"; + if (!connectionString.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)) + { + return connectionString; + } + + var afterScheme = connectionString[scheme.Length..]; + + var queryStart = afterScheme.IndexOf('?'); + if (queryStart >= 0) + { + afterScheme = afterScheme[..queryStart]; + } + + var atSign = afterScheme.IndexOf('@'); + if (atSign >= 0) + { + afterScheme = afterScheme[(atSign + 1)..]; + } + + return $"chronicle://{afterScheme}"; + } + + void PushDataToViews(WorkbenchData data) + { + foreach (var view in views) + { + view.UpdateData(data); + } + } + + void SetPanelText(string text) => + windowSystem.PanelStateService.TopStatus = text; +}