diff --git a/AppLensDesktop.sln b/AppLensDesktop.sln
index d0ea2a8..d505f48 100644
--- a/AppLensDesktop.sln
+++ b/AppLensDesktop.sln
@@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppLens.Backend.Tests", "tests\AppLens.Backend.Tests\AppLens.Backend.Tests.csproj", "{C96B8673-428A-4253-A395-EF23F046FF9A}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppLens.Desktop.Tests", "tests\AppLens.Desktop.Tests\AppLens.Desktop.Tests.csproj", "{F81323DD-5671-48F7-9009-91658150D1C0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -59,6 +61,18 @@ Global
{C96B8673-428A-4253-A395-EF23F046FF9A}.Release|x64.Build.0 = Release|Any CPU
{C96B8673-428A-4253-A395-EF23F046FF9A}.Release|x86.ActiveCfg = Release|Any CPU
{C96B8673-428A-4253-A395-EF23F046FF9A}.Release|x86.Build.0 = Release|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Debug|x64.Build.0 = Debug|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Debug|x86.Build.0 = Debug|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Release|x64.ActiveCfg = Release|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Release|x64.Build.0 = Release|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Release|x86.ActiveCfg = Release|Any CPU
+ {F81323DD-5671-48F7-9009-91658150D1C0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -67,5 +81,6 @@ Global
{9EB9CA36-8096-4BA1-A96F-8A8A060DA89E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A63A7527-399E-4482-A344-C9E361B1CF8B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C96B8673-428A-4253-A395-EF23F046FF9A} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {F81323DD-5671-48F7-9009-91658150D1C0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
diff --git a/docs/AppLens-Typography.md b/docs/AppLens-Typography.md
new file mode 100644
index 0000000..28e8ea4
--- /dev/null
+++ b/docs/AppLens-Typography.md
@@ -0,0 +1,23 @@
+# AppLens Typography
+
+AppLens uses a two-font system across desktop UI, exported reports, and design artifacts.
+
+## Standard
+
+- UI text: `Inter`, with `Segoe UI`, `system-ui`, and `sans-serif` fallbacks.
+- Technical text: `JetBrains Mono`, with `Cascadia Mono`, `Consolas`, and `monospace` fallbacks.
+
+Use Inter for navigation, labels, cards, tables, buttons, report prose, and dashboard summaries. Use JetBrains Mono for paths, ledger IDs, correlation IDs, hashes, code-like values, and other local evidence strings where character distinction matters.
+
+## Implementation Notes
+
+WinUI references the font families by name and falls back to native Windows fonts when Inter or JetBrains Mono are not installed. Package font assets later if AppLens needs exact typography on every machine.
+
+HTML reports and design prototypes should keep the shared CSS tokens:
+
+```css
+:root {
+ --font-ui: "Inter", "Segoe UI", system-ui, sans-serif;
+ --font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
+}
+```
diff --git a/src/AppLens.Backend/ReportWriter.cs b/src/AppLens.Backend/ReportWriter.cs
index 860e98b..9d8e48e 100644
--- a/src/AppLens.Backend/ReportWriter.cs
+++ b/src/AppLens.Backend/ReportWriter.cs
@@ -75,8 +75,8 @@ public string WriteHtml(AuditSnapshot snapshot, bool includeRawDetails = false)
AppLens-desktop Audit Report
+
+
diff --git a/src/AppLens.Desktop/AppLens.Desktop.csproj b/src/AppLens.Desktop/AppLens.Desktop.csproj
index 4cafd5b..d892ba1 100644
--- a/src/AppLens.Desktop/AppLens.Desktop.csproj
+++ b/src/AppLens.Desktop/AppLens.Desktop.csproj
@@ -12,6 +12,8 @@
win-x64
true
true
+ None
+ true
true
enable
enable
diff --git a/src/AppLens.Desktop/DashboardPresentation.cs b/src/AppLens.Desktop/DashboardPresentation.cs
new file mode 100644
index 0000000..854492d
--- /dev/null
+++ b/src/AppLens.Desktop/DashboardPresentation.cs
@@ -0,0 +1,272 @@
+using System.Globalization;
+using System.Text;
+using AppLens.Backend;
+
+namespace AppLens.Desktop;
+
+public sealed class DashboardPresentation
+{
+ public DashboardSummaryPresentation Summary { get; init; } = new();
+ public DashboardRailPresentation Rail { get; init; } = new();
+ public List ModuleCards { get; init; } = [];
+ public List PendingActions { get; init; } = [];
+ public List RecentLedgerEvents { get; init; } = [];
+ public string ModuleEmptyState { get; init; } = "No module cards available.";
+ public string PendingApprovalEmptyState { get; init; } = "No pending Tune approvals.";
+ public string LedgerEmptyState { get; init; } = "No recent ledger events.";
+ public string ActiveAppsEmptyState { get; init; } = "Run a scan to populate active app rows.";
+
+ public static DashboardPresentation FromState(AppLensDashboardState state) =>
+ new()
+ {
+ Summary = new DashboardSummaryPresentation
+ {
+ OverallState = state.Summary.OverallState,
+ ModuleCoverage = $"{state.Summary.AvailableModuleCount} available / {state.Summary.BlockedModuleCount} blocked",
+ PendingApprovals = $"{state.Summary.PendingActionCount} pending",
+ RecentEvents = CountLabel(state.Summary.RecentEventCount, "event"),
+ LastEvent = state.Summary.LastLedgerEventAt is { } lastEvent
+ ? FormatTimestamp(lastEvent)
+ : "No ledger events"
+ },
+ Rail = new DashboardRailPresentation
+ {
+ DashboardBadge = state.Summary.OverallState.Equals("Ready", StringComparison.OrdinalIgnoreCase) ? "live" : "action",
+ InventoryBadge = state.Summary.ModuleCount.ToString(CultureInfo.InvariantCulture),
+ TunePlanBadge = state.Summary.PendingActionCount.ToString(CultureInfo.InvariantCulture),
+ ReportsBadge = state.Summary.RecentEventCount.ToString(CultureInfo.InvariantCulture),
+ Modules = state.ModuleCards.Select(ToModuleRailBadge).ToList()
+ },
+ ModuleCards = state.ModuleCards.Select(ToModuleCard).ToList(),
+ PendingActions = state.PendingActions.Select(ToPendingAction).ToList(),
+ RecentLedgerEvents = state.RecentLedgerEvents.Select(ToLedgerEvent).ToList()
+ };
+
+ public static List BuildActiveAppRows(AuditSnapshot snapshot, int limit = 5) =>
+ snapshot.Tune.TopProcesses
+ .OrderByDescending(process => process.WorkingSetBytes)
+ .ThenBy(process => process.Name, StringComparer.OrdinalIgnoreCase)
+ .Take(limit)
+ .Select(process => new ActiveAppRowPresentation
+ {
+ Name = process.Name,
+ ProcessId = $"PID {process.Id}",
+ Memory = Formatting.Size(process.WorkingSetBytes),
+ Cpu = $"{process.CpuSeconds.ToString("N1", CultureInfo.InvariantCulture)}s",
+ Relevance = RelevanceFor(process.Name)
+ })
+ .ToList();
+
+ public static string FormatReadinessScore(AuditSnapshot snapshot) =>
+ snapshot.Readiness.Score.ToString(CultureInfo.InvariantCulture);
+
+ public static string FormatReadinessRating(AuditSnapshot snapshot) =>
+ string.IsNullOrWhiteSpace(snapshot.Readiness.Rating) ? "Review" : snapshot.Readiness.Rating;
+
+ private static ModuleCardPresentation ToModuleCard(ModuleCardReadModel card) =>
+ new()
+ {
+ 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 = CountLabel(card.CapabilityCount, "capability", "capabilities"),
+ ActionText = CountLabel(card.ActionCount, "action"),
+ HealthCheckText = CountLabel(card.HealthCheckCount, "health check"),
+ StorageRootText = CountLabel(card.StorageRootCount, "storage root"),
+ RunnableActionText = card.HasRunnableActions ? "Runnable" : "Read-only"
+ };
+
+ private static ModuleRailBadgePresentation ToModuleRailBadge(ModuleCardReadModel card) =>
+ new()
+ {
+ DisplayName = card.DisplayName,
+ Badge = card.Availability switch
+ {
+ ModuleAvailability.Available => "ok",
+ ModuleAvailability.Blocked => "blocked",
+ _ => "check"
+ }
+ };
+
+ private static PendingTuneApprovalPresentation ToPendingAction(PendingTuneActionReadModel action) =>
+ new()
+ {
+ ProposalId = action.ProposalId,
+ Kind = Humanize(action.Kind.ToString()),
+ Target = action.Target,
+ TargetContext = action.TargetContext,
+ Risk = string.IsNullOrWhiteSpace(action.RiskLevel) ? "Unknown risk" : $"{action.RiskLevel} risk",
+ AdminState = action.RequiresAdmin ? "Admin approval" : "User approval",
+ ProposedAt = FormatTimestamp(action.ProposedAt),
+ Summary = action.Summary,
+ CorrelationId = action.CorrelationId
+ };
+
+ private static LedgerEventPresentation ToLedgerEvent(LedgerEventReadModel evt) =>
+ new()
+ {
+ Type = Humanize(evt.EventType.ToString()),
+ Module = string.IsNullOrWhiteSpace(evt.ModuleId) ? evt.AppId : evt.ModuleId,
+ CreatedAt = FormatTimestamp(evt.CreatedAt),
+ DataState = Humanize(evt.DataState.ToString()).ToLowerInvariant(),
+ PrivacyState = Humanize(evt.PrivacyState.ToString()).ToLowerInvariant(),
+ Summary = evt.Summary,
+ CorrelationId = evt.CorrelationId
+ };
+
+ private static string CountLabel(int count, string noun, string? plural = null) =>
+ count == 1 ? $"1 {noun}" : $"{count} {plural ?? $"{noun}s"}";
+
+ private static string FormatTimestamp(DateTimeOffset timestamp) =>
+ timestamp.ToString("MMM d, yyyy h:mm tt", CultureInfo.InvariantCulture);
+
+ private static string RelevanceFor(string processName)
+ {
+ if (processName.Contains("ollama", StringComparison.OrdinalIgnoreCase)
+ || processName.Contains("llama", StringComparison.OrdinalIgnoreCase))
+ {
+ return "Local AI workload; verify model jobs are intentional.";
+ }
+
+ if (processName.Contains("docker", StringComparison.OrdinalIgnoreCase))
+ {
+ return "Container runtime; confirm it needs to be active.";
+ }
+
+ if (processName.Contains("chrome", StringComparison.OrdinalIgnoreCase)
+ || processName.Contains("msedge", StringComparison.OrdinalIgnoreCase)
+ || processName.Contains("firefox", StringComparison.OrdinalIgnoreCase))
+ {
+ return "Browser workload; review tab pressure if memory is high.";
+ }
+
+ if (processName.Equals("code", StringComparison.OrdinalIgnoreCase)
+ || processName.Contains("devenv", StringComparison.OrdinalIgnoreCase)
+ || processName.Contains("rider", StringComparison.OrdinalIgnoreCase))
+ {
+ return "Developer tool; check workspace and extension load.";
+ }
+
+ return "Ranked by current memory pressure.";
+ }
+
+ private static string Humanize(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return "";
+ }
+
+ var builder = new StringBuilder(value.Length + 8);
+ for (var i = 0; i < value.Length; i++)
+ {
+ var current = value[i];
+ if (i > 0 && char.IsUpper(current) && !char.IsWhiteSpace(value[i - 1]))
+ {
+ builder.Append(' ');
+ }
+
+ builder.Append(i == 0 ? char.ToUpperInvariant(current) : char.ToLowerInvariant(current));
+ }
+
+ return builder.ToString();
+ }
+}
+
+public sealed class DashboardSummaryPresentation
+{
+ public string OverallState { get; init; } = "Ready";
+ public string ModuleCoverage { get; init; } = "0 available / 0 blocked";
+ public string PendingApprovals { get; init; } = "0 pending";
+ public string RecentEvents { get; init; } = "0 events";
+ public string LastEvent { get; init; } = "No ledger events";
+}
+
+public sealed class DashboardRailPresentation
+{
+ public string DashboardBadge { get; init; } = "live";
+ public string InventoryBadge { get; init; } = "0";
+ public string TunePlanBadge { get; init; } = "0";
+ public string ReportsBadge { get; init; } = "0";
+ public List Modules { get; init; } = [];
+}
+
+public sealed class ModuleRailBadgePresentation
+{
+ public string DisplayName { get; init; } = "";
+ public string Badge { get; init; } = "";
+}
+
+public sealed class ModuleCardPresentation
+{
+ 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 ActionText { get; init; } = "";
+ public string HealthCheckText { get; init; } = "";
+ public string StorageRootText { get; init; } = "";
+ public string RunnableActionText { get; init; } = "";
+}
+
+public sealed class PendingTuneApprovalPresentation
+{
+ public string ProposalId { get; init; } = "";
+ public string Kind { get; init; } = "";
+ public string Target { get; init; } = "";
+ public string TargetContext { get; init; } = "";
+ public string Risk { get; init; } = "";
+ public string AdminState { get; init; } = "";
+ public string ProposedAt { get; init; } = "";
+ public string Summary { get; init; } = "";
+ public string CorrelationId { get; init; } = "";
+}
+
+public sealed class LedgerEventPresentation
+{
+ public string Type { get; init; } = "";
+ public string Module { get; init; } = "";
+ public string CreatedAt { get; init; } = "";
+ public string DataState { get; init; } = "";
+ public string PrivacyState { get; init; } = "";
+ public string Summary { get; init; } = "";
+ public string CorrelationId { get; init; } = "";
+}
+
+public sealed class ActiveAppRowPresentation
+{
+ public string Name { get; init; } = "";
+ public string ProcessId { get; init; } = "";
+ public string Memory { get; init; } = "";
+ public string Cpu { get; init; } = "";
+ public string Relevance { get; init; } = "";
+}
+
+public static class DashboardWindowSizing
+{
+ public static DashboardWindowBounds Calculate(DashboardWorkArea workArea, double scale)
+ {
+ var safeScale = double.IsFinite(scale) && scale > 0 ? scale : 1d;
+ var desiredWidth = (int)Math.Round(1180 * safeScale);
+ var desiredHeight = (int)Math.Round(820 * safeScale);
+ var margin = (int)Math.Round(40 * safeScale);
+ var availableWidth = Math.Max(1, workArea.Width - margin * 2);
+ var availableHeight = Math.Max(1, workArea.Height - margin * 2);
+ var width = Math.Min(desiredWidth, availableWidth);
+ var height = Math.Min(desiredHeight, availableHeight);
+ var x = workArea.X + Math.Max(0, (workArea.Width - width) / 2);
+ var y = workArea.Y + Math.Max(0, (workArea.Height - height) / 2);
+
+ return new DashboardWindowBounds(x, y, width, height);
+ }
+}
+
+public sealed record DashboardWorkArea(int X, int Y, int Width, int Height);
+
+public sealed record DashboardWindowBounds(int X, int Y, int Width, int Height);
diff --git a/src/AppLens.Desktop/MainWindow.xaml b/src/AppLens.Desktop/MainWindow.xaml
index d6e61cf..5e21f94 100644
--- a/src/AppLens.Desktop/MainWindow.xaml
+++ b/src/AppLens.Desktop/MainWindow.xaml
@@ -4,37 +4,364 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AppLens-desktop">
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -45,28 +372,28 @@
-
+
-
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
@@ -78,33 +405,33 @@
-
-
-
-
-
-
-
+
-
+
-
+
-
+
-
+
-
+
+
+
+
+
+
+
-
+
@@ -114,19 +441,19 @@
-
-
+
+
-
-
+
+
-
+
-
+
@@ -148,7 +475,7 @@
-
+
@@ -161,8 +488,6 @@
-
-
@@ -192,7 +517,7 @@
-
+
@@ -267,8 +592,8 @@
-
-
+
+
diff --git a/src/AppLens.Desktop/MainWindow.xaml.cs b/src/AppLens.Desktop/MainWindow.xaml.cs
index b641ee0..6edfbc3 100644
--- a/src/AppLens.Desktop/MainWindow.xaml.cs
+++ b/src/AppLens.Desktop/MainWindow.xaml.cs
@@ -1,7 +1,11 @@
using AppLens.Backend;
+using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Windows.Storage.Pickers;
+using System.Runtime.InteropServices;
+using Windows.Graphics;
+using WinRT.Interop;
namespace AppLens.Desktop;
@@ -13,6 +17,7 @@ public sealed partial class MainWindow : Window
private readonly AppLensRuntimeStorage _runtimeStorage = AppLensRuntimeStorage.Default();
private readonly IBlackboardStore _blackboardStore;
private readonly ModuleStatusService _moduleStatusService = new();
+ private readonly DashboardReadModelService _dashboardReadModelService;
private CancellationTokenSource? _scanCancellation;
private AuditSnapshot? _snapshot;
private List _actionLog = [];
@@ -20,12 +25,34 @@ public sealed partial class MainWindow : Window
public MainWindow()
{
_blackboardStore = new BlackboardStore(_runtimeStorage);
+ _dashboardReadModelService = new DashboardReadModelService(_moduleStatusService, _blackboardStore);
InitializeComponent();
ExtendsContentIntoTitleBar = false;
+ ResizeForDashboardViewport();
SetStatus("Ready");
RuntimeRootText.Text = _runtimeStorage.Root;
LedgerPathText.Text = _runtimeStorage.EventsJsonl;
- _ = RefreshPlatformStatusAsync();
+ _ = RefreshDashboardAsync();
+ }
+
+ private async void RefreshDashboard_Click(object sender, RoutedEventArgs e)
+ {
+ await RefreshDashboardAsync(showErrors: true);
+ }
+
+ private void ResizeForDashboardViewport()
+ {
+ var dpi = GetDpiForWindow(WindowNative.GetWindowHandle(this));
+ var scale = Math.Max(1, dpi / 96d);
+ var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Primary);
+ var workArea = displayArea.WorkArea;
+ var bounds = DashboardWindowSizing.Calculate(
+ new DashboardWorkArea(workArea.X, workArea.Y, workArea.Width, workArea.Height),
+ scale);
+
+ AppWindow.MoveAndResize(
+ new RectInt32(bounds.X, bounds.Y, bounds.Width, bounds.Height),
+ displayArea);
}
private async void RunScan_Click(object sender, RoutedEventArgs e)
@@ -169,7 +196,7 @@ private async Task AppendLedgerEventAsync(BlackboardEvent evt)
try
{
await _blackboardStore.AppendAsync(evt);
- await RefreshPlatformStatusAsync();
+ await RefreshDashboardAsync();
return true;
}
catch (Exception ex)
@@ -180,8 +207,9 @@ private async Task AppendLedgerEventAsync(BlackboardEvent evt)
}
}
- private async Task RefreshPlatformStatusAsync()
+ private async Task RefreshDashboardAsync(bool showErrors = false)
{
+ RefreshDashboardButton.IsEnabled = false;
try
{
var indexedCount = await _blackboardStore.GetIndexedEventCountAsync();
@@ -192,13 +220,31 @@ private async Task RefreshPlatformStatusAsync()
LedgerEventCountText.Text = "unavailable";
}
- HostedModulesList.ItemsSource = _moduleStatusService.GetStatuses()
- .Select(status => new ModuleStatusRow(
- status.DisplayName,
- status.Availability.ToString(),
- status.Reason,
- status.NextAction))
- .ToList();
+ try
+ {
+ var state = await _dashboardReadModelService.GetDashboardStateAsync(recentEventLimit: 8);
+ var dashboard = DashboardPresentation.FromState(state);
+ RenderDashboard(dashboard);
+ HostedModulesList.ItemsSource = dashboard.ModuleCards
+ .Select(card => new ModuleStatusRow(
+ card.DisplayName,
+ card.Availability,
+ card.Reason,
+ card.NextAction))
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ DashboardOverallStateText.Text = "Unavailable";
+ if (showErrors)
+ {
+ await ShowDialogAsync("Dashboard refresh failed", ex.Message);
+ }
+ }
+ finally
+ {
+ RefreshDashboardButton.IsEnabled = true;
+ }
}
private async void VerifyTune_Click(object sender, RoutedEventArgs e)
@@ -241,7 +287,8 @@ private void RenderSnapshot(AuditSnapshot snapshot)
{
MachineText.Text = snapshot.Machine.ComputerName;
AppsText.Text = (snapshot.Inventory.DesktopApplications.Count + snapshot.Inventory.StoreApplications.Count).ToString();
- ReadinessText.Text = $"{snapshot.Readiness.Score}/100 {snapshot.Readiness.Rating}";
+ ReadinessText.Text = DashboardPresentation.FormatReadinessScore(snapshot);
+ ReadinessRatingText.Text = DashboardPresentation.FormatReadinessRating(snapshot);
PlanText.Text = $"{snapshot.TunePlan.Count} item(s)";
StartupText.Text = $"{snapshot.Readiness.StartupEnabledCount}/{snapshot.Readiness.StartupTotalCount} enabled";
StorageText.Text = Formatting.Size(snapshot.Readiness.StorageHotspotBytes);
@@ -250,6 +297,9 @@ private void RenderSnapshot(AuditSnapshot snapshot)
FindingsList.ItemsSource = snapshot.Findings;
TunePlanList.ItemsSource = snapshot.TunePlan;
ActionLogList.ItemsSource = snapshot.ActionLog;
+ var activeAppRows = DashboardPresentation.BuildActiveAppRows(snapshot);
+ ActiveAppsList.ItemsSource = activeAppRows;
+ ActiveAppsEmptyText.Visibility = activeAppRows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
AppsList.ItemsSource = snapshot.Inventory.DesktopApplications
.Concat(snapshot.Inventory.StoreApplications)
.Concat(snapshot.Inventory.RuntimesAndFrameworks)
@@ -264,6 +314,28 @@ private void RenderSnapshot(AuditSnapshot snapshot)
VerifyTuneButton.IsEnabled = true;
}
+ private void RenderDashboard(DashboardPresentation dashboard)
+ {
+ DashboardOverallStateText.Text = dashboard.Summary.OverallState;
+ DashboardModuleCoverageText.Text = dashboard.Summary.ModuleCoverage;
+ DashboardPendingApprovalsText.Text = dashboard.Summary.PendingApprovals;
+ DashboardRecentEventsText.Text = dashboard.Summary.RecentEvents;
+ DashboardLastEventText.Text = dashboard.Summary.LastEvent;
+ DashboardRailBadgeText.Text = dashboard.Rail.DashboardBadge;
+ InventoryRailBadgeText.Text = dashboard.Rail.InventoryBadge;
+ TunePlanRailBadgeText.Text = dashboard.Rail.TunePlanBadge;
+ ReportsRailBadgeText.Text = dashboard.Rail.ReportsBadge;
+
+ ModuleRailList.ItemsSource = dashboard.Rail.Modules;
+ ModuleCardsList.ItemsSource = dashboard.ModuleCards;
+ PendingApprovalsList.ItemsSource = dashboard.PendingActions;
+ RecentLedgerEventsList.ItemsSource = dashboard.RecentLedgerEvents;
+
+ 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 static List BuildDiagnostics(AuditSnapshot snapshot)
{
var rows = new List();
@@ -326,6 +398,9 @@ private static AuditSnapshot WithActionLog(AuditSnapshot snapshot, List
+
+
+ net10.0-windows10.0.19041.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/AppLens.Desktop.Tests/DashboardPresentationTests.cs b/tests/AppLens.Desktop.Tests/DashboardPresentationTests.cs
new file mode 100644
index 0000000..484f6dc
--- /dev/null
+++ b/tests/AppLens.Desktop.Tests/DashboardPresentationTests.cs
@@ -0,0 +1,194 @@
+using AppLens.Backend;
+
+namespace AppLens.Desktop.Tests;
+
+public sealed class DashboardPresentationTests
+{
+ [Fact]
+ public void FromState_formats_summary_cards_and_dashboard_rows()
+ {
+ var state = new AppLensDashboardState
+ {
+ Summary = new DashboardSummaryReadModel
+ {
+ OverallState = "Action Required",
+ ModuleCount = 4,
+ AvailableModuleCount = 1,
+ BlockedModuleCount = 3,
+ PendingActionCount = 2,
+ RecentEventCount = 3,
+ LastLedgerEventAt = new DateTimeOffset(2026, 5, 10, 12, 3, 0, TimeSpan.Zero)
+ },
+ ModuleCards =
+ [
+ new ModuleCardReadModel
+ {
+ ModuleId = "llm",
+ DisplayName = "AppLens-LLM",
+ ModuleKind = "local-llm-adapter",
+ Availability = ModuleAvailability.Available,
+ StatusLabel = "Available",
+ RiskLevel = "medium",
+ Reason = "Package and CLI source detected.",
+ NextAction = "Review module status in AppLens.",
+ CapabilityCount = 3,
+ ActionCount = 2,
+ HealthCheckCount = 2,
+ StorageRootCount = 2,
+ HasRunnableActions = true
+ }
+ ],
+ PendingActions =
+ [
+ new PendingTuneActionReadModel
+ {
+ ProposalId = "proposal-1",
+ PlanItemId = "startup-1",
+ Kind = ProposedActionKind.DisableStartup,
+ Target = "Docker Desktop",
+ TargetContext = @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
+ RiskLevel = "Medium",
+ RequiresAdmin = true,
+ ProposedAt = new DateTimeOffset(2026, 5, 10, 12, 1, 0, TimeSpan.Zero),
+ Summary = "Tune action proposed.",
+ CorrelationId = "corr-1"
+ }
+ ],
+ RecentLedgerEvents =
+ [
+ new LedgerEventReadModel
+ {
+ EventId = "evt-1",
+ EventType = BlackboardEventType.ScanCompleted,
+ ModuleId = "report",
+ AppId = "applens-desktop",
+ CorrelationId = "corr-1",
+ CreatedAt = new DateTimeOffset(2026, 5, 10, 12, 3, 0, TimeSpan.Zero),
+ DataState = BlackboardDataState.Validated,
+ PrivacyState = BlackboardPrivacyState.RawPrivate,
+ Summary = "Scan completed."
+ }
+ ]
+ };
+
+ var model = DashboardPresentation.FromState(state);
+
+ Assert.Equal("Action Required", model.Summary.OverallState);
+ Assert.Equal("1 available / 3 blocked", model.Summary.ModuleCoverage);
+ Assert.Equal("2 pending", model.Summary.PendingApprovals);
+ Assert.Equal("3 events", model.Summary.RecentEvents);
+ Assert.Equal("May 10, 2026 12:03 PM", model.Summary.LastEvent);
+ Assert.Equal("action", model.Rail.DashboardBadge);
+ Assert.Equal("4", model.Rail.InventoryBadge);
+ Assert.Equal("2", model.Rail.TunePlanBadge);
+ Assert.Equal("3", model.Rail.ReportsBadge);
+
+ var module = Assert.Single(model.ModuleCards);
+ Assert.Equal("AppLens-LLM", module.DisplayName);
+ Assert.Equal("Available", module.Availability);
+ Assert.Equal("medium risk", module.Risk);
+ Assert.Equal("3 capabilities", module.CapabilityText);
+ Assert.Equal("2 actions", module.ActionText);
+ Assert.Equal("Runnable", module.RunnableActionText);
+
+ var action = Assert.Single(model.PendingActions);
+ Assert.Equal("Disable startup", action.Kind);
+ Assert.Equal("Medium risk", action.Risk);
+ Assert.Equal("Admin approval", action.AdminState);
+ Assert.Equal("May 10, 2026 12:01 PM", action.ProposedAt);
+
+ var ledgerEvent = Assert.Single(model.RecentLedgerEvents);
+ Assert.Equal("Scan completed", ledgerEvent.Type);
+ Assert.Equal("validated", ledgerEvent.DataState);
+ Assert.Equal("raw private", ledgerEvent.PrivacyState);
+
+ var railModule = Assert.Single(model.Rail.Modules);
+ Assert.Equal("AppLens-LLM", railModule.DisplayName);
+ Assert.Equal("ok", railModule.Badge);
+ }
+
+ [Fact]
+ public void FromState_uses_empty_state_copy_when_dashboard_has_no_activity()
+ {
+ var model = DashboardPresentation.FromState(new AppLensDashboardState());
+
+ Assert.Equal("Ready", model.Summary.OverallState);
+ Assert.Equal("0 available / 0 blocked", model.Summary.ModuleCoverage);
+ Assert.Equal("0 pending", model.Summary.PendingApprovals);
+ Assert.Equal("No ledger events", model.Summary.LastEvent);
+ Assert.Equal("No module cards available.", model.ModuleEmptyState);
+ Assert.Equal("No pending Tune approvals.", model.PendingApprovalEmptyState);
+ Assert.Equal("No recent ledger events.", model.LedgerEmptyState);
+ }
+
+ [Fact]
+ public void BuildActiveAppRows_returns_top_five_processes_by_memory_pressure()
+ {
+ var snapshot = new AuditSnapshot
+ {
+ Tune = new TuneSummary
+ {
+ TopProcesses =
+ [
+ new ProcessSnapshot { Name = "small-helper", Id = 6, WorkingSetBytes = 128L * 1024 * 1024, CpuSeconds = 1.1 },
+ new ProcessSnapshot { Name = "Docker Desktop", Id = 2, WorkingSetBytes = 1536L * 1024 * 1024, CpuSeconds = 12.25 },
+ new ProcessSnapshot { Name = "Code", Id = 3, WorkingSetBytes = 768L * 1024 * 1024, CpuSeconds = 9.4 },
+ new ProcessSnapshot { Name = "chrome", Id = 4, WorkingSetBytes = 1024L * 1024 * 1024, CpuSeconds = 27.8 },
+ new ProcessSnapshot { Name = "AppLens", Id = 5, WorkingSetBytes = 256L * 1024 * 1024, CpuSeconds = 2 },
+ new ProcessSnapshot { Name = "ollama", Id = 1, WorkingSetBytes = 2048L * 1024 * 1024, CpuSeconds = 41.6 }
+ ]
+ }
+ };
+
+ var rows = DashboardPresentation.BuildActiveAppRows(snapshot);
+
+ Assert.Equal(["ollama", "Docker Desktop", "chrome", "Code", "AppLens"], rows.Select(row => row.Name).ToArray());
+ Assert.Equal("PID 1", rows[0].ProcessId);
+ Assert.Equal("2.00 GB", rows[0].Memory);
+ Assert.Equal("41.6s", rows[0].Cpu);
+ Assert.Equal("Local AI workload; verify model jobs are intentional.", rows[0].Relevance);
+ Assert.Equal("Container runtime; confirm it needs to be active.", rows[1].Relevance);
+ }
+
+ [Fact]
+ public void Readiness_gauge_uses_score_and_rating_as_separate_labels()
+ {
+ var snapshot = new AuditSnapshot
+ {
+ Readiness = new ReadinessSummary
+ {
+ Score = 100,
+ Rating = "Attention"
+ }
+ };
+
+ Assert.Equal("100", DashboardPresentation.FormatReadinessScore(snapshot));
+ Assert.Equal("Attention", DashboardPresentation.FormatReadinessRating(snapshot));
+ }
+
+ [Fact]
+ public void CalculateWindowBounds_clamps_dashboard_size_to_work_area()
+ {
+ var bounds = DashboardWindowSizing.Calculate(
+ new DashboardWorkArea(100, 50, 1280, 720),
+ scale: 1d);
+
+ Assert.Equal(1180, bounds.Width);
+ Assert.Equal(640, bounds.Height);
+ Assert.Equal(150, bounds.X);
+ Assert.Equal(90, bounds.Y);
+ }
+
+ [Fact]
+ public void CalculateWindowBounds_scales_for_high_dpi_when_space_allows()
+ {
+ var bounds = DashboardWindowSizing.Calculate(
+ new DashboardWorkArea(0, 0, 2880, 1800),
+ scale: 2d);
+
+ Assert.Equal(2360, bounds.Width);
+ Assert.Equal(1640, bounds.Height);
+ Assert.Equal(260, bounds.X);
+ Assert.Equal(80, bounds.Y);
+ }
+}