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"> - - - - - + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - -