From 2675edf88cb05b65307d1a6b714af65b00ce81c9 Mon Sep 17 00:00:00 2001 From: Cody Date: Mon, 11 May 2026 07:14:15 -0700 Subject: [PATCH] Add module detail dashboard selection --- .../DashboardReadModelService.cs | 18 ++- src/AppLens.Desktop/DashboardPresentation.cs | 148 +++++++++++++++++- src/AppLens.Desktop/MainWindow.xaml | 125 ++++++++++++++- src/AppLens.Desktop/MainWindow.xaml.cs | 62 ++++++++ .../DashboardReadModelServiceTests.cs | 5 + .../DashboardPresentationTests.cs | 30 +++- 6 files changed, 380 insertions(+), 8 deletions(-) diff --git a/src/AppLens.Backend/DashboardReadModelService.cs b/src/AppLens.Backend/DashboardReadModelService.cs index 5fdb87d..0c95b92 100644 --- a/src/AppLens.Backend/DashboardReadModelService.cs +++ b/src/AppLens.Backend/DashboardReadModelService.cs @@ -35,6 +35,14 @@ public sealed class ModuleCardReadModel public int HealthCheckCount { get; init; } public int StorageRootCount { get; init; } public bool HasRunnableActions { get; init; } + public List Capabilities { get; init; } = []; + public List Entrypoints { get; init; } = []; + public List DataContracts { get; init; } = []; + public List ActionContracts { get; init; } = []; + public List Privacy { get; init; } = []; + public List StorageRoots { get; init; } = []; + public List HealthChecks { get; init; } = []; + public List Actions { get; init; } = []; } public sealed class PendingTuneActionReadModel @@ -129,7 +137,15 @@ public List GetModuleCards() HealthCheckCount = manifest.HealthChecks.Count, StorageRootCount = manifest.StorageRoots.Count, HasRunnableActions = manifest.Actions.Any(action => - action.Permission.Contains("execute", StringComparison.OrdinalIgnoreCase)) + action.Permission.Contains("execute", StringComparison.OrdinalIgnoreCase)), + Capabilities = manifest.Capabilities, + Entrypoints = manifest.Entrypoints, + DataContracts = manifest.DataContracts, + ActionContracts = manifest.ActionContracts, + Privacy = manifest.Privacy, + StorageRoots = manifest.StorageRoots, + HealthChecks = manifest.HealthChecks, + Actions = manifest.Actions }; }) .ToList(); diff --git a/src/AppLens.Desktop/DashboardPresentation.cs b/src/AppLens.Desktop/DashboardPresentation.cs index 854492d..9a1eb67 100644 --- a/src/AppLens.Desktop/DashboardPresentation.cs +++ b/src/AppLens.Desktop/DashboardPresentation.cs @@ -9,6 +9,7 @@ public sealed class DashboardPresentation public DashboardSummaryPresentation Summary { get; init; } = new(); public DashboardRailPresentation Rail { get; init; } = new(); public List ModuleCards { get; init; } = []; + public ModuleDetailPresentation SelectedModule { get; init; } = ModuleDetailPresentation.Empty; public List PendingActions { get; init; } = []; public List RecentLedgerEvents { get; init; } = []; public string ModuleEmptyState { get; init; } = "No module cards available."; @@ -38,6 +39,7 @@ public static DashboardPresentation FromState(AppLensDashboardState state) => Modules = state.ModuleCards.Select(ToModuleRailBadge).ToList() }, ModuleCards = state.ModuleCards.Select(ToModuleCard).ToList(), + SelectedModule = state.ModuleCards.Select(ToModuleDetail).FirstOrDefault() ?? ModuleDetailPresentation.Empty, PendingActions = state.PendingActions.Select(ToPendingAction).ToList(), RecentLedgerEvents = state.RecentLedgerEvents.Select(ToLedgerEvent).ToList() }; @@ -66,6 +68,7 @@ public static string FormatReadinessRating(AuditSnapshot snapshot) => private static ModuleCardPresentation ToModuleCard(ModuleCardReadModel card) => new() { + ModuleId = card.ModuleId, DisplayName = card.DisplayName, ModuleKind = card.ModuleKind, Availability = card.StatusLabel, @@ -76,12 +79,87 @@ private static ModuleCardPresentation ToModuleCard(ModuleCardReadModel card) => ActionText = CountLabel(card.ActionCount, "action"), HealthCheckText = CountLabel(card.HealthCheckCount, "health check"), StorageRootText = CountLabel(card.StorageRootCount, "storage root"), - RunnableActionText = card.HasRunnableActions ? "Runnable" : "Read-only" + RunnableActionText = card.HasRunnableActions ? "Runnable" : "Read-only", + Capabilities = card.Capabilities, + Entrypoints = card.Entrypoints, + DataContracts = card.DataContracts, + ActionContracts = card.ActionContracts, + Privacy = card.Privacy, + StorageRoots = card.StorageRoots.Select(ToStorageRoot).ToList(), + HealthChecks = card.HealthChecks.Select(ToHealthCheck).ToList(), + Actions = card.Actions.Select(ToAction).ToList() + }; + + public static ModuleDetailPresentation ToModuleDetail(ModuleCardPresentation card) => + new() + { + ModuleId = card.ModuleId, + DisplayName = card.DisplayName, + ModuleKind = card.ModuleKind, + Availability = card.Availability, + Risk = card.Risk, + Reason = card.Reason, + NextAction = card.NextAction, + CapabilityText = JoinOrDash(card.Capabilities), + PrivacyText = JoinOrDash(card.Privacy), + EntrypointText = JoinOrDash(card.Entrypoints), + ContractText = JoinOrDash(card.DataContracts.Concat(card.ActionContracts)), + StorageRoots = card.StorageRoots, + HealthChecks = card.HealthChecks, + Actions = card.Actions + }; + + private static ModuleDetailPresentation ToModuleDetail(ModuleCardReadModel card) => + new() + { + ModuleId = card.ModuleId, + DisplayName = card.DisplayName, + ModuleKind = card.ModuleKind, + Availability = card.StatusLabel, + Risk = string.IsNullOrWhiteSpace(card.RiskLevel) ? "unknown risk" : $"{card.RiskLevel} risk", + Reason = card.Reason, + NextAction = card.NextAction, + CapabilityText = JoinOrDash(card.Capabilities), + PrivacyText = JoinOrDash(card.Privacy), + EntrypointText = JoinOrDash(card.Entrypoints), + ContractText = JoinOrDash(card.DataContracts.Concat(card.ActionContracts)), + StorageRoots = card.StorageRoots.Select(ToStorageRoot).ToList(), + HealthChecks = card.HealthChecks.Select(ToHealthCheck).ToList(), + Actions = card.Actions.Select(ToAction).ToList() + }; + + private static ModuleStorageRootPresentation ToStorageRoot(ModuleStorageRoot root) => + new() + { + Name = root.Name, + Path = root.Path, + PrivacyState = root.PrivacyState, + Description = root.Description + }; + + private static ModuleHealthCheckPresentation ToHealthCheck(ModuleHealthCheck check) => + new() + { + Name = check.Name, + Kind = check.Kind, + Target = check.Target, + Required = check.Required ? "required" : "optional" + }; + + private static ModuleActionPresentation ToAction(ModuleActionContract action) => + new() + { + Name = action.Name, + Permission = action.Permission, + Approval = action.RequiresApproval ? "approval required" : "read-only", + ChangeState = action.SystemChanging ? "changes system" : "local evidence only", + Description = action.Description }; private static ModuleRailBadgePresentation ToModuleRailBadge(ModuleCardReadModel card) => new() { + ModuleId = card.ModuleId, DisplayName = card.DisplayName, Badge = card.Availability switch { @@ -120,6 +198,12 @@ private static LedgerEventPresentation ToLedgerEvent(LedgerEventReadModel evt) = private static string CountLabel(int count, string noun, string? plural = null) => count == 1 ? $"1 {noun}" : $"{count} {plural ?? $"{noun}s"}"; + private static string JoinOrDash(IEnumerable values) + { + var materialized = values.Where(value => !string.IsNullOrWhiteSpace(value)).ToList(); + return materialized.Count == 0 ? "-" : string.Join(", ", materialized); + } + private static string FormatTimestamp(DateTimeOffset timestamp) => timestamp.ToString("MMM d, yyyy h:mm tt", CultureInfo.InvariantCulture); @@ -196,12 +280,14 @@ public sealed class DashboardRailPresentation public sealed class ModuleRailBadgePresentation { + public string ModuleId { get; init; } = ""; public string DisplayName { get; init; } = ""; public string Badge { get; init; } = ""; } public sealed class ModuleCardPresentation { + public string ModuleId { get; init; } = ""; public string DisplayName { get; init; } = ""; public string ModuleKind { get; init; } = ""; public string Availability { get; init; } = ""; @@ -213,6 +299,66 @@ public sealed class ModuleCardPresentation public string HealthCheckText { get; init; } = ""; public string StorageRootText { get; init; } = ""; public string RunnableActionText { get; init; } = ""; + public List Capabilities { get; init; } = []; + public List Entrypoints { get; init; } = []; + public List DataContracts { get; init; } = []; + public List ActionContracts { get; init; } = []; + public List Privacy { get; init; } = []; + public List StorageRoots { get; init; } = []; + public List HealthChecks { get; init; } = []; + public List Actions { get; init; } = []; +} + +public sealed class ModuleDetailPresentation +{ + public static ModuleDetailPresentation Empty { get; } = new() + { + DisplayName = "No module selected", + Availability = "-", + Risk = "-", + Reason = "Select a module to inspect its local readiness contract.", + NextAction = "-" + }; + + public string ModuleId { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public string ModuleKind { get; init; } = ""; + public string Availability { get; init; } = ""; + public string Risk { get; init; } = ""; + public string Reason { get; init; } = ""; + public string NextAction { get; init; } = ""; + public string CapabilityText { get; init; } = "-"; + public string PrivacyText { get; init; } = "-"; + public string EntrypointText { get; init; } = "-"; + public string ContractText { get; init; } = "-"; + public List StorageRoots { get; init; } = []; + public List HealthChecks { get; init; } = []; + public List Actions { get; init; } = []; +} + +public sealed class ModuleStorageRootPresentation +{ + public string Name { get; init; } = ""; + public string Path { get; init; } = ""; + public string PrivacyState { get; init; } = ""; + public string Description { get; init; } = ""; +} + +public sealed class ModuleHealthCheckPresentation +{ + public string Name { get; init; } = ""; + public string Kind { get; init; } = ""; + public string Target { get; init; } = ""; + public string Required { get; init; } = ""; +} + +public sealed class ModuleActionPresentation +{ + public string Name { get; init; } = ""; + public string Permission { get; init; } = ""; + public string Approval { get; init; } = ""; + public string ChangeState { get; init; } = ""; + public string Description { get; init; } = ""; } public sealed class PendingTuneApprovalPresentation diff --git a/src/AppLens.Desktop/MainWindow.xaml b/src/AppLens.Desktop/MainWindow.xaml index 5e21f94..0e2850a 100644 --- a/src/AppLens.Desktop/MainWindow.xaml +++ b/src/AppLens.Desktop/MainWindow.xaml @@ -84,8 +84,8 @@ - - + + @@ -98,8 +98,8 @@ - - + + @@ -199,7 +199,7 @@ - + @@ -245,6 +245,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AppLens.Desktop/MainWindow.xaml.cs b/src/AppLens.Desktop/MainWindow.xaml.cs index 6edfbc3..8dd196b 100644 --- a/src/AppLens.Desktop/MainWindow.xaml.cs +++ b/src/AppLens.Desktop/MainWindow.xaml.cs @@ -18,6 +18,8 @@ public sealed partial class MainWindow : Window private readonly IBlackboardStore _blackboardStore; private readonly ModuleStatusService _moduleStatusService = new(); private readonly DashboardReadModelService _dashboardReadModelService; + private DashboardPresentation _dashboard = new(); + private bool _syncingModuleSelection; private CancellationTokenSource? _scanCancellation; private AuditSnapshot? _snapshot; private List _actionLog = []; @@ -316,6 +318,7 @@ private void RenderSnapshot(AuditSnapshot snapshot) private void RenderDashboard(DashboardPresentation dashboard) { + _dashboard = dashboard; DashboardOverallStateText.Text = dashboard.Summary.OverallState; DashboardModuleCoverageText.Text = dashboard.Summary.ModuleCoverage; DashboardPendingApprovalsText.Text = dashboard.Summary.PendingApprovals; @@ -330,12 +333,71 @@ private void RenderDashboard(DashboardPresentation dashboard) ModuleCardsList.ItemsSource = dashboard.ModuleCards; PendingApprovalsList.ItemsSource = dashboard.PendingActions; RecentLedgerEventsList.ItemsSource = dashboard.RecentLedgerEvents; + SelectModule(dashboard.SelectedModule.ModuleId); ModuleCardsEmptyText.Visibility = dashboard.ModuleCards.Count == 0 ? Visibility.Visible : Visibility.Collapsed; PendingApprovalsEmptyText.Visibility = dashboard.PendingActions.Count == 0 ? Visibility.Visible : Visibility.Collapsed; LedgerEventsEmptyText.Visibility = dashboard.RecentLedgerEvents.Count == 0 ? Visibility.Visible : Visibility.Collapsed; } + private void ModuleSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_syncingModuleSelection) + { + return; + } + + var moduleId = ReferenceEquals(sender, ModuleCardsList) && ModuleCardsList.SelectedItem is ModuleCardPresentation card + ? card.ModuleId + : ReferenceEquals(sender, ModuleRailList) && ModuleRailList.SelectedItem is ModuleRailBadgePresentation rail + ? rail.ModuleId + : ""; + + if (!string.IsNullOrWhiteSpace(moduleId)) + { + SelectModule(moduleId); + } + } + + private void SelectModule(string moduleId) + { + var selectedCard = _dashboard.ModuleCards.FirstOrDefault(card => + card.ModuleId.Equals(moduleId, StringComparison.OrdinalIgnoreCase)); + var detail = selectedCard is null + ? ModuleDetailPresentation.Empty + : DashboardPresentation.ToModuleDetail(selectedCard); + + _syncingModuleSelection = true; + try + { + ModuleCardsList.SelectedItem = selectedCard; + ModuleRailList.SelectedItem = _dashboard.Rail.Modules.FirstOrDefault(rail => + rail.ModuleId.Equals(moduleId, StringComparison.OrdinalIgnoreCase)); + } + finally + { + _syncingModuleSelection = false; + } + + RenderSelectedModule(detail); + } + + private void RenderSelectedModule(ModuleDetailPresentation detail) + { + SelectedModuleNameText.Text = detail.DisplayName; + SelectedModuleKindText.Text = detail.ModuleKind; + SelectedModuleStatusText.Text = detail.Availability; + SelectedModuleRiskText.Text = detail.Risk; + SelectedModuleReasonText.Text = detail.Reason; + SelectedModuleCapabilityText.Text = detail.CapabilityText; + SelectedModulePrivacyText.Text = detail.PrivacyText; + SelectedModuleEntrypointText.Text = detail.EntrypointText; + SelectedModuleStorageRootsList.ItemsSource = detail.StorageRoots; + SelectedModuleHealthChecksList.ItemsSource = detail.HealthChecks; + SelectedModuleActionsList.ItemsSource = detail.Actions; + SelectedModuleNextActionText.Text = detail.NextAction; + } + private static List BuildDiagnostics(AuditSnapshot snapshot) { var rows = new List(); diff --git a/tests/AppLens.Backend.Tests/DashboardReadModelServiceTests.cs b/tests/AppLens.Backend.Tests/DashboardReadModelServiceTests.cs index 8991e91..5800666 100644 --- a/tests/AppLens.Backend.Tests/DashboardReadModelServiceTests.cs +++ b/tests/AppLens.Backend.Tests/DashboardReadModelServiceTests.cs @@ -39,6 +39,11 @@ public void Module_cards_merge_statuses_and_manifests_for_frontend() Assert.True(llm.ActionCount > 0); Assert.True(llm.HasRunnableActions); Assert.Equal("Available", llm.StatusLabel); + Assert.Contains("lane-status", llm.Capabilities); + Assert.Contains("raw_private", llm.Privacy); + Assert.Contains(llm.StorageRoots, root => root.Name == "repository" && root.PrivacyState == "raw_private"); + Assert.Contains(llm.HealthChecks, check => check.Name == "package" && check.Kind == "file"); + Assert.Contains(llm.Actions, action => action.Name == "run-bounded-job" && action.RequiresApproval); var oracle = cards.Single(card => card.ModuleId == "oracle"); Assert.Equal(ModuleAvailability.Blocked, oracle.Availability); diff --git a/tests/AppLens.Desktop.Tests/DashboardPresentationTests.cs b/tests/AppLens.Desktop.Tests/DashboardPresentationTests.cs index 484f6dc..41441cc 100644 --- a/tests/AppLens.Desktop.Tests/DashboardPresentationTests.cs +++ b/tests/AppLens.Desktop.Tests/DashboardPresentationTests.cs @@ -35,7 +35,24 @@ public void FromState_formats_summary_cards_and_dashboard_rows() ActionCount = 2, HealthCheckCount = 2, StorageRootCount = 2, - HasRunnableActions = true + HasRunnableActions = true, + Capabilities = ["lane-status", "scorecard-import"], + Privacy = ["raw_private", "sanitized"], + Entrypoints = ["status_command"], + DataContracts = ["runtime-lanes"], + ActionContracts = ["bounded-local-command"], + StorageRoots = + [ + new ModuleStorageRoot { Name = "repository", Path = @"C:\AppLens-LLM", PrivacyState = "raw_private", Description = "Repository root." } + ], + HealthChecks = + [ + new ModuleHealthCheck { Name = "package", Kind = "file", Target = @"C:\AppLens-LLM\pyproject.toml" } + ], + Actions = + [ + new ModuleActionContract { Name = "run-bounded-job", Permission = "execute-local", RequiresApproval = true, Description = "Run bounded job." } + ] } ], PendingActions = @@ -103,8 +120,18 @@ public void FromState_formats_summary_cards_and_dashboard_rows() Assert.Equal("raw private", ledgerEvent.PrivacyState); var railModule = Assert.Single(model.Rail.Modules); + Assert.Equal("llm", railModule.ModuleId); Assert.Equal("AppLens-LLM", railModule.DisplayName); Assert.Equal("ok", railModule.Badge); + + var detail = model.SelectedModule; + Assert.Equal("AppLens-LLM", detail.DisplayName); + Assert.Equal("Available", detail.Availability); + Assert.Equal("raw_private, sanitized", detail.PrivacyText); + Assert.Equal("lane-status, scorecard-import", detail.CapabilityText); + Assert.Contains(detail.StorageRoots, root => root.Name == "repository" && root.Path == @"C:\AppLens-LLM"); + Assert.Contains(detail.HealthChecks, check => check.Name == "package" && check.Kind == "file"); + Assert.Contains(detail.Actions, action => action.Name == "run-bounded-job" && action.Approval == "approval required"); } [Fact] @@ -119,6 +146,7 @@ public void FromState_uses_empty_state_copy_when_dashboard_has_no_activity() Assert.Equal("No module cards available.", model.ModuleEmptyState); Assert.Equal("No pending Tune approvals.", model.PendingApprovalEmptyState); Assert.Equal("No recent ledger events.", model.LedgerEmptyState); + Assert.Equal("No module selected", model.SelectedModule.DisplayName); } [Fact]