commit 6c53d3775cabd900ee36ea5e62ddad270e25ea4e Author: MandyTop Builder Date: Sun Jul 26 21:57:58 2026 +0200 Initial MandyTop Windows terminal monitor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfb6b24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +bin/ +obj/ +publish/ +publish-*/ +*.pdb +*.user +*.suo +.vs/ +mandytop-*.csv +mandytop-*.json +winbtop-*.csv +winbtop-*.json diff --git a/MandyTop.csproj b/MandyTop.csproj new file mode 100644 index 0000000..ed9781c --- /dev/null +++ b/MandyTop.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..67d6da7 --- /dev/null +++ b/Program.cs @@ -0,0 +1,4476 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net.NetworkInformation; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace WinBtop; + +internal static class Program +{ + private static int Main(string[] args) + { + Console.OutputEncoding = Encoding.UTF8; + Terminal.EnableVirtualTerminal(); + + if (args.Contains("--help") || args.Contains("-h")) + { + Console.WriteLine("MandyTop - Windows Terminal resource monitor"); + Console.WriteLine("Usage: mandytop [--once | --json | --help | --version]"); + Console.WriteLine(" --once print one human-readable sample and exit"); + Console.WriteLine(" --json print one JSON sample and exit"); + Console.WriteLine(" --help show this help"); + Console.WriteLine(" --version show the version"); + return 0; + } + + if (args.Contains("--version")) + { + Console.WriteLine("MandyTop 0.7"); + return 0; + } + + if (args.Contains("--once") || args.Contains("--json")) + { + using var sampler = new Sampler(synchronousInventory: true); + sampler.Read(); + Thread.Sleep(1000); + var snapshot = sampler.Read(); + if (args.Contains("--json")) + { + Console.WriteLine(JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true })); + return 0; + } + + Console.WriteLine($"CPU {snapshot.Cpu.TotalPercent:0.0}%"); + Console.WriteLine($"RAM {Units.Size(snapshot.Memory.UsedBytes)} / {Units.Size(snapshot.Memory.TotalBytes)} ({snapshot.Memory.UsedPercent:0.0}%)"); + Console.WriteLine($"NET down {Units.Rate(snapshot.Network.DownloadBytesPerSec)} up {Units.Rate(snapshot.Network.UploadBytesPerSec)}"); + Console.WriteLine($"UPTIME {Units.Duration(snapshot.System.Uptime)}"); + Console.WriteLine($"Processes {snapshot.Processes.Count}"); + Console.WriteLine($"USB devices {snapshot.UsbDevices.Count} drivers {snapshot.UsbDrivers.Count}"); + Console.WriteLine($"Services {snapshot.Services.Count}"); + Console.WriteLine($"Connections {snapshot.NetworkConnections.Count} startup {snapshot.StartupItems.Count} events {snapshot.Events.Count} drivers {snapshot.Drivers.Count}"); + Console.WriteLine($"Tasks {snapshot.ScheduledTasks.Count} firewall {snapshot.FirewallRules.Count} users/groups {snapshot.LocalUsers.Count}"); + Console.WriteLine($"Security {snapshot.Security.ToDisplayText()}"); + return 0; + } + + using var app = new MonitorApp(); + return app.Run(); + } +} + +internal sealed class MonitorApp : IDisposable +{ + private readonly Sampler sampler = new(); + private bool quit; + private bool paused; + private bool showHelp; + private bool filtering; + private bool treeMode; + private ViewMode viewMode = ViewMode.Monitor; + private string sortColumn = "cpu"; + private string processFilter = ""; + private string usbFilter = ""; + private string serviceFilter = ""; + private string networkFilter = ""; + private string startupFilter = ""; + private string eventFilter = ""; + private string driverFilter = ""; + private string taskFilter = ""; + private string firewallFilter = ""; + private string userFilter = ""; + private string filterBeforeEditing = ""; + private string statusMessage = ""; + private readonly List logEntries = new(); + private bool reverseSort = true; + private int selectedProcess; + private int scrollOffset; + private int selectedUsbDevice; + private int usbScrollOffset; + private int selectedService; + private int serviceScrollOffset; + private int selectedNetworkConnection; + private int networkScrollOffset; + private int selectedStartupItem; + private int startupScrollOffset; + private int selectedEvent; + private int eventScrollOffset; + private int selectedDriver; + private int driverScrollOffset; + private int selectedTask; + private int taskScrollOffset; + private int selectedFirewallRule; + private int firewallScrollOffset; + private int selectedUser; + private int userScrollOffset; + private PendingAction? pendingAction; + private int intervalMs = 1000; + private DateTime nextSample = DateTime.MinValue; + private readonly DateTime splashUntil = DateTime.UtcNow.AddSeconds(2.2); + private Snapshot snapshot = Snapshot.Empty; + + public int Run() + { + Console.CancelKeyPress += OnCancelKeyPress; + Console.CursorVisible = false; + Terminal.Write("\x1b[?1049h\x1b[2J\x1b[H"); + Log("MandyTop v0.7 started"); + + try + { + while (!quit) + { + DrainInput(); + + if (!paused && DateTime.UtcNow >= nextSample) + { + sampler.SetActiveView(viewMode); + snapshot = sampler.Read(); + nextSample = DateTime.UtcNow.AddMilliseconds(intervalMs); + } + + Draw(); + Thread.Sleep(60); + } + + return 0; + } + finally + { + Terminal.Write("\x1b[?1049l\x1b[0m\x1b[?25h"); + Console.CursorVisible = true; + Console.CancelKeyPress -= OnCancelKeyPress; + } + } + + public void Dispose() + { + sampler.Dispose(); + } + + private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) + { + e.Cancel = true; + quit = true; + } + + private void SetStatus(string message, LogLevel level = LogLevel.Info) + { + statusMessage = message; + Log(message, level); + } + + private void Log(string message, LogLevel level = LogLevel.Info) + { + logEntries.Add(new LogEntry(DateTime.Now, level, message)); + if (logEntries.Count > 80) + { + logEntries.RemoveRange(0, logEntries.Count - 80); + } + } + + private void DrainInput() + { + while (Console.KeyAvailable) + { + var key = Console.ReadKey(intercept: true); + + if (filtering) + { + var currentFilter = GetCurrentFilter(); + if (key.Key == ConsoleKey.Escape) + { + SetCurrentFilter(filterBeforeEditing); + filtering = false; + ResetSelectionForCurrentView(); + statusMessage = "Filter editing canceled"; + } + else if (key.Key == ConsoleKey.Enter) + { + filtering = false; + ResetSelectionForCurrentView(); + statusMessage = string.IsNullOrWhiteSpace(GetCurrentFilter()) ? "Filter cleared" : $"Filter: {GetCurrentFilter()}"; + } + else if (key.Key == ConsoleKey.Backspace && currentFilter.Length > 0) + { + SetCurrentFilter(currentFilter[..^1]); + ResetSelectionForCurrentView(); + } + else if (!char.IsControl(key.KeyChar)) + { + SetCurrentFilter(currentFilter + key.KeyChar); + ResetSelectionForCurrentView(); + } + continue; + } + + if (pendingAction is not null) + { + if (key.Key == ConsoleKey.Enter) + { + ExecutePendingAction(); + } + else if (key.Key is ConsoleKey.Escape or ConsoleKey.N) + { + SetStatus("Action canceled", LogLevel.Warn); + pendingAction = null; + } + continue; + } + + switch (key.Key) + { + case ConsoleKey.Q: + case ConsoleKey.Escape: + quit = true; + break; + case ConsoleKey.H: + case ConsoleKey.F1: + showHelp = !showHelp; + break; + case ConsoleKey.Spacebar: + paused = !paused; + break; + case ConsoleKey.S: + SaveSnapshot(); + break; + case ConsoleKey.D1: + case ConsoleKey.NumPad1: + viewMode = ViewMode.Monitor; + statusMessage = "Monitor page"; + break; + case ConsoleKey.D2: + case ConsoleKey.NumPad2: + viewMode = ViewMode.Usb; + statusMessage = "USB page"; + break; + case ConsoleKey.D3: + case ConsoleKey.NumPad3: + viewMode = ViewMode.Services; + statusMessage = "Services page"; + break; + case ConsoleKey.D4: + case ConsoleKey.NumPad4: + viewMode = ViewMode.Network; + statusMessage = "Network page"; + break; + case ConsoleKey.D5: + case ConsoleKey.NumPad5: + viewMode = ViewMode.Startup; + statusMessage = "Startup page"; + break; + case ConsoleKey.D6: + case ConsoleKey.NumPad6: + viewMode = ViewMode.Events; + statusMessage = "Eventlog page"; + break; + case ConsoleKey.D7: + case ConsoleKey.NumPad7: + viewMode = ViewMode.Drivers; + statusMessage = "Drivers page"; + break; + case ConsoleKey.D8: + case ConsoleKey.NumPad8: + viewMode = ViewMode.Security; + SetStatus("Security page"); + break; + case ConsoleKey.D9: + case ConsoleKey.NumPad9: + viewMode = ViewMode.Tasks; + SetStatus("Task Scheduler page"); + break; + case ConsoleKey.F9: + viewMode = ViewMode.Firewall; + SetStatus("Firewall page"); + break; + case ConsoleKey.F10: + viewMode = ViewMode.Users; + SetStatus("Users/groups page"); + break; + case ConsoleKey.D0: + case ConsoleKey.NumPad0: + SetCurrentFilter(""); + ResetSelectionForCurrentView(); + statusMessage = "Filter cleared"; + break; + case ConsoleKey.F: + case ConsoleKey.Oem2: + case ConsoleKey.Divide: + filterBeforeEditing = GetCurrentFilter(); + filtering = true; + statusMessage = "Type filter, Enter to apply, Esc to cancel"; + break; + case ConsoleKey.Add: + case ConsoleKey.OemPlus: + intervalMs = Math.Min(10_000, intervalMs + 250); + break; + case ConsoleKey.Subtract: + case ConsoleKey.OemMinus: + intervalMs = Math.Max(250, intervalMs - 250); + break; + default: + HandleViewKey(key); + break; + } + } + } + + private void HandleViewKey(ConsoleKeyInfo key) + { + switch (viewMode) + { + case ViewMode.Usb: + HandleUsbKey(key); + break; + case ViewMode.Services: + HandleServicesKey(key); + break; + case ViewMode.Network: + HandleNetworkKey(key); + break; + case ViewMode.Startup: + HandleStartupKey(key); + break; + case ViewMode.Events: + HandleEventsKey(key); + break; + case ViewMode.Drivers: + HandleDriversKey(key); + break; + case ViewMode.Security: + HandleSecurityKey(key); + break; + case ViewMode.Tasks: + HandleTasksKey(key); + break; + case ViewMode.Firewall: + HandleFirewallKey(key); + break; + case ViewMode.Users: + HandleUsersKey(key); + break; + default: + HandleMonitorKey(key); + break; + } + } + + private void HandleMonitorKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + reverseSort = !reverseSort; + break; + case ConsoleKey.E: + ExportProcessesCsv(); + break; + case ConsoleKey.C: + CopySelectedProcess(); + break; + case ConsoleKey.T: + treeMode = !treeMode; + selectedProcess = 0; + scrollOffset = 0; + statusMessage = treeMode ? "Process tree enabled" : "Process tree disabled"; + break; + case ConsoleKey.P: + ArmPriorityChange("AboveNormal"); + break; + case ConsoleKey.N: + ArmPriorityChange("Normal"); + break; + case ConsoleKey.B: + ArmProcessAction(PendingActionKind.KillProcessTree); + break; + case ConsoleKey.Z: + ArmProcessAction(PendingActionKind.SuspendProcess); + break; + case ConsoleKey.U: + ArmProcessAction(PendingActionKind.ResumeProcess); + break; + case ConsoleKey.Tab: + sortColumn = sortColumn switch + { + "cpu" => "mem", + "mem" => "threads", + "threads" => "pid", + "pid" => "name", + _ => "cpu" + }; + selectedProcess = 0; + scrollOffset = 0; + break; + case ConsoleKey.Delete: + case ConsoleKey.X: + ArmKillForSelectedProcess(); + break; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + selectedProcess = Math.Max(0, selectedProcess - 1); + break; + case ConsoleKey.DownArrow: + case ConsoleKey.J: + selectedProcess = Math.Min(Math.Max(0, GetVisibleProcesses().Count - 1), selectedProcess + 1); + break; + case ConsoleKey.Home: + selectedProcess = 0; + break; + case ConsoleKey.End: + selectedProcess = Math.Max(0, GetVisibleProcesses().Count - 1); + break; + case ConsoleKey.PageUp: + selectedProcess = Math.Max(0, selectedProcess - 10); + break; + case ConsoleKey.PageDown: + selectedProcess = Math.Min(Math.Max(0, GetVisibleProcesses().Count - 1), selectedProcess + 10); + break; + } + } + + private void HandleNetworkKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + sampler.RefreshInventory(); + nextSample = DateTime.MinValue; + statusMessage = "Network refresh queued"; + break; + case ConsoleKey.E: + ExportNetworkCsv(); + break; + case ConsoleKey.C: + CopySelectedNetworkConnection(); + break; + case ConsoleKey.Delete: + case ConsoleKey.X: + ArmKillForSelectedNetworkConnection(); + break; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + selectedNetworkConnection = Math.Max(0, selectedNetworkConnection - 1); + break; + case ConsoleKey.DownArrow: + case ConsoleKey.J: + selectedNetworkConnection = Math.Min(Math.Max(0, GetVisibleNetworkConnections().Count - 1), selectedNetworkConnection + 1); + break; + case ConsoleKey.Home: + selectedNetworkConnection = 0; + break; + case ConsoleKey.End: + selectedNetworkConnection = Math.Max(0, GetVisibleNetworkConnections().Count - 1); + break; + case ConsoleKey.PageUp: + selectedNetworkConnection = Math.Max(0, selectedNetworkConnection - 10); + break; + case ConsoleKey.PageDown: + selectedNetworkConnection = Math.Min(Math.Max(0, GetVisibleNetworkConnections().Count - 1), selectedNetworkConnection + 10); + break; + } + } + + private void HandleStartupKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + sampler.RefreshInventory(); + nextSample = DateTime.MinValue; + statusMessage = "Startup refresh queued"; + break; + case ConsoleKey.E: + ExportStartupCsv(); + break; + case ConsoleKey.C: + CopySelectedStartupItem(); + break; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + selectedStartupItem = Math.Max(0, selectedStartupItem - 1); + break; + case ConsoleKey.DownArrow: + case ConsoleKey.J: + selectedStartupItem = Math.Min(Math.Max(0, GetVisibleStartupItems().Count - 1), selectedStartupItem + 1); + break; + case ConsoleKey.Home: + selectedStartupItem = 0; + break; + case ConsoleKey.End: + selectedStartupItem = Math.Max(0, GetVisibleStartupItems().Count - 1); + break; + case ConsoleKey.PageUp: + selectedStartupItem = Math.Max(0, selectedStartupItem - 10); + break; + case ConsoleKey.PageDown: + selectedStartupItem = Math.Min(Math.Max(0, GetVisibleStartupItems().Count - 1), selectedStartupItem + 10); + break; + } + } + + private void HandleEventsKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + sampler.RefreshInventory(); + nextSample = DateTime.MinValue; + statusMessage = "Eventlog refresh queued"; + break; + case ConsoleKey.E: + ExportEventsCsv(); + break; + case ConsoleKey.C: + CopySelectedEvent(); + break; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + selectedEvent = Math.Max(0, selectedEvent - 1); + break; + case ConsoleKey.DownArrow: + case ConsoleKey.J: + selectedEvent = Math.Min(Math.Max(0, GetVisibleEvents().Count - 1), selectedEvent + 1); + break; + case ConsoleKey.Home: + selectedEvent = 0; + break; + case ConsoleKey.End: + selectedEvent = Math.Max(0, GetVisibleEvents().Count - 1); + break; + case ConsoleKey.PageUp: + selectedEvent = Math.Max(0, selectedEvent - 10); + break; + case ConsoleKey.PageDown: + selectedEvent = Math.Min(Math.Max(0, GetVisibleEvents().Count - 1), selectedEvent + 10); + break; + } + } + + private void HandleDriversKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + sampler.RefreshInventory(); + nextSample = DateTime.MinValue; + statusMessage = "Driver refresh queued"; + break; + case ConsoleKey.E: + ExportDriversCsv(); + break; + case ConsoleKey.C: + CopySelectedDriver(); + break; + case ConsoleKey.O: + case ConsoleKey.Delete: + case ConsoleKey.X: + ArmDriverAction(PendingActionKind.StopService); + break; + case ConsoleKey.A: + ArmDriverAction(PendingActionKind.StartService); + break; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + selectedDriver = Math.Max(0, selectedDriver - 1); + break; + case ConsoleKey.DownArrow: + case ConsoleKey.J: + selectedDriver = Math.Min(Math.Max(0, GetVisibleDrivers().Count - 1), selectedDriver + 1); + break; + case ConsoleKey.Home: + selectedDriver = 0; + break; + case ConsoleKey.End: + selectedDriver = Math.Max(0, GetVisibleDrivers().Count - 1); + break; + case ConsoleKey.PageUp: + selectedDriver = Math.Max(0, selectedDriver - 10); + break; + case ConsoleKey.PageDown: + selectedDriver = Math.Min(Math.Max(0, GetVisibleDrivers().Count - 1), selectedDriver + 10); + break; + } + } + + private void HandleSecurityKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + sampler.RefreshInventory(); + nextSample = DateTime.MinValue; + statusMessage = "Security refresh queued"; + break; + case ConsoleKey.E: + ExportSecurityCsv(); + break; + case ConsoleKey.C: + CopyToClipboard(snapshot.Security.ToDisplayText(), "Copied security summary"); + break; + } + } + + private void HandleTasksKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + sampler.RefreshInventory(); + nextSample = DateTime.MinValue; + SetStatus("Task refresh queued"); + break; + case ConsoleKey.E: + ExportTasksCsv(); + break; + case ConsoleKey.C: + CopySelectedTask(); + break; + case ConsoleKey.A: + ArmTaskAction(PendingActionKind.EnableTask); + break; + case ConsoleKey.D: + case ConsoleKey.Delete: + ArmTaskAction(PendingActionKind.DisableTask); + break; + case ConsoleKey.S: + case ConsoleKey.X: + ArmTaskAction(PendingActionKind.StartTask); + break; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + selectedTask = Math.Max(0, selectedTask - 1); + break; + case ConsoleKey.DownArrow: + case ConsoleKey.J: + selectedTask = Math.Min(Math.Max(0, GetVisibleTasks().Count - 1), selectedTask + 1); + break; + case ConsoleKey.Home: + selectedTask = 0; + break; + case ConsoleKey.End: + selectedTask = Math.Max(0, GetVisibleTasks().Count - 1); + break; + case ConsoleKey.PageUp: + selectedTask = Math.Max(0, selectedTask - 10); + break; + case ConsoleKey.PageDown: + selectedTask = Math.Min(Math.Max(0, GetVisibleTasks().Count - 1), selectedTask + 10); + break; + } + } + + private void HandleFirewallKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + sampler.RefreshInventory(); + nextSample = DateTime.MinValue; + SetStatus("Firewall refresh queued"); + break; + case ConsoleKey.E: + ExportFirewallCsv(); + break; + case ConsoleKey.C: + CopySelectedFirewallRule(); + break; + case ConsoleKey.A: + ArmFirewallAction(PendingActionKind.EnableFirewallRule); + break; + case ConsoleKey.D: + case ConsoleKey.Delete: + case ConsoleKey.X: + ArmFirewallAction(PendingActionKind.DisableFirewallRule); + break; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + selectedFirewallRule = Math.Max(0, selectedFirewallRule - 1); + break; + case ConsoleKey.DownArrow: + case ConsoleKey.J: + selectedFirewallRule = Math.Min(Math.Max(0, GetVisibleFirewallRules().Count - 1), selectedFirewallRule + 1); + break; + case ConsoleKey.Home: + selectedFirewallRule = 0; + break; + case ConsoleKey.End: + selectedFirewallRule = Math.Max(0, GetVisibleFirewallRules().Count - 1); + break; + case ConsoleKey.PageUp: + selectedFirewallRule = Math.Max(0, selectedFirewallRule - 10); + break; + case ConsoleKey.PageDown: + selectedFirewallRule = Math.Min(Math.Max(0, GetVisibleFirewallRules().Count - 1), selectedFirewallRule + 10); + break; + } + } + + private void HandleUsersKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + sampler.RefreshInventory(); + nextSample = DateTime.MinValue; + SetStatus("Users refresh queued"); + break; + case ConsoleKey.E: + ExportUsersCsv(); + break; + case ConsoleKey.C: + CopySelectedUser(); + break; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + selectedUser = Math.Max(0, selectedUser - 1); + break; + case ConsoleKey.DownArrow: + case ConsoleKey.J: + selectedUser = Math.Min(Math.Max(0, GetVisibleUsers().Count - 1), selectedUser + 1); + break; + case ConsoleKey.Home: + selectedUser = 0; + break; + case ConsoleKey.End: + selectedUser = Math.Max(0, GetVisibleUsers().Count - 1); + break; + case ConsoleKey.PageUp: + selectedUser = Math.Max(0, selectedUser - 10); + break; + case ConsoleKey.PageDown: + selectedUser = Math.Min(Math.Max(0, GetVisibleUsers().Count - 1), selectedUser + 10); + break; + } + } + + private void HandleUsbKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + sampler.RefreshInventory(); + nextSample = DateTime.MinValue; + statusMessage = "USB inventory refresh queued"; + break; + case ConsoleKey.E: + ExportUsbCsv(); + break; + case ConsoleKey.C: + CopySelectedUsbDevice(); + break; + case ConsoleKey.D: + case ConsoleKey.Delete: + case ConsoleKey.X: + ArmDeviceAction(PendingActionKind.DisableDevice); + break; + case ConsoleKey.A: + ArmDeviceAction(PendingActionKind.EnableDevice); + break; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + selectedUsbDevice = Math.Max(0, selectedUsbDevice - 1); + break; + case ConsoleKey.DownArrow: + case ConsoleKey.J: + selectedUsbDevice = Math.Min(Math.Max(0, GetVisibleUsbDevices().Count - 1), selectedUsbDevice + 1); + break; + case ConsoleKey.Home: + selectedUsbDevice = 0; + break; + case ConsoleKey.End: + selectedUsbDevice = Math.Max(0, GetVisibleUsbDevices().Count - 1); + break; + case ConsoleKey.PageUp: + selectedUsbDevice = Math.Max(0, selectedUsbDevice - 10); + break; + case ConsoleKey.PageDown: + selectedUsbDevice = Math.Min(Math.Max(0, GetVisibleUsbDevices().Count - 1), selectedUsbDevice + 10); + break; + } + } + + private void HandleServicesKey(ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.R: + ArmServiceAction(PendingActionKind.RestartService); + break; + case ConsoleKey.O: + case ConsoleKey.Delete: + case ConsoleKey.X: + ArmServiceAction(PendingActionKind.StopService); + break; + case ConsoleKey.A: + ArmServiceAction(PendingActionKind.StartService); + break; + case ConsoleKey.E: + ExportServicesCsv(); + break; + case ConsoleKey.C: + CopySelectedService(); + break; + case ConsoleKey.Tab: + sampler.RefreshInventory(); + nextSample = DateTime.MinValue; + statusMessage = "Service refresh queued"; + break; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + selectedService = Math.Max(0, selectedService - 1); + break; + case ConsoleKey.DownArrow: + case ConsoleKey.J: + selectedService = Math.Min(Math.Max(0, GetVisibleServices().Count - 1), selectedService + 1); + break; + case ConsoleKey.Home: + selectedService = 0; + break; + case ConsoleKey.End: + selectedService = Math.Max(0, GetVisibleServices().Count - 1); + break; + case ConsoleKey.PageUp: + selectedService = Math.Max(0, selectedService - 10); + break; + case ConsoleKey.PageDown: + selectedService = Math.Min(Math.Max(0, GetVisibleServices().Count - 1), selectedService + 10); + break; + } + } + + private string GetCurrentFilter() + { + return viewMode switch + { + ViewMode.Usb => usbFilter, + ViewMode.Services => serviceFilter, + ViewMode.Network => networkFilter, + ViewMode.Startup => startupFilter, + ViewMode.Events => eventFilter, + ViewMode.Drivers => driverFilter, + ViewMode.Tasks => taskFilter, + ViewMode.Firewall => firewallFilter, + ViewMode.Users => userFilter, + _ => processFilter + }; + } + + private void SetCurrentFilter(string value) + { + switch (viewMode) + { + case ViewMode.Usb: + usbFilter = value; + break; + case ViewMode.Services: + serviceFilter = value; + break; + case ViewMode.Network: + networkFilter = value; + break; + case ViewMode.Startup: + startupFilter = value; + break; + case ViewMode.Events: + eventFilter = value; + break; + case ViewMode.Drivers: + driverFilter = value; + break; + case ViewMode.Tasks: + taskFilter = value; + break; + case ViewMode.Firewall: + firewallFilter = value; + break; + case ViewMode.Users: + userFilter = value; + break; + default: + processFilter = value; + break; + } + } + + private void ResetSelectionForCurrentView() + { + switch (viewMode) + { + case ViewMode.Usb: + selectedUsbDevice = 0; + usbScrollOffset = 0; + break; + case ViewMode.Services: + selectedService = 0; + serviceScrollOffset = 0; + break; + case ViewMode.Network: + selectedNetworkConnection = 0; + networkScrollOffset = 0; + break; + case ViewMode.Startup: + selectedStartupItem = 0; + startupScrollOffset = 0; + break; + case ViewMode.Events: + selectedEvent = 0; + eventScrollOffset = 0; + break; + case ViewMode.Drivers: + selectedDriver = 0; + driverScrollOffset = 0; + break; + case ViewMode.Tasks: + selectedTask = 0; + taskScrollOffset = 0; + break; + case ViewMode.Firewall: + selectedFirewallRule = 0; + firewallScrollOffset = 0; + break; + case ViewMode.Users: + selectedUser = 0; + userScrollOffset = 0; + break; + default: + selectedProcess = 0; + scrollOffset = 0; + break; + } + } + + private void SaveSnapshot() + { + try + { + var filename = $"winbtop-snapshot-{DateTime.Now:yyyyMMdd-HHmmss}.json"; + var options = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(filename, JsonSerializer.Serialize(snapshot, options)); + SetStatus($"Saved {filename}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + SetStatus($"Could not save snapshot: {ex.Message}", LogLevel.Error); + } + } + + private void ExportProcessesCsv() + { + try + { + var filename = $"winbtop-processes-{DateTime.Now:yyyyMMdd-HHmmss}.csv"; + var lines = new List { "PID,ParentPID,Name,CPUPercent,MemoryBytes,Threads,Handles,Priority,Responding,StartTime,Path" }; + lines.AddRange(GetVisibleProcesses().Select(p => string.Join(",", + p.Pid, + p.ParentPid, + Csv(p.Name), + p.CpuPercent.ToString("0.00", CultureInfo.InvariantCulture), + p.MemoryBytes, + p.ThreadCount, + p.HandleCount, + Csv(p.Priority), + p.Responding, + Csv(p.StartTime?.ToString("O") ?? ""), + Csv(p.Path ?? "")))); + File.WriteAllLines(filename, lines, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + SetStatus($"Exported {filename}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + SetStatus($"Could not export CSV: {ex.Message}", LogLevel.Error); + } + } + + private void ExportUsbCsv() + { + try + { + var filename = $"winbtop-usb-{DateTime.Now:yyyyMMdd-HHmmss}.csv"; + var lines = new List { "Kind,Name,Status,Class,Present,Provider,Version,InfName,InstanceOrDeviceId" }; + lines.AddRange(GetVisibleUsbDevices().Select(d => string.Join(",", + "Device", + Csv(d.Name), + Csv(d.Status), + Csv(d.Class), + d.Present, + "", + "", + "", + Csv(d.InstanceId)))); + lines.AddRange(snapshot.UsbDrivers.Select(d => string.Join(",", + "Driver", + Csv(d.DeviceName), + "", + Csv(d.DeviceClass), + "", + Csv(d.Provider), + Csv(d.Version), + Csv(d.InfName), + Csv(d.DeviceId)))); + File.WriteAllLines(filename, lines, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + SetStatus($"Exported {filename}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + SetStatus($"Could not export USB CSV: {ex.Message}", LogLevel.Error); + } + } + + private void ExportServicesCsv() + { + try + { + var filename = $"mandytop-services-{DateTime.Now:yyyyMMdd-HHmmss}.csv"; + var lines = new List { "Name,DisplayName,State,StartMode,AcceptStop,ProcessId,DependsOn,DependedOnBy,Path" }; + lines.AddRange(GetVisibleServices().Select(s => string.Join(",", + Csv(s.Name), + Csv(s.DisplayName), + Csv(s.State), + Csv(s.StartMode), + s.AcceptStop, + s.ProcessId, + Csv(s.DependsOn), + Csv(s.DependedOnBy), + Csv(s.Path ?? "")))); + File.WriteAllLines(filename, lines, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + SetStatus($"Exported {filename}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + SetStatus($"Could not export services CSV: {ex.Message}", LogLevel.Error); + } + } + + private void ExportNetworkCsv() + { + ExportCsv("mandytop-network", "Protocol,State,Local,Remote,PID,Process", GetVisibleNetworkConnections().Select(c => string.Join(",", + Csv(c.Protocol), + Csv(c.State), + Csv(c.LocalEndpoint), + Csv(c.RemoteEndpoint), + c.ProcessId, + Csv(c.ProcessName)))); + } + + private void ExportStartupCsv() + { + ExportCsv("mandytop-startup", "Name,Source,Location,Command", GetVisibleStartupItems().Select(i => string.Join(",", + Csv(i.Name), + Csv(i.Source), + Csv(i.Location), + Csv(i.Command)))); + } + + private void ExportEventsCsv() + { + ExportCsv("mandytop-events", "TimeCreated,LogName,Level,Provider,Id,Message", GetVisibleEvents().Select(e => string.Join(",", + Csv(e.TimeCreated?.ToString("O") ?? ""), + Csv(e.LogName), + Csv(e.Level), + Csv(e.Provider), + e.Id, + Csv(e.Message)))); + } + + private void ExportDriversCsv() + { + ExportCsv("mandytop-drivers", "Name,DisplayName,State,StartMode,Path", GetVisibleDrivers().Select(d => string.Join(",", + Csv(d.Name), + Csv(d.DisplayName), + Csv(d.State), + Csv(d.StartMode), + Csv(d.Path ?? "")))); + } + + private void ExportSecurityCsv() + { + var security = snapshot.Security; + var rows = new[] + { + $"DefenderRealTime,{Csv(security.DefenderRealTime)}", + $"DefenderAntivirus,{Csv(security.DefenderAntivirus)}", + $"DefenderSignatureAge,{security.DefenderSignatureAgeDays}", + $"FirewallDomain,{Csv(security.FirewallDomain)}", + $"FirewallPrivate,{Csv(security.FirewallPrivate)}", + $"FirewallPublic,{Csv(security.FirewallPublic)}", + $"LatestHotFix,{Csv(security.LatestHotFix)}", + $"PendingReboot,{security.PendingReboot}", + $"GpuSummary,{Csv(security.GpuSummary)}", + $"ThermalSummary,{Csv(security.ThermalSummary)}" + }; + ExportCsv("mandytop-security", "Name,Value", rows); + } + + private void ExportTasksCsv() + { + ExportCsv("mandytop-tasks", "TaskPath,TaskName,State,Enabled,LastRunTime,NextRunTime,Action", GetVisibleTasks().Select(t => string.Join(",", + Csv(t.TaskPath), + Csv(t.TaskName), + Csv(t.State), + t.Enabled, + Csv(t.LastRunTime?.ToString("O") ?? ""), + Csv(t.NextRunTime?.ToString("O") ?? ""), + Csv(t.Action)))); + } + + private void ExportFirewallCsv() + { + ExportCsv("mandytop-firewall", "Name,DisplayName,Enabled,Direction,Action,Profile,Group", GetVisibleFirewallRules().Select(r => string.Join(",", + Csv(r.Name), + Csv(r.DisplayName), + r.Enabled, + Csv(r.Direction), + Csv(r.Action), + Csv(r.Profile), + Csv(r.Group)))); + } + + private void ExportUsersCsv() + { + ExportCsv("mandytop-users", "Kind,Name,Enabled,IsAdmin,LastLogon,Description,Members", GetVisibleUsers().Select(u => string.Join(",", + Csv(u.Kind), + Csv(u.Name), + u.Enabled, + u.IsAdmin, + Csv(u.LastLogon?.ToString("O") ?? ""), + Csv(u.Description ?? ""), + Csv(u.Members)))); + } + + private void ExportCsv(string prefix, string header, IEnumerable rows) + { + try + { + var filename = $"{prefix}-{DateTime.Now:yyyyMMdd-HHmmss}.csv"; + File.WriteAllLines(filename, new[] { header }.Concat(rows), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + SetStatus($"Exported {filename}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + SetStatus($"Could not export CSV: {ex.Message}", LogLevel.Error); + } + } + + private void CopySelectedProcess() + { + var selected = GetSelectedProcess(); + if (selected is null) + { + statusMessage = "No process selected"; + return; + } + + try + { + var value = selected.Value.Path ?? $"{selected.Value.Name} (PID {selected.Value.Pid})"; + using var clip = Process.Start(new ProcessStartInfo("clip.exe") + { + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true + }); + if (clip is null) + { + throw new InvalidOperationException("clip.exe could not be started"); + } + clip.StandardInput.Write(value); + clip.StandardInput.Close(); + clip.WaitForExit(2000); + statusMessage = $"Copied PID {selected.Value.Pid} details"; + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + statusMessage = $"Could not copy: {ex.Message}"; + } + } + + private void CopySelectedUsbDevice() + { + var selected = GetSelectedUsbDevice(); + if (selected is null) + { + statusMessage = "No USB device selected"; + return; + } + + CopyToClipboard(selected.Value.InstanceId, $"Copied USB instance ID"); + } + + private void CopySelectedService() + { + var selected = GetSelectedService(); + if (selected is null) + { + statusMessage = "No service selected"; + return; + } + + CopyToClipboard(selected.Value.Name, $"Copied service name {selected.Value.Name}"); + } + + private void CopySelectedNetworkConnection() + { + var selected = GetSelectedNetworkConnection(); + if (selected is null) + { + statusMessage = "No network connection selected"; + return; + } + + CopyToClipboard($"{selected.Value.Protocol} {selected.Value.LocalEndpoint} -> {selected.Value.RemoteEndpoint} PID {selected.Value.ProcessId}", "Copied network connection"); + } + + private void CopySelectedStartupItem() + { + var selected = GetSelectedStartupItem(); + if (selected is null) + { + statusMessage = "No startup item selected"; + return; + } + + CopyToClipboard(selected.Value.Command, $"Copied startup command {selected.Value.Name}"); + } + + private void CopySelectedEvent() + { + var selected = GetSelectedEvent(); + if (selected is null) + { + statusMessage = "No event selected"; + return; + } + + CopyToClipboard($"{selected.Value.TimeCreated:O} {selected.Value.LogName} {selected.Value.Level} {selected.Value.Provider} {selected.Value.Id}: {selected.Value.Message}", "Copied event"); + } + + private void CopySelectedDriver() + { + var selected = GetSelectedDriver(); + if (selected is null) + { + statusMessage = "No driver selected"; + return; + } + + CopyToClipboard(selected.Value.Name, $"Copied driver name {selected.Value.Name}"); + } + + private void CopySelectedTask() + { + var selected = GetSelectedTask(); + if (selected is null) + { + SetStatus("No task selected", LogLevel.Warn); + return; + } + + CopyToClipboard($"{selected.Value.TaskPath}{selected.Value.TaskName}", $"Copied task {selected.Value.TaskName}"); + } + + private void CopySelectedFirewallRule() + { + var selected = GetSelectedFirewallRule(); + if (selected is null) + { + SetStatus("No firewall rule selected", LogLevel.Warn); + return; + } + + CopyToClipboard(selected.Value.Name, $"Copied firewall rule {selected.Value.DisplayName}"); + } + + private void CopySelectedUser() + { + var selected = GetSelectedUser(); + if (selected is null) + { + SetStatus("No user/group selected", LogLevel.Warn); + return; + } + + CopyToClipboard($"{selected.Value.Kind} {selected.Value.Name} admin={selected.Value.IsAdmin} enabled={selected.Value.Enabled} lastLogon={selected.Value.LastLogon}", $"Copied {selected.Value.Name}"); + } + + private void CopyToClipboard(string value, string successMessage) + { + try + { + using var clip = Process.Start(new ProcessStartInfo("clip.exe") + { + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true + }); + if (clip is null) + { + throw new InvalidOperationException("clip.exe could not be started"); + } + clip.StandardInput.Write(value); + clip.StandardInput.Close(); + clip.WaitForExit(2000); + SetStatus(successMessage); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + SetStatus($"Could not copy: {ex.Message}", LogLevel.Error); + } + } + + private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\""; + private static string Blank(string value) => string.IsNullOrWhiteSpace(value) ? "none" : value; + + private List GetVisibleUsbDevices() + { + IEnumerable devices = snapshot.UsbDevices; + if (!string.IsNullOrWhiteSpace(usbFilter)) + { + devices = devices.Where(d => + d.Name.Contains(usbFilter, StringComparison.OrdinalIgnoreCase) || + d.Status.Contains(usbFilter, StringComparison.OrdinalIgnoreCase) || + d.Class.Contains(usbFilter, StringComparison.OrdinalIgnoreCase) || + d.InstanceId.Contains(usbFilter, StringComparison.OrdinalIgnoreCase)); + } + + return devices + .OrderByDescending(d => d.Present) + .ThenBy(d => d.Class) + .ThenBy(d => d.Name) + .ToList(); + } + + private List GetVisibleServices() + { + IEnumerable services = snapshot.Services; + if (!string.IsNullOrWhiteSpace(serviceFilter)) + { + services = services.Where(s => + s.Name.Contains(serviceFilter, StringComparison.OrdinalIgnoreCase) || + s.DisplayName.Contains(serviceFilter, StringComparison.OrdinalIgnoreCase) || + s.State.Contains(serviceFilter, StringComparison.OrdinalIgnoreCase) || + s.StartMode.Contains(serviceFilter, StringComparison.OrdinalIgnoreCase)); + } + + return services + .OrderByDescending(s => string.Equals(s.State, "Running", StringComparison.OrdinalIgnoreCase)) + .ThenBy(s => s.Name) + .ToList(); + } + + private List GetVisibleNetworkConnections() + { + IEnumerable connections = snapshot.NetworkConnections; + if (!string.IsNullOrWhiteSpace(networkFilter)) + { + connections = connections.Where(c => + c.Protocol.Contains(networkFilter, StringComparison.OrdinalIgnoreCase) || + c.State.Contains(networkFilter, StringComparison.OrdinalIgnoreCase) || + c.LocalEndpoint.Contains(networkFilter, StringComparison.OrdinalIgnoreCase) || + c.RemoteEndpoint.Contains(networkFilter, StringComparison.OrdinalIgnoreCase) || + c.ProcessName.Contains(networkFilter, StringComparison.OrdinalIgnoreCase) || + c.ProcessId.ToString(CultureInfo.InvariantCulture).Contains(networkFilter, StringComparison.OrdinalIgnoreCase)); + } + + return connections + .OrderByDescending(c => string.Equals(c.State, "Established", StringComparison.OrdinalIgnoreCase)) + .ThenBy(c => c.ProcessName) + .ThenBy(c => c.RemoteEndpoint) + .ToList(); + } + + private List GetVisibleStartupItems() + { + IEnumerable items = snapshot.StartupItems; + if (!string.IsNullOrWhiteSpace(startupFilter)) + { + items = items.Where(i => + i.Name.Contains(startupFilter, StringComparison.OrdinalIgnoreCase) || + i.Source.Contains(startupFilter, StringComparison.OrdinalIgnoreCase) || + i.Location.Contains(startupFilter, StringComparison.OrdinalIgnoreCase) || + i.Command.Contains(startupFilter, StringComparison.OrdinalIgnoreCase)); + } + + return items.OrderBy(i => i.Source).ThenBy(i => i.Name).ToList(); + } + + private List GetVisibleEvents() + { + IEnumerable events = snapshot.Events; + if (!string.IsNullOrWhiteSpace(eventFilter)) + { + events = events.Where(e => + e.LogName.Contains(eventFilter, StringComparison.OrdinalIgnoreCase) || + e.Level.Contains(eventFilter, StringComparison.OrdinalIgnoreCase) || + e.Provider.Contains(eventFilter, StringComparison.OrdinalIgnoreCase) || + e.Message.Contains(eventFilter, StringComparison.OrdinalIgnoreCase) || + e.Id.ToString(CultureInfo.InvariantCulture).Contains(eventFilter, StringComparison.OrdinalIgnoreCase)); + } + + return events.OrderByDescending(e => e.TimeCreated ?? DateTime.MinValue).ToList(); + } + + private List GetVisibleDrivers() + { + IEnumerable drivers = snapshot.Drivers; + if (!string.IsNullOrWhiteSpace(driverFilter)) + { + drivers = drivers.Where(d => + d.Name.Contains(driverFilter, StringComparison.OrdinalIgnoreCase) || + d.DisplayName.Contains(driverFilter, StringComparison.OrdinalIgnoreCase) || + d.State.Contains(driverFilter, StringComparison.OrdinalIgnoreCase) || + d.StartMode.Contains(driverFilter, StringComparison.OrdinalIgnoreCase) || + (d.Path ?? "").Contains(driverFilter, StringComparison.OrdinalIgnoreCase)); + } + + return drivers + .OrderByDescending(d => string.Equals(d.State, "Running", StringComparison.OrdinalIgnoreCase)) + .ThenBy(d => d.Name) + .ToList(); + } + + private List GetVisibleTasks() + { + IEnumerable tasks = snapshot.ScheduledTasks; + if (!string.IsNullOrWhiteSpace(taskFilter)) + { + tasks = tasks.Where(t => + t.TaskName.Contains(taskFilter, StringComparison.OrdinalIgnoreCase) || + t.TaskPath.Contains(taskFilter, StringComparison.OrdinalIgnoreCase) || + t.State.Contains(taskFilter, StringComparison.OrdinalIgnoreCase) || + t.Action.Contains(taskFilter, StringComparison.OrdinalIgnoreCase)); + } + + return tasks + .OrderByDescending(t => string.Equals(t.State, "Running", StringComparison.OrdinalIgnoreCase)) + .ThenBy(t => t.TaskPath) + .ThenBy(t => t.TaskName) + .ToList(); + } + + private List GetVisibleFirewallRules() + { + IEnumerable rules = snapshot.FirewallRules; + if (!string.IsNullOrWhiteSpace(firewallFilter)) + { + rules = rules.Where(r => + r.Name.Contains(firewallFilter, StringComparison.OrdinalIgnoreCase) || + r.DisplayName.Contains(firewallFilter, StringComparison.OrdinalIgnoreCase) || + r.Direction.Contains(firewallFilter, StringComparison.OrdinalIgnoreCase) || + r.Action.Contains(firewallFilter, StringComparison.OrdinalIgnoreCase) || + r.Profile.Contains(firewallFilter, StringComparison.OrdinalIgnoreCase) || + r.Group.Contains(firewallFilter, StringComparison.OrdinalIgnoreCase)); + } + + return rules + .OrderByDescending(r => r.Enabled) + .ThenBy(r => r.Direction) + .ThenBy(r => r.DisplayName) + .ToList(); + } + + private List GetVisibleUsers() + { + IEnumerable users = snapshot.LocalUsers; + if (!string.IsNullOrWhiteSpace(userFilter)) + { + users = users.Where(u => + u.Kind.Contains(userFilter, StringComparison.OrdinalIgnoreCase) || + u.Name.Contains(userFilter, StringComparison.OrdinalIgnoreCase) || + (u.Description ?? "").Contains(userFilter, StringComparison.OrdinalIgnoreCase) || + u.Members.Contains(userFilter, StringComparison.OrdinalIgnoreCase)); + } + + return users + .OrderByDescending(u => u.IsAdmin) + .ThenBy(u => u.Kind) + .ThenBy(u => u.Name) + .ToList(); + } + + private UsbDeviceSnapshot? GetSelectedUsbDevice() + { + var visible = GetVisibleUsbDevices(); + if (visible.Count == 0) + { + return null; + } + + selectedUsbDevice = Math.Clamp(selectedUsbDevice, 0, visible.Count - 1); + return visible[selectedUsbDevice]; + } + + private ServiceSnapshot? GetSelectedService() + { + var visible = GetVisibleServices(); + if (visible.Count == 0) + { + return null; + } + + selectedService = Math.Clamp(selectedService, 0, visible.Count - 1); + return visible[selectedService]; + } + + private NetworkConnectionSnapshot? GetSelectedNetworkConnection() + { + var visible = GetVisibleNetworkConnections(); + if (visible.Count == 0) + { + return null; + } + + selectedNetworkConnection = Math.Clamp(selectedNetworkConnection, 0, visible.Count - 1); + return visible[selectedNetworkConnection]; + } + + private StartupItemSnapshot? GetSelectedStartupItem() + { + var visible = GetVisibleStartupItems(); + if (visible.Count == 0) + { + return null; + } + + selectedStartupItem = Math.Clamp(selectedStartupItem, 0, visible.Count - 1); + return visible[selectedStartupItem]; + } + + private EventSnapshot? GetSelectedEvent() + { + var visible = GetVisibleEvents(); + if (visible.Count == 0) + { + return null; + } + + selectedEvent = Math.Clamp(selectedEvent, 0, visible.Count - 1); + return visible[selectedEvent]; + } + + private DriverSnapshot? GetSelectedDriver() + { + var visible = GetVisibleDrivers(); + if (visible.Count == 0) + { + return null; + } + + selectedDriver = Math.Clamp(selectedDriver, 0, visible.Count - 1); + return visible[selectedDriver]; + } + + private ScheduledTaskSnapshot? GetSelectedTask() + { + var visible = GetVisibleTasks(); + if (visible.Count == 0) + { + return null; + } + + selectedTask = Math.Clamp(selectedTask, 0, visible.Count - 1); + return visible[selectedTask]; + } + + private FirewallRuleSnapshot? GetSelectedFirewallRule() + { + var visible = GetVisibleFirewallRules(); + if (visible.Count == 0) + { + return null; + } + + selectedFirewallRule = Math.Clamp(selectedFirewallRule, 0, visible.Count - 1); + return visible[selectedFirewallRule]; + } + + private LocalUserSnapshot? GetSelectedUser() + { + var visible = GetVisibleUsers(); + if (visible.Count == 0) + { + return null; + } + + selectedUser = Math.Clamp(selectedUser, 0, visible.Count - 1); + return visible[selectedUser]; + } + + private List GetVisibleProcesses() + { + IEnumerable processes = snapshot.Processes; + if (!string.IsNullOrWhiteSpace(processFilter)) + { + processes = processes.Where(p => + p.Name.Contains(processFilter, StringComparison.OrdinalIgnoreCase) || + p.Pid.ToString().Contains(processFilter, StringComparison.OrdinalIgnoreCase)); + } + + var filtered = processes.ToList(); + if (!treeMode) + { + return SortProcesses(filtered).ToList(); + } + + var included = filtered.Select(p => p.Pid).ToHashSet(); + var children = filtered + .Where(p => p.ParentPid != p.Pid && included.Contains(p.ParentPid)) + .GroupBy(p => p.ParentPid) + .ToDictionary(group => group.Key, group => SortProcesses(group).ToList()); + var roots = SortProcesses(filtered.Where(p => !included.Contains(p.ParentPid) || p.ParentPid == p.Pid)); + var flattened = new List(filtered.Count); + var visited = new HashSet(); + + void AddBranch(ProcessSnapshot process) + { + if (!visited.Add(process.Pid)) + { + return; + } + flattened.Add(process); + if (children.TryGetValue(process.Pid, out var branch)) + { + foreach (var child in branch) + { + AddBranch(child); + } + } + } + + foreach (var root in roots) + { + AddBranch(root); + } + foreach (var orphan in SortProcesses(filtered.Where(p => !visited.Contains(p.Pid)))) + { + AddBranch(orphan); + } + return flattened; + } + + private IOrderedEnumerable SortProcesses(IEnumerable processes) + { + return sortColumn switch + { + "mem" => reverseSort ? processes.OrderByDescending(p => p.MemoryBytes) : processes.OrderBy(p => p.MemoryBytes), + "threads" => reverseSort ? processes.OrderByDescending(p => p.ThreadCount) : processes.OrderBy(p => p.ThreadCount), + "pid" => reverseSort ? processes.OrderByDescending(p => p.Pid) : processes.OrderBy(p => p.Pid), + "name" => reverseSort ? processes.OrderByDescending(p => p.Name) : processes.OrderBy(p => p.Name), + _ => reverseSort ? processes.OrderByDescending(p => p.CpuPercent) : processes.OrderBy(p => p.CpuPercent) + }; + } + + private int GetTreeDepth(ProcessSnapshot process) + { + if (!treeMode) + { + return 0; + } + + var lookup = snapshot.Processes.ToDictionary(p => p.Pid); + var seen = new HashSet { process.Pid }; + var depth = 0; + var parent = process.ParentPid; + while (depth < 8 && lookup.TryGetValue(parent, out var current) && seen.Add(parent)) + { + depth++; + parent = current.ParentPid; + } + return depth; + } + + private ProcessSnapshot? GetSelectedProcess() + { + var visible = GetVisibleProcesses(); + if (visible.Count == 0) + { + return null; + } + + selectedProcess = Math.Clamp(selectedProcess, 0, visible.Count - 1); + return visible[selectedProcess]; + } + + private void ArmKillForSelectedProcess() + { + var process = GetSelectedProcess(); + if (process is null) + { + statusMessage = "No process selected"; + return; + } + + pendingAction = new PendingAction(PendingActionKind.KillProcess, process.Value.Pid.ToString(CultureInfo.InvariantCulture), process.Value.Name); + statusMessage = $"Press Enter to kill PID {process.Value.Pid} ({process.Value.Name}), Esc to cancel"; + } + + private void ArmPriorityChange(string priority) + { + var process = GetSelectedProcess(); + if (process is null) + { + statusMessage = "No process selected"; + return; + } + + pendingAction = new PendingAction(PendingActionKind.SetProcessPriority, process.Value.Pid.ToString(CultureInfo.InvariantCulture), priority); + statusMessage = $"Press Enter to set PID {process.Value.Pid} priority to {priority}, Esc to cancel"; + } + + private void ArmProcessAction(PendingActionKind kind) + { + var process = GetSelectedProcess(); + if (process is null) + { + SetStatus("No process selected", LogLevel.Warn); + return; + } + + pendingAction = new PendingAction(kind, process.Value.Pid.ToString(CultureInfo.InvariantCulture), process.Value.Name); + var verb = kind switch + { + PendingActionKind.KillProcessTree => "kill process tree for", + PendingActionKind.SuspendProcess => "suspend", + PendingActionKind.ResumeProcess => "resume", + _ => "act on" + }; + SetStatus($"Press Enter to {verb} PID {process.Value.Pid} ({process.Value.Name}), Esc to cancel", LogLevel.Warn); + } + + private void ArmKillForSelectedNetworkConnection() + { + var connection = GetSelectedNetworkConnection(); + if (connection is null || connection.Value.ProcessId <= 0) + { + statusMessage = "No owning process for selected connection"; + return; + } + + pendingAction = new PendingAction(PendingActionKind.KillProcess, connection.Value.ProcessId.ToString(CultureInfo.InvariantCulture), connection.Value.ProcessName); + statusMessage = $"Press Enter to kill network owner PID {connection.Value.ProcessId} ({connection.Value.ProcessName}), Esc to cancel"; + } + + private void ArmDeviceAction(PendingActionKind kind) + { + var device = GetSelectedUsbDevice(); + if (device is null) + { + statusMessage = "No USB device selected"; + return; + } + + pendingAction = new PendingAction(kind, device.Value.InstanceId, device.Value.Name); + var verb = kind == PendingActionKind.EnableDevice ? "enable" : "disable"; + statusMessage = $"Press Enter to {verb} USB device ({device.Value.Name}), Esc to cancel"; + } + + private void ArmServiceAction(PendingActionKind kind) + { + var service = GetSelectedService(); + if (service is null) + { + statusMessage = "No service selected"; + return; + } + + pendingAction = new PendingAction(kind, service.Value.Name, service.Value.DisplayName); + var verb = kind switch + { + PendingActionKind.StartService => "start", + PendingActionKind.RestartService => "restart", + _ => "stop" + }; + statusMessage = $"Press Enter to {verb} service {service.Value.Name}, Esc to cancel"; + } + + private void ArmDriverAction(PendingActionKind kind) + { + var driver = GetSelectedDriver(); + if (driver is null) + { + statusMessage = "No driver selected"; + return; + } + + pendingAction = new PendingAction(kind, driver.Value.Name, driver.Value.DisplayName); + var verb = kind == PendingActionKind.StartService ? "start" : "stop"; + statusMessage = $"Press Enter to {verb} driver {driver.Value.Name}, Esc to cancel"; + } + + private void ArmTaskAction(PendingActionKind kind) + { + var task = GetSelectedTask(); + if (task is null) + { + SetStatus("No task selected", LogLevel.Warn); + return; + } + + pendingAction = new PendingAction(kind, task.Value.Id, task.Value.TaskName); + var verb = kind switch + { + PendingActionKind.EnableTask => "enable", + PendingActionKind.DisableTask => "disable", + _ => "start" + }; + SetStatus($"Press Enter to {verb} task {task.Value.TaskPath}{task.Value.TaskName}, Esc to cancel", LogLevel.Warn); + } + + private void ArmFirewallAction(PendingActionKind kind) + { + var rule = GetSelectedFirewallRule(); + if (rule is null) + { + SetStatus("No firewall rule selected", LogLevel.Warn); + return; + } + + pendingAction = new PendingAction(kind, rule.Value.Name, rule.Value.DisplayName); + var verb = kind == PendingActionKind.EnableFirewallRule ? "enable" : "disable"; + SetStatus($"Press Enter to {verb} firewall rule {rule.Value.DisplayName}, Esc to cancel", LogLevel.Warn); + } + + private void ExecutePendingAction() + { + if (pendingAction is null) + { + return; + } + + var action = pendingAction.Value; + pendingAction = null; + try + { + switch (action.Kind) + { + case PendingActionKind.KillProcess: + using (var process = Process.GetProcessById(int.Parse(action.Id, CultureInfo.InvariantCulture))) + { + process.Kill(entireProcessTree: false); + } + SetStatus($"Kill signal sent to PID {action.Id}", LogLevel.Warn); + break; + case PendingActionKind.KillProcessTree: + WindowsActions.KillProcessTree(int.Parse(action.Id, CultureInfo.InvariantCulture)); + SetStatus($"Kill tree signal sent to PID {action.Id}", LogLevel.Warn); + break; + case PendingActionKind.SuspendProcess: + WindowsActions.SuspendProcess(int.Parse(action.Id, CultureInfo.InvariantCulture)); + SetStatus($"Suspended PID {action.Id}", LogLevel.Warn); + break; + case PendingActionKind.ResumeProcess: + WindowsActions.ResumeProcess(int.Parse(action.Id, CultureInfo.InvariantCulture)); + SetStatus($"Resumed PID {action.Id}"); + break; + case PendingActionKind.SetProcessPriority: + WindowsActions.SetProcessPriority(int.Parse(action.Id, CultureInfo.InvariantCulture), action.Name); + SetStatus($"Priority for PID {action.Id} set to {action.Name}"); + break; + case PendingActionKind.DisableDevice: + WindowsActions.DisablePnpDevice(action.Id); + SetStatus($"Disable requested for USB device", LogLevel.Warn); + sampler.RefreshInventory(); + break; + case PendingActionKind.EnableDevice: + WindowsActions.EnablePnpDevice(action.Id); + SetStatus($"Enable requested for USB device"); + sampler.RefreshInventory(); + break; + case PendingActionKind.StopService: + WindowsActions.StopService(action.Id); + SetStatus($"Stop requested for service {action.Id}", LogLevel.Warn); + sampler.RefreshInventory(); + break; + case PendingActionKind.RestartService: + WindowsActions.RestartService(action.Id); + SetStatus($"Restart requested for service {action.Id}", LogLevel.Warn); + sampler.RefreshInventory(); + break; + case PendingActionKind.StartService: + WindowsActions.StartService(action.Id); + SetStatus($"Start requested for service {action.Id}"); + sampler.RefreshInventory(); + break; + case PendingActionKind.EnableTask: + WindowsActions.EnableScheduledTask(action.Id); + SetStatus($"Enabled task {action.Name}"); + sampler.RefreshInventory(); + break; + case PendingActionKind.DisableTask: + WindowsActions.DisableScheduledTask(action.Id); + SetStatus($"Disabled task {action.Name}", LogLevel.Warn); + sampler.RefreshInventory(); + break; + case PendingActionKind.StartTask: + WindowsActions.StartScheduledTask(action.Id); + SetStatus($"Started task {action.Name}"); + sampler.RefreshInventory(); + break; + case PendingActionKind.EnableFirewallRule: + WindowsActions.SetFirewallRule(action.Id, enabled: true); + SetStatus($"Enabled firewall rule {action.Name}"); + sampler.RefreshInventory(); + break; + case PendingActionKind.DisableFirewallRule: + WindowsActions.SetFirewallRule(action.Id, enabled: false); + SetStatus($"Disabled firewall rule {action.Name}", LogLevel.Warn); + sampler.RefreshInventory(); + break; + } + nextSample = DateTime.MinValue; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + SetStatus($"Action failed: {ex.Message}", LogLevel.Error); + } + } + + private void Draw() + { + var width = Math.Max(1, Console.WindowWidth); + var height = Math.Max(1, Console.WindowHeight); + var canvas = new Canvas(width, height); + + if (width < 80 || height < 24) + { + canvas.Text(1, 1, Theme.Error, "winbtop needs at least 80x24 cells."); + canvas.Text(2, 1, Theme.Muted, $"Current: {width}x{height}. Press q to quit."); + Terminal.Write(canvas.Render()); + return; + } + + if (DateTime.UtcNow < splashUntil) + { + DrawSplash(canvas, width, height); + Terminal.Write(canvas.Render()); + return; + } + + var now = DateTime.Now; + var title = " MandyTop v0.7 "; + var status = paused ? "PAUSED" : $"{intervalMs} ms"; + var mode = filtering ? $"FILTER:{GetCurrentFilter()}_" : pendingAction is not null ? "CONFIRM ACTION" : status; + var health = ComputeHealthScore(); + var banner = $"{title} health {health.Score:0}% {health.Label} {now:yyyy-MM-dd HH:mm:ss} {mode} 1 mon 2 usb 3 svc 4 net 5 start 6 events 7 drv 8 sec 9 tasks F9 fw F10 users"; + canvas.Text(1, 1, Theme.Title, Text.Fit(banner, width)); + if (!string.IsNullOrWhiteSpace(statusMessage)) + { + canvas.Text(1, Math.Max(1, width - Math.Min(width - 1, statusMessage.Length + 2)), pendingAction is null ? Theme.Highlight : Theme.Warn, Text.Fit(statusMessage, Math.Min(width - 2, statusMessage.Length))); + } + + var logHeight = Math.Clamp(height / 6, 4, 6); + var contentHeight = height - logHeight; + if (viewMode == ViewMode.Usb) + { + DrawUsbPage(canvas, 1, 2, width, contentHeight - 1); + } + else if (viewMode == ViewMode.Services) + { + DrawServicesPage(canvas, 1, 2, width, contentHeight - 1); + } + else if (viewMode == ViewMode.Network) + { + DrawNetworkPage(canvas, 1, 2, width, contentHeight - 1); + } + else if (viewMode == ViewMode.Startup) + { + DrawStartupPage(canvas, 1, 2, width, contentHeight - 1); + } + else if (viewMode == ViewMode.Events) + { + DrawEventsPage(canvas, 1, 2, width, contentHeight - 1); + } + else if (viewMode == ViewMode.Drivers) + { + DrawDriversPage(canvas, 1, 2, width, contentHeight - 1); + } + else if (viewMode == ViewMode.Security) + { + DrawSecurityPage(canvas, 1, 2, width, contentHeight - 1); + } + else if (viewMode == ViewMode.Tasks) + { + DrawTasksPage(canvas, 1, 2, width, contentHeight - 1); + } + else if (viewMode == ViewMode.Firewall) + { + DrawFirewallPage(canvas, 1, 2, width, contentHeight - 1); + } + else if (viewMode == ViewMode.Users) + { + DrawUsersPage(canvas, 1, 2, width, contentHeight - 1); + } + else + { + var cpuHeight = Math.Clamp(contentHeight / 4, 7, 11); + var lowerTop = cpuHeight + 2; + var lowerHeight = Math.Clamp(contentHeight / 4, 7, 11); + var procTop = lowerTop + lowerHeight + 1; + var procHeight = contentHeight - procTop + 1; + var leftWidth = width / 2; + var rightWidth = width - leftWidth; + + DrawCpu(canvas, 1, 2, width, cpuHeight); + DrawMemNet(canvas, 1, lowerTop, leftWidth, lowerHeight); + DrawSystemDisks(canvas, leftWidth + 1, lowerTop, rightWidth, lowerHeight); + DrawProcesses(canvas, 1, procTop, width, procHeight); + } + + DrawLiveLog(canvas, 1, contentHeight + 1, width, logHeight); + + if (showHelp) + { + DrawHelp(canvas, width, height); + } + + Terminal.Write(canvas.Render()); + } + + private void DrawSplash(Canvas canvas, int width, int height) + { + var art = new[] + { + " __ __ _ _____ ", + "| \\/ | __ _ _ __ __| |_ _|_ _|__ _ __ ", + "| |\\/| |/ _` | '_ \\ / _` | | | | | |/ _ \\| '_ \\", + "| | | | (_| | | | | (_| | |_| | | | (_) | |_) |", + "|_| |_|\\__,_|_| |_|\\__,_|\\__, | |_|\\___/| .__/", + " |___/ |_| " + }; + var startY = Math.Max(2, (height - art.Length) / 2 - 2); + var startX = Math.Max(1, (width - art[0].Length) / 2 + 1); + var colors = new[] { Theme.TransBlue, Theme.TransPink, Theme.TransWhite, Theme.TransPink, Theme.TransBlue, Theme.TransWhite }; + for (var i = 0; i < art.Length; i++) + { + canvas.Text(startY + i, startX, colors[i], art[i]); + } + canvas.Text(startY + art.Length + 2, Math.Max(1, (width - 36) / 2), Theme.Highlight, "Windows Terminal system cockpit v0.7"); + canvas.Text(startY + art.Length + 3, Math.Max(1, (width - 30) / 2), Theme.SoftText, "trans colors, admin powers, logs"); + } + + private HealthScore ComputeHealthScore() + { + var score = 100; + score -= (int)Math.Clamp((snapshot.Cpu.TotalPercent - 75) * 0.7, 0, 20); + score -= (int)Math.Clamp((snapshot.Memory.UsedPercent - 80) * 0.8, 0, 18); + var worstDisk = snapshot.Disks.Count == 0 ? 0 : snapshot.Disks.Max(d => d.UsedPercent); + score -= (int)Math.Clamp((worstDisk - 88) * 1.1, 0, 15); + var errorCount = snapshot.Events.Count(e => e.Level.Contains("Error", StringComparison.OrdinalIgnoreCase)); + var warningCount = snapshot.Events.Count(e => e.Level.Contains("Warning", StringComparison.OrdinalIgnoreCase)); + score -= Math.Min(18, errorCount * 3 + warningCount); + if (!string.Equals(snapshot.Security.DefenderRealTime, "True", StringComparison.OrdinalIgnoreCase)) + { + score -= 14; + } + if (!string.Equals(snapshot.Security.DefenderAntivirus, "True", StringComparison.OrdinalIgnoreCase)) + { + score -= 16; + } + foreach (var value in new[] { snapshot.Security.FirewallDomain, snapshot.Security.FirewallPrivate, snapshot.Security.FirewallPublic }) + { + if (!string.Equals(value, "True", StringComparison.OrdinalIgnoreCase)) + { + score -= 8; + } + } + if (snapshot.Security.PendingReboot) + { + score -= 8; + } + + score = Math.Clamp(score, 0, 100); + var label = score switch + { + >= 90 => "excellent", + >= 75 => "good", + >= 55 => "watch", + >= 35 => "rough", + _ => "critical" + }; + return new HealthScore(score, label); + } + + private void DrawLiveLog(Canvas canvas, int x, int y, int w, int h) + { + canvas.Box(x, y, w, h, "live log"); + var rows = Math.Max(0, h - 2); + var recent = logEntries.TakeLast(rows).ToList(); + for (var i = 0; i < recent.Count; i++) + { + var entry = recent[i]; + var color = entry.Level switch + { + LogLevel.Error => Theme.Error, + LogLevel.Warn => Theme.Warn, + _ => Theme.SoftText + }; + canvas.Text(y + 1 + i, x + 2, color, Text.Fit($"{entry.Time:HH:mm:ss} [{entry.Level}] {entry.Message}", Math.Max(5, w - 4))); + } + } + + private void DrawCpu(Canvas canvas, int x, int y, int w, int h) + { + canvas.Box(x, y, w, h, "cpu"); + canvas.Text(y + 1, x + 2, Theme.Highlight, $"Total {snapshot.Cpu.TotalPercent,5:0.0}%"); + canvas.Text(y + 1, x + 17, Theme.Muted, $"Logical CPUs: {snapshot.Cpu.CorePercents.Length}"); + canvas.Text(y + 2, x + 2, Theme.Cpu, Graph.Line(snapshot.Cpu.History, Math.Max(1, w - 4), 100)); + DrawMandyTopArt(canvas, x, y, w, h); + + var cols = Math.Max(1, Math.Min(snapshot.Cpu.CorePercents.Length, (h - 4) * 2)); + var rows = (int)Math.Ceiling(cols / 2.0); + for (var i = 0; i < cols; i++) + { + var row = y + 4 + (i / 2); + var col = x + 2 + (i % 2) * Math.Max(25, (w - 4) / 2); + if (row >= y + h - 1 || col >= x + w - 4) + { + break; + } + + var value = snapshot.Cpu.CorePercents[i]; + var meterWidth = Math.Max(5, Math.Min(12, x + w - col - 11)); + canvas.Text(row, col, Theme.Muted, $"cpu{i,2}"); + canvas.Text(row, col + 6, Color.Percent(value), Meter(value, meterWidth)); + canvas.Text(row, col + 8 + meterWidth, Theme.SoftText, $"{value,5:0}%"); + } + } + + private static void DrawMandyTopArt(Canvas canvas, int x, int y, int w, int h) + { + if (w < 104 || h < 9) + { + return; + } + + var art = new[] + { + " __ __ _ _____ ", + "| \\/ | __ _ _ __ __| |_ _|_ _|__ _ __ ", + "| |\\/| |/ _` | '_ \\ / _` | | | | | |/ _ \\| '_ \\", + "| | | | (_| | | | | (_| | |_| | | | (_) | |_) |", + "|_| |_|\\__,_|_| |_|\\__,_|\\__, | |_|\\___/| .__/", + " |___/ |_| " + }; + var colors = new[] { Theme.TransBlue, Theme.TransPink, Theme.TransWhite, Theme.TransPink, Theme.TransBlue, Theme.TransWhite }; + var startX = x + w - art[0].Length - 3; + var startY = y + 2; + for (var i = 0; i < art.Length; i++) + { + canvas.Text(startY + i, startX, colors[i], art[i]); + } + } + + private void DrawMemNet(Canvas canvas, int x, int y, int w, int h) + { + canvas.Box(x, y, w, h, "mem / net"); + var mem = snapshot.Memory; + var usedGb = mem.UsedBytes / Units.Gib; + var totalGb = mem.TotalBytes / Units.Gib; + canvas.Text(y + 1, x + 2, Theme.Highlight, $"RAM {usedGb:0.0}/{totalGb:0.0} GiB"); + canvas.Text(y + 1, x + Math.Min(w - 16, 24), Color.Percent(mem.UsedPercent), Meter(mem.UsedPercent, Math.Max(8, w - 32))); + canvas.Text(y + 2, x + 2, Theme.Memory, Graph.Line(mem.History, Math.Max(1, w - 4), 100)); + + var net = snapshot.Network; + canvas.Text(y + 4, x + 2, Theme.Highlight, $"NET down {Units.Rate(net.DownloadBytesPerSec),12} up {Units.Rate(net.UploadBytesPerSec),12}"); + canvas.Text(y + 5, x + 2, Theme.Net, Graph.Line(net.History, Math.Max(1, w - 4), Math.Max(1, net.HistoryMax))); + + var line = y + 6; + if (line < y + h - 1) + { + canvas.Text(line++, x + 2, Theme.SoftText, $"Session down {Units.Size(net.TotalDownloadedBytes)} up {Units.Size(net.TotalUploadedBytes)}"); + } + foreach (var adapter in net.Adapters.Take(Math.Max(0, h - 8))) + { + canvas.Text(line++, x + 2, Theme.Muted, $"{Text.Fit(adapter.Name, 20),-20} {Units.Rate(adapter.DownloadBytesPerSec),10} {Units.Rate(adapter.UploadBytesPerSec),10}"); + } + } + + private void DrawSystemDisks(Canvas canvas, int x, int y, int w, int h) + { + canvas.Box(x, y, w, h, "system / disks"); + var system = snapshot.System; + var battery = system.BatteryPercent is null ? "battery n/a" : $"battery {system.BatteryPercent:0}%"; + var power = system.OnAcPower is null ? "" : system.OnAcPower.Value ? " AC" : " battery"; + canvas.Text(y + 1, x + 2, Theme.Highlight, Text.Fit(system.MachineName, Math.Max(8, w / 3))); + canvas.Text(y + 1, x + Math.Max(15, w / 3 + 1), Theme.Muted, Text.Fit(system.OsDescription, Math.Max(10, w - w / 3 - 4))); + canvas.Text(y + 2, x + 2, Theme.Muted, $"Uptime {Units.Duration(system.Uptime)}"); + canvas.Text(y + 2, x + Math.Max(22, w / 2), Color.Percent(100 - (system.BatteryPercent ?? 100)), Text.Fit(battery + power, Math.Max(8, w / 2 - 3))); + if (w >= 62) + { + canvas.Text(y + 3, x + 2, Theme.SoftText, $"RAM free {Units.Size(snapshot.Memory.AvailableBytes)} Commit {Units.Size(snapshot.Memory.PageFileUsedBytes)}/{Units.Size(snapshot.Memory.PageFileTotalBytes)}"); + } + + var row = y + 4; + canvas.Text(row++, x + 2, Theme.Title, " DRIVE USED FREE TOTAL"); + foreach (var disk in snapshot.Disks.Take(Math.Max(0, h - 6))) + { + var name = Text.Fit($"{disk.Name} {disk.Format}", 10); + canvas.Text(row, x + 2, Theme.Muted, $"{name,-10} {Units.Size(disk.UsedBytes),10} {Units.Size(disk.FreeBytes),10} {Units.Size(disk.TotalBytes),10}"); + canvas.Text(row, x + w - 13, Color.Percent(disk.UsedPercent), $"{disk.UsedPercent,5:0}%"); + row++; + } + } + + private void DrawUsbPage(Canvas canvas, int x, int y, int w, int h) + { + var filterText = string.IsNullOrWhiteSpace(usbFilter) ? "" : $" filter:{usbFilter}"; + canvas.Box(x, y, w, h, $"usb devices / installed drivers{filterText}"); + var devices = GetVisibleUsbDevices(); + selectedUsbDevice = Math.Clamp(selectedUsbDevice, 0, Math.Max(0, devices.Count - 1)); + + var driverHeight = Math.Clamp(h / 3, 7, 12); + var deviceHeight = h - driverHeight; + var detailRows = deviceHeight >= 14 ? 4 : 0; + var visibleRows = Math.Max(0, deviceHeight - 4 - detailRows); + if (selectedUsbDevice < usbScrollOffset) + { + usbScrollOffset = selectedUsbDevice; + } + else if (selectedUsbDevice >= usbScrollOffset + visibleRows) + { + usbScrollOffset = selectedUsbDevice - visibleRows + 1; + } + + var connected = snapshot.UsbDevices.Count(d => d.Present); + canvas.Text(y + 1, x + 2, Theme.Highlight, $"Connected {connected} Installed USB devices {snapshot.UsbDevices.Count} Drivers {snapshot.UsbDrivers.Count}"); + canvas.Text(y + 2, x + 2, Theme.Title, " PRES STATUS CLASS NAME"); + for (var i = 0; i < visibleRows && i + usbScrollOffset < devices.Count; i++) + { + var device = devices[i + usbScrollOffset]; + var row = y + 3 + i; + var selected = i + usbScrollOffset == selectedUsbDevice; + var color = selected ? Theme.Selected : device.Present ? Theme.Muted : Theme.SoftText; + var prefix = selected ? ">" : " "; + var present = device.Present ? "yes" : "no "; + var statusColor = string.Equals(device.Status, "OK", StringComparison.OrdinalIgnoreCase) ? color : Theme.Warn; + canvas.Text(row, x + 2, color, $"{prefix} {present,-4} "); + canvas.Text(row, x + 10, statusColor, $"{Text.Fit(device.Status, 10),-10}"); + canvas.Text(row, x + 22, color, $"{Text.Fit(device.Class, 11),-11} {Text.Fit(device.Name, Math.Max(8, w - 37))}"); + } + + if (detailRows > 0 && devices.Count > 0) + { + var device = devices[selectedUsbDevice]; + var top = y + deviceHeight - detailRows; + canvas.Text(top, x + 2, Theme.Border, new string('-', Math.Max(1, w - 4))); + canvas.Text(top + 1, x + 2, Theme.Highlight, Text.Fit($"Selected USB: {device.Name}", Math.Max(5, w - 4))); + canvas.Text(top + 2, x + 2, Theme.Muted, Text.Fit($"Status {device.Status} Class {device.Class} InstanceId: {device.InstanceId}", Math.Max(5, w - 4))); + var devicePending = pendingAction?.Id == device.InstanceId; + canvas.Text(top + 3, x + 2, devicePending ? Theme.Warn : Theme.SoftText, devicePending ? "Enter confirms action, Esc cancels" : "d/x/Delete disable device a enable device c copy InstanceId e export CSV r refresh"); + } + else if (devices.Count == 0) + { + canvas.Text(y + 3, x + 2, Theme.Warn, "No USB devices match the current filter."); + } + + var driverTop = y + deviceHeight; + canvas.Text(driverTop, x + 2, Theme.Border, new string('-', Math.Max(1, w - 4))); + canvas.Text(driverTop + 1, x + 2, Theme.Title, " INSTALLED USB DRIVERS"); + canvas.Text(driverTop + 2, x + 2, Theme.Title, " INF PROVIDER VERSION DEVICE"); + var rowDriver = driverTop + 3; + foreach (var driver in snapshot.UsbDrivers.Take(Math.Max(0, h - deviceHeight - 4))) + { + var line = $"{Text.Fit(driver.InfName, 15),-15} {Text.Fit(driver.Provider, 18),-18} {Text.Fit(driver.Version, 14),-14} {Text.Fit(driver.DeviceName, Math.Max(8, w - 57))}"; + canvas.Text(rowDriver++, x + 2, Theme.Muted, Text.Fit(line, w - 4)); + } + } + + private void DrawServicesPage(Canvas canvas, int x, int y, int w, int h) + { + var filterText = string.IsNullOrWhiteSpace(serviceFilter) ? "" : $" filter:{serviceFilter}"; + canvas.Box(x, y, w, h, $"services{filterText}"); + var services = GetVisibleServices(); + selectedService = Math.Clamp(selectedService, 0, Math.Max(0, services.Count - 1)); + var running = snapshot.Services.Count(s => string.Equals(s.State, "Running", StringComparison.OrdinalIgnoreCase)); + var detailRows = h >= 14 ? 4 : 0; + var visibleRows = Math.Max(0, h - 4 - detailRows); + + if (selectedService < serviceScrollOffset) + { + serviceScrollOffset = selectedService; + } + else if (selectedService >= serviceScrollOffset + visibleRows) + { + serviceScrollOffset = selectedService - visibleRows + 1; + } + + canvas.Text(y + 1, x + 2, Theme.Highlight, $"Services {snapshot.Services.Count} Running {running}"); + canvas.Text(y + 2, x + 2, Theme.Title, " STATE START PID NAME DISPLAY"); + for (var i = 0; i < visibleRows && i + serviceScrollOffset < services.Count; i++) + { + var service = services[i + serviceScrollOffset]; + var row = y + 3 + i; + var selected = i + serviceScrollOffset == selectedService; + var runningService = string.Equals(service.State, "Running", StringComparison.OrdinalIgnoreCase); + var color = selected ? Theme.Selected : runningService ? Theme.Muted : Theme.SoftText; + var prefix = selected ? ">" : " "; + var stateColor = runningService ? color : Theme.Warn; + canvas.Text(row, x + 2, color, prefix); + canvas.Text(row, x + 4, stateColor, $"{Text.Fit(service.State, 10),-10}"); + var line = $" {Text.Fit(service.StartMode, 10),-10} {service.ProcessId,7} {Text.Fit(service.Name, 20),-20} {Text.Fit(service.DisplayName, Math.Max(8, w - 60))}"; + canvas.Text(row, x + 14, color, Text.Fit(line, Math.Max(5, w - 16))); + } + + if (detailRows > 0 && services.Count > 0) + { + var service = services[selectedService]; + var top = y + h - detailRows; + canvas.Text(top, x + 2, Theme.Border, new string('-', Math.Max(1, w - 4))); + canvas.Text(top + 1, x + 2, Theme.Highlight, Text.Fit($"Selected service: {service.Name} ({service.DisplayName})", Math.Max(5, w - 4))); + canvas.Text(top + 2, x + 2, Theme.Muted, Text.Fit($"State {service.State} Start {service.StartMode} PID {service.ProcessId} Depends: {Blank(service.DependsOn)} Used by: {Blank(service.DependedOnBy)}", Math.Max(5, w - 4))); + var servicePending = pendingAction?.Id == service.Name; + canvas.Text(top + 3, x + 2, servicePending ? Theme.Warn : Theme.SoftText, servicePending ? "Enter confirms action, Esc cancels" : "o/x/Delete stop r restart a start c copy name e export CSV Tab refresh"); + } + else if (services.Count == 0) + { + canvas.Text(y + 3, x + 2, Theme.Warn, "No services match the current filter."); + } + } + + private void DrawNetworkPage(Canvas canvas, int x, int y, int w, int h) + { + var filterText = string.IsNullOrWhiteSpace(networkFilter) ? "" : $" filter:{networkFilter}"; + canvas.Box(x, y, w, h, $"network connections{filterText}"); + var connections = GetVisibleNetworkConnections(); + selectedNetworkConnection = Math.Clamp(selectedNetworkConnection, 0, Math.Max(0, connections.Count - 1)); + var established = snapshot.NetworkConnections.Count(c => string.Equals(c.State, "Established", StringComparison.OrdinalIgnoreCase)); + var listening = snapshot.NetworkConnections.Count(c => string.Equals(c.State, "Listen", StringComparison.OrdinalIgnoreCase)); + var detailRows = h >= 14 ? 4 : 0; + var visibleRows = Math.Max(0, h - 4 - detailRows); + KeepSelectionVisible(selectedNetworkConnection, visibleRows, ref networkScrollOffset); + + canvas.Text(y + 1, x + 2, Theme.Highlight, $"TCP/UDP {snapshot.NetworkConnections.Count} Established {established} Listening {listening}"); + canvas.Text(y + 2, x + 2, Theme.Title, " PROTO STATE LOCAL REMOTE PID PROCESS"); + for (var i = 0; i < visibleRows && i + networkScrollOffset < connections.Count; i++) + { + var connection = connections[i + networkScrollOffset]; + var row = y + 3 + i; + var selected = i + networkScrollOffset == selectedNetworkConnection; + var color = selected ? Theme.Selected : string.Equals(connection.State, "Established", StringComparison.OrdinalIgnoreCase) ? Theme.Muted : Theme.SoftText; + var line = $"{(selected ? ">" : " ")} {connection.Protocol,-5} {Text.Fit(connection.State, 12),-12} {Text.Fit(connection.LocalEndpoint, 29),-29} {Text.Fit(connection.RemoteEndpoint, 29),-29} {connection.ProcessId,5} {Text.Fit(connection.ProcessName, Math.Max(8, w - 91))}"; + canvas.Text(row, x + 2, color, Text.Fit(line, w - 4)); + } + + if (detailRows > 0 && connections.Count > 0) + { + var connection = connections[selectedNetworkConnection]; + var top = y + h - detailRows; + canvas.Text(top, x + 2, Theme.Border, new string('-', Math.Max(1, w - 4))); + canvas.Text(top + 1, x + 2, Theme.Highlight, Text.Fit($"Selected connection: {connection.Protocol} {connection.LocalEndpoint} -> {connection.RemoteEndpoint}", Math.Max(5, w - 4))); + canvas.Text(top + 2, x + 2, Theme.Muted, Text.Fit($"State {connection.State} PID {connection.ProcessId} Process {connection.ProcessName}", Math.Max(5, w - 4))); + canvas.Text(top + 3, x + 2, pendingAction?.Id == connection.ProcessId.ToString(CultureInfo.InvariantCulture) ? Theme.Warn : Theme.SoftText, pendingAction?.Id == connection.ProcessId.ToString(CultureInfo.InvariantCulture) ? "Enter confirms kill, Esc cancels" : "x/Delete kill owning process c copy endpoint e export CSV r refresh"); + } + else if (connections.Count == 0) + { + canvas.Text(y + 3, x + 2, Theme.Warn, "No network connections match the current filter."); + } + } + + private void DrawStartupPage(Canvas canvas, int x, int y, int w, int h) + { + var filterText = string.IsNullOrWhiteSpace(startupFilter) ? "" : $" filter:{startupFilter}"; + canvas.Box(x, y, w, h, $"startup entries{filterText}"); + var items = GetVisibleStartupItems(); + selectedStartupItem = Math.Clamp(selectedStartupItem, 0, Math.Max(0, items.Count - 1)); + var detailRows = h >= 14 ? 4 : 0; + var visibleRows = Math.Max(0, h - 4 - detailRows); + KeepSelectionVisible(selectedStartupItem, visibleRows, ref startupScrollOffset); + + canvas.Text(y + 1, x + 2, Theme.Highlight, $"Startup entries {snapshot.StartupItems.Count}"); + canvas.Text(y + 2, x + 2, Theme.Title, " SOURCE NAME LOCATION / COMMAND"); + for (var i = 0; i < visibleRows && i + startupScrollOffset < items.Count; i++) + { + var item = items[i + startupScrollOffset]; + var row = y + 3 + i; + var selected = i + startupScrollOffset == selectedStartupItem; + var color = selected ? Theme.Selected : Theme.Muted; + var line = $"{(selected ? ">" : " ")} {Text.Fit(item.Source, 12),-12} {Text.Fit(item.Name, 28),-28} {Text.Fit(item.Location + " :: " + item.Command, Math.Max(8, w - 50))}"; + canvas.Text(row, x + 2, color, Text.Fit(line, w - 4)); + } + + if (detailRows > 0 && items.Count > 0) + { + var item = items[selectedStartupItem]; + var top = y + h - detailRows; + canvas.Text(top, x + 2, Theme.Border, new string('-', Math.Max(1, w - 4))); + canvas.Text(top + 1, x + 2, Theme.Highlight, Text.Fit($"Selected startup: {item.Name} Source {item.Source}", Math.Max(5, w - 4))); + canvas.Text(top + 2, x + 2, Theme.Muted, Text.Fit($"Location {item.Location} Command: {item.Command}", Math.Max(5, w - 4))); + canvas.Text(top + 3, x + 2, Theme.SoftText, "c copy command e export CSV r refresh"); + } + else if (items.Count == 0) + { + canvas.Text(y + 3, x + 2, Theme.Warn, "No startup entries match the current filter."); + } + } + + private void DrawEventsPage(Canvas canvas, int x, int y, int w, int h) + { + var filterText = string.IsNullOrWhiteSpace(eventFilter) ? "" : $" filter:{eventFilter}"; + canvas.Box(x, y, w, h, $"recent errors / warnings{filterText}"); + var events = GetVisibleEvents(); + selectedEvent = Math.Clamp(selectedEvent, 0, Math.Max(0, events.Count - 1)); + var errors = snapshot.Events.Count(e => e.Level.Contains("Error", StringComparison.OrdinalIgnoreCase)); + var warnings = snapshot.Events.Count(e => e.Level.Contains("Warning", StringComparison.OrdinalIgnoreCase)); + var detailRows = h >= 14 ? 5 : 0; + var visibleRows = Math.Max(0, h - 4 - detailRows); + KeepSelectionVisible(selectedEvent, visibleRows, ref eventScrollOffset); + + canvas.Text(y + 1, x + 2, Theme.Highlight, $"Last 24h sample {snapshot.Events.Count} Errors {errors} Warnings {warnings}"); + canvas.Text(y + 2, x + 2, Theme.Title, " TIME LOG LVL ID PROVIDER / MESSAGE"); + for (var i = 0; i < visibleRows && i + eventScrollOffset < events.Count; i++) + { + var item = events[i + eventScrollOffset]; + var row = y + 3 + i; + var selected = i + eventScrollOffset == selectedEvent; + var color = selected ? Theme.Selected : item.Level.Contains("Error", StringComparison.OrdinalIgnoreCase) ? Theme.Error : Theme.Warn; + var time = item.TimeCreated?.ToString("MM-dd HH:mm:ss") ?? "n/a"; + var line = $"{(selected ? ">" : " ")} {time,-16} {Text.Fit(item.LogName, 12),-12} {Text.Fit(item.Level, 9),-9} {item.Id,6} {Text.Fit(item.Provider + ": " + item.Message, Math.Max(8, w - 61))}"; + canvas.Text(row, x + 2, color, Text.Fit(line, w - 4)); + } + + if (detailRows > 0 && events.Count > 0) + { + var item = events[selectedEvent]; + var top = y + h - detailRows; + canvas.Text(top, x + 2, Theme.Border, new string('-', Math.Max(1, w - 4))); + canvas.Text(top + 1, x + 2, Theme.Highlight, Text.Fit($"Event {item.Id} {item.Level} {item.LogName} {item.TimeCreated}", Math.Max(5, w - 4))); + canvas.Text(top + 2, x + 2, Theme.Muted, Text.Fit($"Provider {item.Provider}", Math.Max(5, w - 4))); + canvas.Text(top + 3, x + 2, Theme.SoftText, Text.Fit(item.Message, Math.Max(5, w - 4))); + canvas.Text(top + 4, x + 2, Theme.SoftText, "c copy event e export CSV r refresh"); + } + else if (events.Count == 0) + { + canvas.Text(y + 3, x + 2, Theme.Warn, "No recent errors or warnings match the current filter."); + } + } + + private void DrawDriversPage(Canvas canvas, int x, int y, int w, int h) + { + var filterText = string.IsNullOrWhiteSpace(driverFilter) ? "" : $" filter:{driverFilter}"; + canvas.Box(x, y, w, h, $"system drivers{filterText}"); + var drivers = GetVisibleDrivers(); + selectedDriver = Math.Clamp(selectedDriver, 0, Math.Max(0, drivers.Count - 1)); + var running = snapshot.Drivers.Count(d => string.Equals(d.State, "Running", StringComparison.OrdinalIgnoreCase)); + var detailRows = h >= 14 ? 4 : 0; + var visibleRows = Math.Max(0, h - 4 - detailRows); + KeepSelectionVisible(selectedDriver, visibleRows, ref driverScrollOffset); + + canvas.Text(y + 1, x + 2, Theme.Highlight, $"Drivers {snapshot.Drivers.Count} Running {running}"); + canvas.Text(y + 2, x + 2, Theme.Title, " STATE START NAME DISPLAY"); + for (var i = 0; i < visibleRows && i + driverScrollOffset < drivers.Count; i++) + { + var driver = drivers[i + driverScrollOffset]; + var row = y + 3 + i; + var selected = i + driverScrollOffset == selectedDriver; + var color = selected ? Theme.Selected : string.Equals(driver.State, "Running", StringComparison.OrdinalIgnoreCase) ? Theme.Muted : Theme.SoftText; + var line = $"{(selected ? ">" : " ")} {Text.Fit(driver.State, 10),-10} {Text.Fit(driver.StartMode, 10),-10} {Text.Fit(driver.Name, 28),-28} {Text.Fit(driver.DisplayName, Math.Max(8, w - 56))}"; + canvas.Text(row, x + 2, color, Text.Fit(line, w - 4)); + } + + if (detailRows > 0 && drivers.Count > 0) + { + var driver = drivers[selectedDriver]; + var top = y + h - detailRows; + canvas.Text(top, x + 2, Theme.Border, new string('-', Math.Max(1, w - 4))); + canvas.Text(top + 1, x + 2, Theme.Highlight, Text.Fit($"Selected driver: {driver.Name} ({driver.DisplayName})", Math.Max(5, w - 4))); + canvas.Text(top + 2, x + 2, Theme.Muted, Text.Fit($"State {driver.State} Start {driver.StartMode} Path: {driver.Path ?? "n/a"}", Math.Max(5, w - 4))); + canvas.Text(top + 3, x + 2, pendingAction?.Id == driver.Name ? Theme.Warn : Theme.SoftText, pendingAction?.Id == driver.Name ? "Enter confirms action, Esc cancels" : "o/x/Delete stop driver a start driver c copy name e export CSV r refresh"); + } + else if (drivers.Count == 0) + { + canvas.Text(y + 3, x + 2, Theme.Warn, "No drivers match the current filter."); + } + } + + private void DrawSecurityPage(Canvas canvas, int x, int y, int w, int h) + { + canvas.Box(x, y, w, h, "security / updates / firewall / hardware"); + var security = snapshot.Security; + var health = ComputeHealthScore(); + var lines = new[] + { + $"Health score: {health.Score:0}% ({health.Label})", + $"Defender real-time: {security.DefenderRealTime}", + $"Defender antivirus: {security.DefenderAntivirus}", + $"Signature age: {security.DefenderSignatureAgeDays} days", + $"Firewall domain/private/public: {security.FirewallDomain} / {security.FirewallPrivate} / {security.FirewallPublic}", + $"Latest hotfix: {security.LatestHotFix}", + $"Pending reboot: {security.PendingReboot}", + $"GPU: {security.GpuSummary}", + $"Thermal: {security.ThermalSummary}", + "", + "c copy summary e export CSV r refresh" + }; + for (var i = 0; i < lines.Length && i < h - 2; i++) + { + var color = i switch + { + 0 => Color.Percent(100 - health.Score), + 1 or 2 or 4 => lines[i].Contains("False", StringComparison.OrdinalIgnoreCase) || lines[i].Contains("Off", StringComparison.OrdinalIgnoreCase) ? Theme.Warn : Theme.Highlight, + 6 => security.PendingReboot ? Theme.Warn : Theme.Ok, + _ => Theme.Muted + }; + canvas.Text(y + 1 + i, x + 2, color, Text.Fit(lines[i], Math.Max(5, w - 4))); + } + } + + private void DrawTasksPage(Canvas canvas, int x, int y, int w, int h) + { + var filterText = string.IsNullOrWhiteSpace(taskFilter) ? "" : $" filter:{taskFilter}"; + canvas.Box(x, y, w, h, $"task scheduler{filterText}"); + var tasks = GetVisibleTasks(); + selectedTask = Math.Clamp(selectedTask, 0, Math.Max(0, tasks.Count - 1)); + var enabled = snapshot.ScheduledTasks.Count(t => t.Enabled); + var running = snapshot.ScheduledTasks.Count(t => string.Equals(t.State, "Running", StringComparison.OrdinalIgnoreCase)); + var detailRows = h >= 14 ? 4 : 0; + var visibleRows = Math.Max(0, h - 4 - detailRows); + KeepSelectionVisible(selectedTask, visibleRows, ref taskScrollOffset); + + canvas.Text(y + 1, x + 2, Theme.Highlight, $"Tasks {snapshot.ScheduledTasks.Count} Enabled {enabled} Running {running}"); + canvas.Text(y + 2, x + 2, Theme.Title, " EN STATE LAST RUN NEXT RUN PATH / NAME"); + for (var i = 0; i < visibleRows && i + taskScrollOffset < tasks.Count; i++) + { + var task = tasks[i + taskScrollOffset]; + var row = y + 3 + i; + var selected = i + taskScrollOffset == selectedTask; + var color = selected ? Theme.Selected : task.Enabled ? Theme.Muted : Theme.SoftText; + var last = task.LastRunTime?.ToString("MM-dd HH:mm") ?? "n/a"; + var next = task.NextRunTime?.ToString("MM-dd HH:mm") ?? "n/a"; + var line = $"{(selected ? ">" : " ")} {(task.Enabled ? "yes" : "no "),-3} {Text.Fit(task.State, 10),-10} {last,-16} {next,-16} {Text.Fit(task.TaskPath + task.TaskName, Math.Max(8, w - 56))}"; + canvas.Text(row, x + 2, color, Text.Fit(line, w - 4)); + } + + if (detailRows > 0 && tasks.Count > 0) + { + var task = tasks[selectedTask]; + var top = y + h - detailRows; + canvas.Text(top, x + 2, Theme.Border, new string('-', Math.Max(1, w - 4))); + canvas.Text(top + 1, x + 2, Theme.Highlight, Text.Fit($"Selected task: {task.TaskPath}{task.TaskName}", Math.Max(5, w - 4))); + canvas.Text(top + 2, x + 2, Theme.Muted, Text.Fit($"State {task.State} Enabled {task.Enabled} Action: {task.Action}", Math.Max(5, w - 4))); + canvas.Text(top + 3, x + 2, pendingAction?.Id == task.Id ? Theme.Warn : Theme.SoftText, pendingAction?.Id == task.Id ? "Enter confirms action, Esc cancels" : "x start task a enable d/Delete disable c copy name e export CSV r refresh"); + } + else if (tasks.Count == 0) + { + canvas.Text(y + 3, x + 2, Theme.Warn, "No scheduled tasks match the current filter."); + } + } + + private void DrawFirewallPage(Canvas canvas, int x, int y, int w, int h) + { + var filterText = string.IsNullOrWhiteSpace(firewallFilter) ? "" : $" filter:{firewallFilter}"; + canvas.Box(x, y, w, h, $"firewall rules{filterText}"); + var rules = GetVisibleFirewallRules(); + selectedFirewallRule = Math.Clamp(selectedFirewallRule, 0, Math.Max(0, rules.Count - 1)); + var enabled = snapshot.FirewallRules.Count(r => r.Enabled); + var detailRows = h >= 14 ? 4 : 0; + var visibleRows = Math.Max(0, h - 4 - detailRows); + KeepSelectionVisible(selectedFirewallRule, visibleRows, ref firewallScrollOffset); + + canvas.Text(y + 1, x + 2, Theme.Highlight, $"Rules {snapshot.FirewallRules.Count} Enabled {enabled}"); + canvas.Text(y + 2, x + 2, Theme.Title, " EN DIR ACTION PROFILE DISPLAY NAME"); + for (var i = 0; i < visibleRows && i + firewallScrollOffset < rules.Count; i++) + { + var rule = rules[i + firewallScrollOffset]; + var row = y + 3 + i; + var selected = i + firewallScrollOffset == selectedFirewallRule; + var color = selected ? Theme.Selected : rule.Enabled ? Theme.Muted : Theme.SoftText; + var actionColor = string.Equals(rule.Action, "Block", StringComparison.OrdinalIgnoreCase) ? Theme.Warn : color; + canvas.Text(row, x + 2, color, $"{(selected ? ">" : " ")} {(rule.Enabled ? "yes" : "no "),-3} {Text.Fit(rule.Direction, 9),-9}"); + canvas.Text(row, x + 18, actionColor, $"{Text.Fit(rule.Action, 8),-8}"); + canvas.Text(row, x + 27, color, $"{Text.Fit(rule.Profile, 14),-14} {Text.Fit(rule.DisplayName, Math.Max(8, w - 44))}"); + } + + if (detailRows > 0 && rules.Count > 0) + { + var rule = rules[selectedFirewallRule]; + var top = y + h - detailRows; + canvas.Text(top, x + 2, Theme.Border, new string('-', Math.Max(1, w - 4))); + canvas.Text(top + 1, x + 2, Theme.Highlight, Text.Fit($"Selected rule: {rule.DisplayName}", Math.Max(5, w - 4))); + canvas.Text(top + 2, x + 2, Theme.Muted, Text.Fit($"Name {rule.Name} Enabled {rule.Enabled} {rule.Direction} {rule.Action} {rule.Profile} Group {Blank(rule.Group)}", Math.Max(5, w - 4))); + canvas.Text(top + 3, x + 2, pendingAction?.Id == rule.Name ? Theme.Warn : Theme.SoftText, pendingAction?.Id == rule.Name ? "Enter confirms action, Esc cancels" : "a enable rule d/x/Delete disable rule c copy name e export CSV r refresh"); + } + else if (rules.Count == 0) + { + canvas.Text(y + 3, x + 2, Theme.Warn, "No firewall rules match the current filter."); + } + } + + private void DrawUsersPage(Canvas canvas, int x, int y, int w, int h) + { + var filterText = string.IsNullOrWhiteSpace(userFilter) ? "" : $" filter:{userFilter}"; + canvas.Box(x, y, w, h, $"local users / groups{filterText}"); + var users = GetVisibleUsers(); + selectedUser = Math.Clamp(selectedUser, 0, Math.Max(0, users.Count - 1)); + var admins = snapshot.LocalUsers.Count(u => u.IsAdmin); + var enabled = snapshot.LocalUsers.Count(u => string.Equals(u.Kind, "User", StringComparison.OrdinalIgnoreCase) && u.Enabled); + var detailRows = h >= 14 ? 4 : 0; + var visibleRows = Math.Max(0, h - 4 - detailRows); + KeepSelectionVisible(selectedUser, visibleRows, ref userScrollOffset); + + canvas.Text(y + 1, x + 2, Theme.Highlight, $"Entries {snapshot.LocalUsers.Count} Enabled users {enabled} Admin entries {admins}"); + canvas.Text(y + 2, x + 2, Theme.Title, " KIND EN ADM LAST LOGON NAME / DETAILS"); + for (var i = 0; i < visibleRows && i + userScrollOffset < users.Count; i++) + { + var item = users[i + userScrollOffset]; + var row = y + 3 + i; + var selected = i + userScrollOffset == selectedUser; + var color = selected ? Theme.Selected : item.IsAdmin ? Theme.Warn : Theme.Muted; + var last = item.LastLogon?.ToString("MM-dd HH:mm") ?? "n/a"; + var detail = string.Equals(item.Kind, "Group", StringComparison.OrdinalIgnoreCase) ? item.Members : item.Description ?? ""; + var line = $"{(selected ? ">" : " ")} {Text.Fit(item.Kind, 7),-7} {(item.Enabled ? "yes" : "no "),-3} {(item.IsAdmin ? "yes" : "no "),-3} {last,-15} {Text.Fit(item.Name + " :: " + detail, Math.Max(8, w - 42))}"; + canvas.Text(row, x + 2, color, Text.Fit(line, w - 4)); + } + + if (detailRows > 0 && users.Count > 0) + { + var item = users[selectedUser]; + var top = y + h - detailRows; + canvas.Text(top, x + 2, Theme.Border, new string('-', Math.Max(1, w - 4))); + canvas.Text(top + 1, x + 2, Theme.Highlight, Text.Fit($"Selected {item.Kind}: {item.Name}", Math.Max(5, w - 4))); + canvas.Text(top + 2, x + 2, Theme.Muted, Text.Fit($"Enabled {item.Enabled} Admin {item.IsAdmin} LastLogon {item.LastLogon?.ToString("yyyy-MM-dd HH:mm:ss") ?? "n/a"} Members: {Blank(item.Members)}", Math.Max(5, w - 4))); + canvas.Text(top + 3, x + 2, Theme.SoftText, "c copy entry e export CSV r refresh"); + } + else if (users.Count == 0) + { + canvas.Text(y + 3, x + 2, Theme.Warn, "No users or groups match the current filter."); + } + } + + private static void KeepSelectionVisible(int selected, int visibleRows, ref int scrollOffset) + { + if (selected < scrollOffset) + { + scrollOffset = selected; + } + else if (selected >= scrollOffset + visibleRows) + { + scrollOffset = selected - visibleRows + 1; + } + scrollOffset = Math.Max(0, scrollOffset); + } + + private void DrawProcesses(Canvas canvas, int x, int y, int w, int h) + { + var filterText = string.IsNullOrWhiteSpace(processFilter) ? "" : $" filter:{processFilter}"; + var treeText = treeMode ? " tree" : ""; + canvas.Box(x, y, w, h, $"processes sort:{sortColumn}{(reverseSort ? " desc" : " asc")}{treeText}{filterText}"); + var sorted = GetVisibleProcesses(); + + selectedProcess = Math.Clamp(selectedProcess, 0, Math.Max(0, sorted.Count - 1)); + var detailRows = h >= 20 ? 6 : h >= 18 ? 4 : 0; + var visibleRows = Math.Max(0, h - 3 - detailRows); + if (selectedProcess < scrollOffset) + { + scrollOffset = selectedProcess; + } + else if (selectedProcess >= scrollOffset + visibleRows) + { + scrollOffset = selectedProcess - visibleRows + 1; + } + + canvas.Text(y + 1, x + 2, Theme.Title, " PID CPU% MEM THR NAME"); + for (var i = 0; i < visibleRows && i + scrollOffset < sorted.Count; i++) + { + var process = sorted[i + scrollOffset]; + var row = y + 2 + i; + var selected = i + scrollOffset == selectedProcess; + var color = selected ? Theme.Selected : (process.CpuPercent > 15 ? Theme.Cpu : Theme.Muted); + var prefix = selected ? ">" : " "; + var nameWidth = Math.Max(8, w - 39); + var depth = GetTreeDepth(process); + var treePrefix = treeMode ? new string(' ', Math.Min(depth * 2, Math.Max(0, nameWidth - 3))) + (depth > 0 ? "└ " : "") : ""; + var line = $"{prefix}{process.Pid,6} {process.CpuPercent,7:0.0} {Units.Size(process.MemoryBytes),10} {process.ThreadCount,5} {Text.Fit(treePrefix + process.Name, nameWidth)}"; + canvas.Text(row, x + 2, color, Text.Fit(line, w - 4)); + } + + if (detailRows > 0 && sorted.Count > 0) + { + var process = sorted[selectedProcess]; + var top = y + h - detailRows; + canvas.Text(top, x + 2, Theme.Border, new string('─', Math.Max(1, w - 4))); + canvas.Text(top + 1, x + 2, Theme.Highlight, $"Selected PID {process.Pid} PPID {process.ParentPid} {process.Name}"); + canvas.Text(top + 1, x + Math.Min(w - 38, 42), process.Responding ? Theme.Muted : Theme.Warn, $"CPU {process.CpuPercent:0.0}% MEM {Units.Size(process.MemoryBytes)} THR {process.ThreadCount} HND {process.HandleCount}"); + var started = process.StartTime is null ? "n/a" : process.StartTime.Value.ToString("yyyy-MM-dd HH:mm:ss"); + canvas.Text(top + 2, x + 2, Theme.Muted, Text.Fit($"Priority {process.Priority} Started {started} Path: {process.Path ?? "n/a"}", Math.Max(5, w - 4))); + var processPending = pendingAction?.Kind == PendingActionKind.KillProcess && pendingAction?.Id == process.Pid.ToString(CultureInfo.InvariantCulture); + if (detailRows >= 6) + { + var graphWidth = Math.Max(10, (w - 24) / 2); + var memMax = Math.Max(0.1, process.MemoryHistory.Count == 0 ? 0.1 : process.MemoryHistory.Max()); + canvas.Text(top + 3, x + 2, Theme.Cpu, Text.Fit($"CPU {Graph.Line(process.CpuHistory, graphWidth, 100)}", Math.Max(5, graphWidth + 5))); + canvas.Text(top + 3, x + graphWidth + 10, Theme.Memory, Text.Fit($"RAM {Graph.Line(process.MemoryHistory, graphWidth, memMax)}", Math.Max(5, graphWidth + 5))); + canvas.Text(top + 4, x + 2, Theme.Muted, $"Network conns {process.NetworkConnectionCount} established {process.EstablishedConnectionCount}"); + canvas.Text(top + 5, x + 2, processPending ? Theme.Warn : Theme.SoftText, processPending ? "Enter confirms action, Esc cancels" : "x kill b kill tree z suspend u resume p AboveNormal n Normal c copy e export"); + } + else + { + canvas.Text(top + 3, x + 2, processPending ? Theme.Warn : Theme.SoftText, processPending ? "Enter confirms kill, Esc cancels" : "x kill b kill tree z suspend u resume p/n priority c copy e export"); + } + } + else if (sorted.Count == 0) + { + canvas.Text(y + 2, x + 2, Theme.Warn, "No processes match the current filter."); + } + } + + private static void DrawHelp(Canvas canvas, int width, int height) + { + var lines = new[] + { + "q / Esc quit", + "h / F1 toggle this help", + "Space pause updates", + "1..9 monitor, USB, services, network, startup, events, drivers, security, tasks", + "F9/F10 firewall rules, local users/groups", + "/ or f edit filter for the current page", + "0 clear current page filter", + "s save current snapshot as JSON", + "+ / - change update interval", + "Up/Down move selection, PageUp/PageDown faster, Home/End first/last", + "", + "Monitor page:", + "Tab cycle process sort column", + "r reverse process sort", + "t toggle process tree", + "p / n set selected process priority AboveNormal / Normal", + "b / z / u kill process tree, suspend process, resume process", + "c copy selected executable path", + "e export visible processes as CSV", + "x / Delete arm kill for selected process", + "", + "USB page:", + "d/x/Delete disable selected USB device", + "a enable selected USB device", + "c copy selected USB InstanceId", + "e export USB devices and drivers as CSV", + "r refresh USB inventory", + "", + "Services page:", + "o/x/Delete stop selected service", + "r restart selected service", + "a start selected service", + "c copy selected service name", + "e export visible services as CSV", + "", + "Network: x kill owner, c copy endpoint, e export, r refresh", + "Startup: c copy command, e export, r refresh", + "Events: c copy event, e export, r refresh", + "Drivers: o/x stop, a start, c copy name, e export, r refresh", + "Security: c copy summary, e export, r refresh", + "Tasks: x start, a enable, d disable, c copy, e export, r refresh", + "Firewall: a enable, d/x disable, c copy, e export, r refresh", + "Users: c copy, e export, r refresh", + "Enter confirm pending action, Esc cancels" + }; + var w = Math.Min(82, width - 8); + var h = Math.Min(lines.Length + 2, height - 4); + var x = (width - w) / 2 + 1; + var y = (height - h) / 2 + 1; + canvas.Box(x, y, w, h, "help"); + var visibleLines = Math.Max(0, h - 2); + for (var i = 0; i < visibleLines && i < lines.Length; i++) + { + canvas.Text(y + 1 + i, x + 2, Theme.Highlight, lines[i]); + } + } + + private static string Meter(double percent, int width) + { + percent = Math.Clamp(percent, 0, 100); + var filled = (int)Math.Round(width * percent / 100); + return new string('█', filled) + new string('░', Math.Max(0, width - filled)); + } +} + +internal sealed class Sampler : IDisposable +{ + private readonly bool synchronousInventory; + private readonly CpuReader cpuReader = new(); + private readonly object inventoryLock = new(); + private readonly Dictionary netCounters = new(); + private readonly Dictionary processCpu = new(); + private readonly Dictionary> processCpuHistory = new(); + private readonly Dictionary> processMemHistory = new(); + private readonly List cpuHistory = new(); + private readonly List memHistory = new(); + private readonly List netHistory = new(); + private IReadOnlyList usbDevices = []; + private IReadOnlyList usbDrivers = []; + private IReadOnlyList services = []; + private IReadOnlyList networkConnections = []; + private IReadOnlyList startupItems = []; + private IReadOnlyList events = []; + private IReadOnlyList drivers = []; + private IReadOnlyList tasks = []; + private IReadOnlyList firewallRules = []; + private IReadOnlyList users = []; + private SecuritySnapshot security = SecuritySnapshot.Empty; + private long totalDownloadedBytes; + private long totalUploadedBytes; + private DateTime lastNetTime = DateTime.UtcNow; + private DateTime lastProcessTime = DateTime.UtcNow; + private DateTime nextInventoryRead = DateTime.MinValue; + private ViewMode activeView = ViewMode.Monitor; + private ViewMode lastInventoryView = ViewMode.Monitor; + private Task? inventoryTask; + private bool disposed; + + public Sampler(bool synchronousInventory = false) + { + this.synchronousInventory = synchronousInventory; + } + + public Snapshot Read() + { + var cpu = cpuReader.Read(); + AddHistory(cpuHistory, cpu.TotalPercent, 240); + + var memory = MemoryReader.Read(); + AddHistory(memHistory, memory.UsedPercent, 240); + memory = memory with { History = cpuHistory.Count == 0 ? [] : [.. memHistory] }; + + var network = ReadNetwork(); + AddHistory(netHistory, network.DownloadBytesPerSec + network.UploadBytesPerSec, 240); + network = network with + { + History = [.. netHistory], + HistoryMax = Math.Max(1, netHistory.Count == 0 ? 1 : netHistory.Max()) + }; + + var system = SystemReader.Read(); + var disks = ReadDisks(); + var processes = ReadProcesses(); + ReadInventoryIfNeeded(); + IReadOnlyList usbDevicesSnapshot; + IReadOnlyList usbDriversSnapshot; + IReadOnlyList servicesSnapshot; + IReadOnlyList networkConnectionsSnapshot; + IReadOnlyList startupItemsSnapshot; + IReadOnlyList eventsSnapshot; + IReadOnlyList driversSnapshot; + IReadOnlyList tasksSnapshot; + IReadOnlyList firewallRulesSnapshot; + IReadOnlyList usersSnapshot; + SecuritySnapshot securitySnapshot; + lock (inventoryLock) + { + usbDevicesSnapshot = usbDevices; + usbDriversSnapshot = usbDrivers; + servicesSnapshot = services; + networkConnectionsSnapshot = networkConnections; + startupItemsSnapshot = startupItems; + eventsSnapshot = events; + driversSnapshot = drivers; + tasksSnapshot = tasks; + firewallRulesSnapshot = firewallRules; + usersSnapshot = users; + securitySnapshot = security; + } + + return new Snapshot( + DateTime.Now, + system, + cpu with { History = [.. cpuHistory] }, + memory, + network, + disks, + processes, + usbDevicesSnapshot, + usbDriversSnapshot, + servicesSnapshot, + networkConnectionsSnapshot, + startupItemsSnapshot, + eventsSnapshot, + driversSnapshot, + tasksSnapshot, + firewallRulesSnapshot, + usersSnapshot, + securitySnapshot); + } + + public void Dispose() + { + disposed = true; + } + + public void RefreshInventory() + { + nextInventoryRead = DateTime.MinValue; + } + + public void SetActiveView(ViewMode mode) + { + if (activeView == mode) + { + return; + } + + activeView = mode; + nextInventoryRead = DateTime.MinValue; + } + + private void ReadInventoryIfNeeded() + { + var now = DateTime.UtcNow; + if (activeView != lastInventoryView) + { + nextInventoryRead = DateTime.MinValue; + } + if (now < nextInventoryRead) + { + return; + } + + lastInventoryView = activeView; + nextInventoryRead = now.AddSeconds(synchronousInventory ? 10 : 90); + if (synchronousInventory) + { + ApplyInventory(ReadInventorySnapshot()); + return; + } + + if (inventoryTask is { IsCompleted: false }) + { + return; + } + + var viewToRead = activeView; + inventoryTask = Task.Run(() => + { + if (!disposed) + { + ReadInventoryForView(viewToRead); + } + }); + } + + private void ReadInventoryForView(ViewMode mode) + { + switch (mode) + { + case ViewMode.Usb: + { + var devices = SafeRead(WindowsInventory.ReadUsbDevices, Array.Empty()); + var usbDriverList = SafeRead(WindowsInventory.ReadUsbDrivers, Array.Empty()); + lock (inventoryLock) + { + usbDevices = devices; + usbDrivers = usbDriverList; + } + break; + } + case ViewMode.Services: + { + var serviceList = SafeRead(WindowsInventory.ReadServices, Array.Empty()); + lock (inventoryLock) + { + services = serviceList; + } + break; + } + case ViewMode.Network: + { + var connectionList = SafeRead(WindowsInventory.ReadNetworkConnections, Array.Empty()); + lock (inventoryLock) + { + networkConnections = connectionList; + } + break; + } + case ViewMode.Startup: + { + var itemList = SafeRead(WindowsInventory.ReadStartupItems, Array.Empty()); + lock (inventoryLock) + { + startupItems = itemList; + } + break; + } + case ViewMode.Events: + { + var eventList = SafeRead(WindowsInventory.ReadEvents, Array.Empty()); + lock (inventoryLock) + { + events = eventList; + } + break; + } + case ViewMode.Drivers: + { + var driverList = SafeRead(WindowsInventory.ReadDrivers, Array.Empty()); + lock (inventoryLock) + { + drivers = driverList; + } + break; + } + case ViewMode.Security: + { + var securitySnapshot = SafeRead(WindowsInventory.ReadSecurity, SecuritySnapshot.Empty); + var eventList = SafeRead(WindowsInventory.ReadEvents, Array.Empty()); + lock (inventoryLock) + { + security = securitySnapshot; + events = eventList; + } + break; + } + case ViewMode.Tasks: + { + var taskList = SafeRead(WindowsInventory.ReadScheduledTasks, Array.Empty()); + lock (inventoryLock) + { + tasks = taskList; + } + break; + } + case ViewMode.Firewall: + { + var ruleList = SafeRead(WindowsInventory.ReadFirewallRules, Array.Empty()); + lock (inventoryLock) + { + firewallRules = ruleList; + } + break; + } + case ViewMode.Users: + { + var userList = SafeRead(WindowsInventory.ReadLocalUsers, Array.Empty()); + lock (inventoryLock) + { + users = userList; + } + break; + } + } + } + + private void ApplyInventory(InventorySnapshot inventory) + { + lock (inventoryLock) + { + usbDevices = inventory.UsbDevices; + usbDrivers = inventory.UsbDrivers; + services = inventory.Services; + networkConnections = inventory.NetworkConnections; + startupItems = inventory.StartupItems; + events = inventory.Events; + drivers = inventory.Drivers; + tasks = inventory.Tasks; + firewallRules = inventory.FirewallRules; + users = inventory.Users; + security = inventory.Security; + } + } + + private static InventorySnapshot ReadInventorySnapshot() + { + return new InventorySnapshot( + SafeRead(WindowsInventory.ReadUsbDevices, Array.Empty()), + SafeRead(WindowsInventory.ReadUsbDrivers, Array.Empty()), + SafeRead(WindowsInventory.ReadServices, Array.Empty()), + SafeRead(WindowsInventory.ReadNetworkConnections, Array.Empty()), + SafeRead(WindowsInventory.ReadStartupItems, Array.Empty()), + SafeRead(WindowsInventory.ReadEvents, Array.Empty()), + SafeRead(WindowsInventory.ReadDrivers, Array.Empty()), + SafeRead(WindowsInventory.ReadScheduledTasks, Array.Empty()), + SafeRead(WindowsInventory.ReadFirewallRules, Array.Empty()), + SafeRead(WindowsInventory.ReadLocalUsers, Array.Empty()), + SafeRead(WindowsInventory.ReadSecurity, SecuritySnapshot.Empty)); + } + + private static T SafeRead(Func read, T fallback) + { + try + { + return read(); + } + catch + { + return fallback; + } + } + + private NetworkSnapshot ReadNetwork() + { + var now = DateTime.UtcNow; + var seconds = Math.Max(0.1, (now - lastNetTime).TotalSeconds); + lastNetTime = now; + + var adapters = new List(); + double totalDown = 0; + double totalUp = 0; + var liveIds = new HashSet(); + + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up || + nic.NetworkInterfaceType == NetworkInterfaceType.Loopback || + nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + { + continue; + } + + IPv4InterfaceStatistics stats; + try + { + stats = nic.GetIPv4Statistics(); + } + catch + { + continue; + } + + liveIds.Add(nic.Id); + var current = new NetCounter(stats.BytesReceived, stats.BytesSent); + if (!netCounters.TryGetValue(nic.Id, out var previous)) + { + netCounters[nic.Id] = current; + continue; + } + + var receivedDelta = Math.Max(0, current.Received - previous.Received); + var sentDelta = Math.Max(0, current.Sent - previous.Sent); + var down = receivedDelta / seconds; + var up = sentDelta / seconds; + netCounters[nic.Id] = current; + + totalDown += down; + totalUp += up; + totalDownloadedBytes += receivedDelta; + totalUploadedBytes += sentDelta; + adapters.Add(new AdapterSnapshot(nic.Name, down, up)); + } + + foreach (var id in netCounters.Keys.Where(id => !liveIds.Contains(id)).ToList()) + { + netCounters.Remove(id); + } + + return new NetworkSnapshot(totalDown, totalUp, totalDownloadedBytes, totalUploadedBytes, adapters.OrderByDescending(a => a.DownloadBytesPerSec + a.UploadBytesPerSec).ToList(), [], 1); + } + + private static List ReadDisks() + { + var disks = new List(); + foreach (var drive in DriveInfo.GetDrives()) + { + if (!drive.IsReady) + { + continue; + } + + try + { + var used = drive.TotalSize - drive.AvailableFreeSpace; + var usedPercent = drive.TotalSize <= 0 ? 0 : used * 100.0 / drive.TotalSize; + disks.Add(new DiskSnapshot(drive.Name.TrimEnd('\\'), drive.DriveFormat, drive.TotalSize, used, drive.AvailableFreeSpace, usedPercent)); + } + catch + { + // Some virtual drives can disappear while being queried. + } + } + + return disks.OrderByDescending(d => d.UsedPercent).ToList(); + } + + private List ReadProcesses() + { + var now = DateTime.UtcNow; + var seconds = Math.Max(0.1, (now - lastProcessTime).TotalSeconds); + lastProcessTime = now; + var processorCount = Math.Max(1, Environment.ProcessorCount); + var result = new List(); + var livePids = new HashSet(); + var parentPids = NativeMethods.ReadParentProcessIds(); + var connectionCounts = networkConnections + .GroupBy(c => c.ProcessId) + .ToDictionary(group => group.Key, group => group.Count()); + var establishedCounts = networkConnections + .Where(c => string.Equals(c.State, "Established", StringComparison.OrdinalIgnoreCase)) + .GroupBy(c => c.ProcessId) + .ToDictionary(group => group.Key, group => group.Count()); + + foreach (var process in Process.GetProcesses()) + { + using (process) + { + try + { + var pid = process.Id; + var total = process.TotalProcessorTime; + var memory = process.WorkingSet64; + var name = process.ProcessName; + var threadCount = 0; + var handleCount = 0; + var responding = true; + var priority = "n/a"; + DateTime? startTime = null; + string? path = null; + try + { + threadCount = process.Threads.Count; + } + catch + { + // Access can be denied for protected processes. + } + try + { + handleCount = process.HandleCount; + responding = process.Responding; + priority = process.PriorityClass.ToString(); + startTime = process.StartTime; + } + catch + { + // Process details are best effort. + } + try + { + path = process.MainModule?.FileName; + } + catch + { + // Access can be denied for protected processes. + } + livePids.Add(pid); + + var cpu = 0.0; + if (processCpu.TryGetValue(pid, out var previous)) + { + cpu = Math.Max(0, (total - previous).TotalSeconds / seconds * 100.0 / processorCount); + } + processCpu[pid] = total; + if (!processCpuHistory.TryGetValue(pid, out var cpuHistoryForProcess)) + { + cpuHistoryForProcess = []; + processCpuHistory[pid] = cpuHistoryForProcess; + } + if (!processMemHistory.TryGetValue(pid, out var memHistoryForProcess)) + { + memHistoryForProcess = []; + processMemHistory[pid] = memHistoryForProcess; + } + AddHistory(cpuHistoryForProcess, cpu, 90); + AddHistory(memHistoryForProcess, memory / Units.Gib, 90); + + result.Add(new ProcessSnapshot(pid, parentPids.GetValueOrDefault(pid), name, cpu, memory, threadCount, handleCount, priority, responding, startTime, path, connectionCounts.GetValueOrDefault(pid), establishedCounts.GetValueOrDefault(pid), [.. cpuHistoryForProcess], [.. memHistoryForProcess])); + } + catch + { + // Protected or short-lived processes can fail between enumeration and read. + } + } + } + + foreach (var pid in processCpu.Keys.Where(pid => !livePids.Contains(pid)).ToList()) + { + processCpu.Remove(pid); + processCpuHistory.Remove(pid); + processMemHistory.Remove(pid); + } + + return result; + } + + private static void AddHistory(List values, double value, int max) + { + values.Add(value); + if (values.Count > max) + { + values.RemoveRange(0, values.Count - max); + } + } + + private readonly record struct NetCounter(long Received, long Sent); + private readonly record struct InventorySnapshot( + IReadOnlyList UsbDevices, + IReadOnlyList UsbDrivers, + IReadOnlyList Services, + IReadOnlyList NetworkConnections, + IReadOnlyList StartupItems, + IReadOnlyList Events, + IReadOnlyList Drivers, + IReadOnlyList Tasks, + IReadOnlyList FirewallRules, + IReadOnlyList Users, + SecuritySnapshot Security); +} + +internal sealed class CpuReader +{ + private ProcessorTimes[]? previous; + + public CpuSnapshot Read() + { + var current = NativeMethods.ReadProcessorTimes(); + if (previous is null || previous.Length != current.Length) + { + previous = current; + return new CpuSnapshot(0, new double[current.Length], []); + } + + var percents = new double[current.Length]; + long totalDelta = 0; + long busyDelta = 0; + + for (var i = 0; i < current.Length; i++) + { + var total = current[i].Total - previous[i].Total; + var idle = current[i].Idle - previous[i].Idle; + var busy = Math.Max(0, total - idle); + percents[i] = total <= 0 ? 0 : Math.Clamp(busy * 100.0 / total, 0, 100); + totalDelta += total; + busyDelta += busy; + } + + previous = current; + var totalPercent = totalDelta <= 0 ? 0 : Math.Clamp(busyDelta * 100.0 / totalDelta, 0, 100); + return new CpuSnapshot(totalPercent, percents, []); + } +} + +internal static class SystemReader +{ + public static SystemSnapshot Read() + { + int? batteryPercent = null; + bool? onAcPower = null; + var status = new NativeMethods.SystemPowerStatus(); + if (NativeMethods.GetSystemPowerStatus(out status)) + { + if (status.BatteryLifePercent <= 100) + { + batteryPercent = status.BatteryLifePercent; + } + if (status.AcLineStatus <= 1) + { + onAcPower = status.AcLineStatus == 1; + } + } + + return new SystemSnapshot( + Environment.MachineName, + RuntimeInformation.OSDescription.Trim(), + TimeSpan.FromMilliseconds(Environment.TickCount64), + batteryPercent, + onAcPower); + } +} + +internal static class MemoryReader +{ + public static MemorySnapshot Read() + { + var status = new NativeMethods.MemoryStatusEx(); + status.Length = (uint)Marshal.SizeOf(); + if (!NativeMethods.GlobalMemoryStatusEx(ref status)) + { + return new MemorySnapshot(0, 0, 0, 0, 0, 0, []); + } + + var used = status.TotalPhys - status.AvailPhys; + var pageFileUsed = status.TotalPageFile - status.AvailPageFile; + var percent = status.TotalPhys == 0 ? 0 : used * 100.0 / status.TotalPhys; + return new MemorySnapshot(status.TotalPhys, used, status.AvailPhys, status.TotalPageFile, pageFileUsed, percent, []); + } +} + +internal static class WindowsInventory +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public static IReadOnlyList ReadUsbDevices() + { + var script = """ + $ErrorActionPreference = 'Stop' + $all = @(Get-PnpDevice | Where-Object { $_.InstanceId -like 'USB*' -or $_.Class -eq 'USB' -or $_.Class -eq 'USBDevice' }) + $presentIds = @(Get-PnpDevice -PresentOnly | Where-Object { $_.InstanceId -like 'USB*' -or $_.Class -eq 'USB' -or $_.Class -eq 'USBDevice' } | ForEach-Object { $_.InstanceId }) + $present = @{} + foreach ($id in $presentIds) { $present[$id] = $true } + @($all | Sort-Object Class,FriendlyName | Select-Object ` + @{Name='Name';Expression={ if ($_.FriendlyName) { $_.FriendlyName } else { $_.InstanceId } }}, ` + @{Name='Status';Expression={ [string]$_.Status }}, ` + @{Name='Class';Expression={ [string]$_.Class }}, ` + @{Name='InstanceId';Expression={ [string]$_.InstanceId }}, ` + @{Name='Present';Expression={ $present.ContainsKey($_.InstanceId) }}) | ConvertTo-Json -Compress -Depth 3 + """; + return DeserializeList(RunPowerShell(script)) + .Select(d => new UsbDeviceSnapshot(d.Name ?? "n/a", d.Status ?? "n/a", d.Class ?? "n/a", d.InstanceId ?? "", d.Present)) + .Where(d => !string.IsNullOrWhiteSpace(d.InstanceId)) + .ToList(); + } + + public static IReadOnlyList ReadUsbDrivers() + { + var script = """ + $ErrorActionPreference = 'Stop' + @(Get-CimInstance Win32_PnPSignedDriver | + Where-Object { $_.DeviceID -like 'USB*' -or $_.DeviceClass -eq 'USB' -or $_.DeviceClass -eq 'USBDevice' } | + Sort-Object DeviceName | + Select-Object ` + @{Name='DeviceName';Expression={ if ($_.DeviceName) { $_.DeviceName } else { $_.DeviceID } }}, ` + @{Name='Provider';Expression={ [string]$_.DriverProviderName }}, ` + @{Name='Version';Expression={ [string]$_.DriverVersion }}, ` + @{Name='InfName';Expression={ [string]$_.InfName }}, ` + @{Name='DeviceClass';Expression={ [string]$_.DeviceClass }}, ` + @{Name='DeviceId';Expression={ [string]$_.DeviceID }}) | ConvertTo-Json -Compress -Depth 3 + """; + return DeserializeList(RunPowerShell(script)) + .Select(d => new UsbDriverSnapshot(d.DeviceName ?? "n/a", d.Provider ?? "n/a", d.Version ?? "n/a", d.InfName ?? "n/a", d.DeviceClass ?? "n/a", d.DeviceId ?? "")) + .ToList(); + } + + public static IReadOnlyList ReadServices() + { + var script = """ + $ErrorActionPreference = 'Stop' + $cim = @{} + foreach ($svc in Get-CimInstance Win32_Service) { $cim[$svc.Name] = $svc } + @(Get-Service | + Sort-Object Name | + Select-Object ` + @{Name='Name';Expression={ [string]$_.Name }}, + @{Name='DisplayName';Expression={ [string]$_.DisplayName }}, + @{Name='State';Expression={ [string]$_.Status }}, + @{Name='StartMode';Expression={ if ($cim.ContainsKey($_.Name)) { [string]$cim[$_.Name].StartMode } else { '' } }}, + @{Name='AcceptStop';Expression={ [bool]$_.CanStop }}, + @{Name='ProcessId';Expression={ if ($cim.ContainsKey($_.Name)) { [int]$cim[$_.Name].ProcessId } else { 0 } }}, + @{Name='DependsOn';Expression={ [string](($_.ServicesDependedOn | ForEach-Object Name) -join ';') }}, + @{Name='DependedOnBy';Expression={ [string](($_.DependentServices | ForEach-Object Name) -join ';') }}, + @{Name='Path';Expression={ if ($cim.ContainsKey($_.Name)) { [string]$cim[$_.Name].PathName } else { '' } }}) | ConvertTo-Json -Compress -Depth 3 + """; + return DeserializeList(RunPowerShell(script)) + .Select(s => new ServiceSnapshot(s.Name ?? "", s.DisplayName ?? s.Name ?? "n/a", s.State ?? "n/a", s.StartMode ?? "n/a", s.AcceptStop, s.ProcessId, s.DependsOn ?? "", s.DependedOnBy ?? "", s.Path)) + .Where(s => !string.IsNullOrWhiteSpace(s.Name)) + .ToList(); + } + + public static IReadOnlyList ReadNetworkConnections() + { + var script = """ + $ErrorActionPreference = 'SilentlyContinue' + $rows = @() + foreach ($c in @(Get-NetTCPConnection)) { + $p = Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue + $rows += [pscustomobject]@{ + Protocol='TCP' + State=[string]$c.State + LocalEndpoint=('{0}:{1}' -f $c.LocalAddress,$c.LocalPort) + RemoteEndpoint=('{0}:{1}' -f $c.RemoteAddress,$c.RemotePort) + ProcessId=[int]$c.OwningProcess + ProcessName= if ($p) { [string]$p.ProcessName } else { '' } + } + } + foreach ($u in @(Get-NetUDPEndpoint)) { + $p = Get-Process -Id $u.OwningProcess -ErrorAction SilentlyContinue + $rows += [pscustomobject]@{ + Protocol='UDP' + State='Listen' + LocalEndpoint=('{0}:{1}' -f $u.LocalAddress,$u.LocalPort) + RemoteEndpoint='*:*' + ProcessId=[int]$u.OwningProcess + ProcessName= if ($p) { [string]$p.ProcessName } else { '' } + } + } + @($rows | Sort-Object Protocol,State,LocalEndpoint) | ConvertTo-Json -Compress -Depth 3 + """; + return DeserializeList(RunPowerShell(script, 20_000)) + .Select(c => new NetworkConnectionSnapshot(c.Protocol ?? "", c.State ?? "", c.LocalEndpoint ?? "", c.RemoteEndpoint ?? "", c.ProcessId, string.IsNullOrWhiteSpace(c.ProcessName) ? "n/a" : c.ProcessName)) + .ToList(); + } + + public static IReadOnlyList ReadStartupItems() + { + var script = """ + $ErrorActionPreference = 'SilentlyContinue' + $rows = @() + foreach ($item in @(Get-CimInstance Win32_StartupCommand)) { + $rows += [pscustomobject]@{ Name=[string]$item.Name; Source='StartupCommand'; Location=[string]$item.Location; Command=[string]$item.Command } + } + foreach ($task in @(Get-ScheduledTask | Where-Object { $_.Triggers -and ($_.Triggers | Where-Object { $_.CimClass.CimClassName -match 'Logon|Boot|Startup' }) })) { + $rows += [pscustomobject]@{ Name=[string]$task.TaskName; Source='ScheduledTask'; Location=[string]$task.TaskPath; Command=[string](($task.Actions | ForEach-Object { ($_.Execute + ' ' + $_.Arguments).Trim() }) -join '; ') } + } + @($rows | Sort-Object Source,Name) | ConvertTo-Json -Compress -Depth 3 + """; + return DeserializeList(RunPowerShell(script, 20_000)) + .Select(i => new StartupItemSnapshot(i.Name ?? "n/a", i.Source ?? "n/a", i.Location ?? "", i.Command ?? "")) + .ToList(); + } + + public static IReadOnlyList ReadEvents() + { + var script = """ + $ErrorActionPreference = 'SilentlyContinue' + $start = (Get-Date).AddHours(-24) + @(Get-WinEvent -FilterHashtable @{LogName=@('System','Application'); Level=@(2,3); StartTime=$start} -MaxEvents 80 | + Select-Object ` + @{Name='TimeCreated';Expression={ if ($_.TimeCreated) { $_.TimeCreated.ToString('o') } else { $null } }}, + @{Name='LogName';Expression={[string]$_.LogName}}, + @{Name='Level';Expression={[string]$_.LevelDisplayName}}, + @{Name='Provider';Expression={[string]$_.ProviderName}}, + @{Name='Id';Expression={[int]$_.Id}}, + @{Name='Message';Expression={([string]$_.Message).Replace("`r",' ').Replace("`n",' ')}}) | ConvertTo-Json -Compress -Depth 3 + """; + return DeserializeList(RunPowerShell(script, 20_000)) + .Select(e => new EventSnapshot(e.TimeCreated, e.LogName ?? "", e.Level ?? "", e.Provider ?? "", e.Id, e.Message ?? "")) + .ToList(); + } + + public static IReadOnlyList ReadDrivers() + { + var script = """ + $ErrorActionPreference = 'Stop' + @(Get-CimInstance Win32_SystemDriver | + Sort-Object Name | + Select-Object ` + @{Name='Name';Expression={[string]$_.Name}}, + @{Name='DisplayName';Expression={ if ($_.DisplayName) { [string]$_.DisplayName } else { [string]$_.Name } }}, + @{Name='State';Expression={[string]$_.State}}, + @{Name='StartMode';Expression={[string]$_.StartMode}}, + @{Name='Path';Expression={[string]$_.PathName}}) | ConvertTo-Json -Compress -Depth 3 + """; + return DeserializeList(RunPowerShell(script, 20_000)) + .Select(d => new DriverSnapshot(d.Name ?? "", d.DisplayName ?? d.Name ?? "n/a", d.State ?? "n/a", d.StartMode ?? "n/a", d.Path)) + .Where(d => !string.IsNullOrWhiteSpace(d.Name)) + .ToList(); + } + + public static SecuritySnapshot ReadSecurity() + { + var script = """ + $ErrorActionPreference = 'SilentlyContinue' + $mp = Get-MpComputerStatus + $fw = @{} + foreach ($profile in @(Get-NetFirewallProfile)) { $fw[$profile.Name] = [string]$profile.Enabled } + $hotfix = Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First 1 + $pending = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' + $gpus = @(Get-CimInstance Win32_VideoController | ForEach-Object { '{0} ({1})' -f $_.Name,$_.DriverVersion }) + $thermal = @(Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature | ForEach-Object { '{0:0.0} C' -f (($_.CurrentTemperature / 10) - 273.15) }) + [pscustomobject]@{ + DefenderRealTime= if ($mp) { [string]$mp.RealTimeProtectionEnabled } else { 'n/a' } + DefenderAntivirus= if ($mp) { [string]$mp.AntivirusEnabled } else { 'n/a' } + DefenderSignatureAgeDays= if ($mp) { [int]$mp.AntispywareSignatureAge } else { -1 } + FirewallDomain= if ($fw.ContainsKey('Domain')) { $fw['Domain'] } else { 'n/a' } + FirewallPrivate= if ($fw.ContainsKey('Private')) { $fw['Private'] } else { 'n/a' } + FirewallPublic= if ($fw.ContainsKey('Public')) { $fw['Public'] } else { 'n/a' } + LatestHotFix= if ($hotfix) { '{0} {1}' -f $hotfix.HotFixID,$hotfix.InstalledOn.ToShortDateString() } else { 'n/a' } + PendingReboot=[bool]$pending + GpuSummary= if ($gpus.Count) { [string]($gpus -join '; ') } else { 'n/a' } + ThermalSummary= if ($thermal.Count) { [string]($thermal -join '; ') } else { 'n/a' } + } | ConvertTo-Json -Compress -Depth 3 + """; + var dto = DeserializeList(RunPowerShell(script, 25_000)).FirstOrDefault(); + return dto is null + ? SecuritySnapshot.Empty + : new SecuritySnapshot(dto.DefenderRealTime ?? "n/a", dto.DefenderAntivirus ?? "n/a", dto.DefenderSignatureAgeDays, dto.FirewallDomain ?? "n/a", dto.FirewallPrivate ?? "n/a", dto.FirewallPublic ?? "n/a", dto.LatestHotFix ?? "n/a", dto.PendingReboot, dto.GpuSummary ?? "n/a", dto.ThermalSummary ?? "n/a"); + } + + public static IReadOnlyList ReadScheduledTasks() + { + var script = """ + $ErrorActionPreference = 'SilentlyContinue' + @(Get-ScheduledTask | ForEach-Object { + $info = Get-ScheduledTaskInfo -TaskName $_.TaskName -TaskPath $_.TaskPath -ErrorAction SilentlyContinue + [pscustomobject]@{ + Id=($_.TaskPath + '|' + $_.TaskName) + TaskPath=[string]$_.TaskPath + TaskName=[string]$_.TaskName + State=[string]$_.State + Enabled=([string]$_.State -ne 'Disabled') + LastRunTime= if ($info -and $info.LastRunTime) { $info.LastRunTime.ToString('o') } else { $null } + NextRunTime= if ($info -and $info.NextRunTime) { $info.NextRunTime.ToString('o') } else { $null } + Action=[string](($_.Actions | ForEach-Object { ($_.Execute + ' ' + $_.Arguments).Trim() }) -join '; ') + } + }) | ConvertTo-Json -Compress -Depth 3 + """; + return DeserializeList(RunPowerShell(script, 30_000)) + .Select(t => new ScheduledTaskSnapshot(t.Id ?? $"{t.TaskPath}|{t.TaskName}", t.TaskPath ?? "", t.TaskName ?? "", t.State ?? "n/a", t.Enabled, t.LastRunTime, t.NextRunTime, t.Action ?? "")) + .ToList(); + } + + public static IReadOnlyList ReadFirewallRules() + { + var script = """ + $ErrorActionPreference = 'SilentlyContinue' + @(Get-NetFirewallRule | + Select-Object ` + @{Name='Name';Expression={[string]$_.Name}}, + @{Name='DisplayName';Expression={[string]$_.DisplayName}}, + @{Name='Enabled';Expression={$_.Enabled -eq 'True'}}, + @{Name='Direction';Expression={[string]$_.Direction}}, + @{Name='Action';Expression={[string]$_.Action}}, + @{Name='Profile';Expression={[string]$_.Profile}}, + @{Name='Group';Expression={[string]$_.Group}}) | ConvertTo-Json -Compress -Depth 3 + """; + return DeserializeList(RunPowerShell(script, 30_000)) + .Select(r => new FirewallRuleSnapshot(r.Name ?? "", string.IsNullOrWhiteSpace(r.DisplayName) ? r.Name ?? "n/a" : r.DisplayName, r.Enabled, r.Direction ?? "", r.Action ?? "", r.Profile ?? "", r.Group ?? "")) + .Where(r => !string.IsNullOrWhiteSpace(r.Name)) + .ToList(); + } + + public static IReadOnlyList ReadLocalUsers() + { + var script = """ + $ErrorActionPreference = 'SilentlyContinue' + $adminNames = @{} + foreach ($m in @(Get-LocalGroupMember -Group 'Administrators')) { $adminNames[$m.Name] = $true; $adminNames[($m.Name -split '\\')[-1]] = $true } + $rows = @() + foreach ($u in @(Get-LocalUser)) { + $rows += [pscustomobject]@{ + Kind='User' + Name=[string]$u.Name + Enabled=[bool]$u.Enabled + IsAdmin=($adminNames.ContainsKey($u.Name)) + LastLogon= if ($u.LastLogon) { $u.LastLogon.ToString('o') } else { $null } + Description=[string]$u.Description + Members='' + } + } + foreach ($g in @(Get-LocalGroup)) { + $members = @(Get-LocalGroupMember -Group $g.Name -ErrorAction SilentlyContinue | ForEach-Object Name) + $rows += [pscustomobject]@{ + Kind='Group' + Name=[string]$g.Name + Enabled=$true + IsAdmin=($g.Name -eq 'Administrators') + LastLogon=$null + Description=[string]$g.Description + Members=[string]($members -join '; ') + } + } + @($rows | Sort-Object Kind,Name) | ConvertTo-Json -Compress -Depth 3 + """; + return DeserializeList(RunPowerShell(script, 20_000)) + .Select(u => new LocalUserSnapshot(u.Kind ?? "", u.Name ?? "", u.Enabled, u.IsAdmin, u.LastLogon, u.Description, u.Members ?? "")) + .Where(u => !string.IsNullOrWhiteSpace(u.Name)) + .ToList(); + } + + internal static string RunPowerShell(string script, int timeoutMs = 12_000) + { + using var process = Process.Start(new ProcessStartInfo("powershell.exe") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }.WithArguments("-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)); + if (process is null) + { + throw new InvalidOperationException("powershell.exe could not be started"); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(timeoutMs)) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Best effort cleanup. + } + throw new InvalidOperationException("PowerShell command timed out"); + } + + Task.WaitAll(stdoutTask, stderrTask); + var stdout = stdoutTask.Result; + var stderr = stderrTask.Result; + if (process.ExitCode != 0) + { + throw new InvalidOperationException(LimitMessage(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr)); + } + + return stdout; + } + + private static List DeserializeList(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + var trimmed = json.Trim(); + if (trimmed == "null") + { + return []; + } + + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + return JsonSerializer.Deserialize>(trimmed, JsonOptions) ?? []; + } + + var single = JsonSerializer.Deserialize(trimmed, JsonOptions); + return single is null ? [] : [single]; + } + + private static string LimitMessage(string value) + { + value = value.ReplaceLineEndings(" ").Trim(); + return value.Length <= 180 ? value : value[..180] + "..."; + } + + private sealed class UsbDeviceDto + { + public string? Name { get; set; } + public string? Status { get; set; } + public string? Class { get; set; } + public string? InstanceId { get; set; } + public bool Present { get; set; } + } + + private sealed class UsbDriverDto + { + public string? DeviceName { get; set; } + public string? Provider { get; set; } + public string? Version { get; set; } + public string? InfName { get; set; } + public string? DeviceClass { get; set; } + public string? DeviceId { get; set; } + } + + private sealed class ServiceDto + { + public string? Name { get; set; } + public string? DisplayName { get; set; } + public string? State { get; set; } + public string? StartMode { get; set; } + public bool AcceptStop { get; set; } + public int ProcessId { get; set; } + public string? DependsOn { get; set; } + public string? DependedOnBy { get; set; } + public string? Path { get; set; } + } + + private sealed class NetworkConnectionDto + { + public string? Protocol { get; set; } + public string? State { get; set; } + public string? LocalEndpoint { get; set; } + public string? RemoteEndpoint { get; set; } + public int ProcessId { get; set; } + public string? ProcessName { get; set; } + } + + private sealed class StartupItemDto + { + public string? Name { get; set; } + public string? Source { get; set; } + public string? Location { get; set; } + public string? Command { get; set; } + } + + private sealed class EventDto + { + public DateTime? TimeCreated { get; set; } + public string? LogName { get; set; } + public string? Level { get; set; } + public string? Provider { get; set; } + public int Id { get; set; } + public string? Message { get; set; } + } + + private sealed class DriverDto + { + public string? Name { get; set; } + public string? DisplayName { get; set; } + public string? State { get; set; } + public string? StartMode { get; set; } + public string? Path { get; set; } + } + + private sealed class SecurityDto + { + public string? DefenderRealTime { get; set; } + public string? DefenderAntivirus { get; set; } + public int DefenderSignatureAgeDays { get; set; } + public string? FirewallDomain { get; set; } + public string? FirewallPrivate { get; set; } + public string? FirewallPublic { get; set; } + public string? LatestHotFix { get; set; } + public bool PendingReboot { get; set; } + public string? GpuSummary { get; set; } + public string? ThermalSummary { get; set; } + } + + private sealed class ScheduledTaskDto + { + public string? Id { get; set; } + public string? TaskPath { get; set; } + public string? TaskName { get; set; } + public string? State { get; set; } + public bool Enabled { get; set; } + public DateTime? LastRunTime { get; set; } + public DateTime? NextRunTime { get; set; } + public string? Action { get; set; } + } + + private sealed class FirewallRuleDto + { + public string? Name { get; set; } + public string? DisplayName { get; set; } + public bool Enabled { get; set; } + public string? Direction { get; set; } + public string? Action { get; set; } + public string? Profile { get; set; } + public string? Group { get; set; } + } + + private sealed class LocalUserDto + { + public string? Kind { get; set; } + public string? Name { get; set; } + public bool Enabled { get; set; } + public bool IsAdmin { get; set; } + public DateTime? LastLogon { get; set; } + public string? Description { get; set; } + public string? Members { get; set; } + } +} + +internal static class WindowsActions +{ + public static void DisablePnpDevice(string instanceId) + { + WindowsInventory.RunPowerShell($"$ErrorActionPreference='Stop'; Disable-PnpDevice -InstanceId {PsQuote(instanceId)} -Confirm:$false | Out-Null", 20_000); + } + + public static void EnablePnpDevice(string instanceId) + { + WindowsInventory.RunPowerShell($"$ErrorActionPreference='Stop'; Enable-PnpDevice -InstanceId {PsQuote(instanceId)} -Confirm:$false | Out-Null", 20_000); + } + + public static void StopService(string serviceName) + { + WindowsInventory.RunPowerShell($"$ErrorActionPreference='Stop'; Stop-Service -Name {PsQuote(serviceName)} -Force -ErrorAction Stop", 20_000); + } + + public static void StartService(string serviceName) + { + WindowsInventory.RunPowerShell($"$ErrorActionPreference='Stop'; Start-Service -Name {PsQuote(serviceName)} -ErrorAction Stop", 20_000); + } + + public static void RestartService(string serviceName) + { + WindowsInventory.RunPowerShell($"$ErrorActionPreference='Stop'; Restart-Service -Name {PsQuote(serviceName)} -Force -ErrorAction Stop", 30_000); + } + + public static void SetProcessPriority(int pid, string priority) + { + using var process = Process.GetProcessById(pid); + process.PriorityClass = Enum.Parse(priority, ignoreCase: true); + } + + public static void KillProcessTree(int pid) + { + using var process = Process.GetProcessById(pid); + process.Kill(entireProcessTree: true); + } + + public static void SuspendProcess(int pid) + { + NativeMethods.SuspendProcess(pid); + } + + public static void ResumeProcess(int pid) + { + NativeMethods.ResumeProcess(pid); + } + + public static void EnableScheduledTask(string id) + { + var (path, name) = SplitTaskId(id); + WindowsInventory.RunPowerShell($"$ErrorActionPreference='Stop'; Enable-ScheduledTask -TaskPath {PsQuote(path)} -TaskName {PsQuote(name)} | Out-Null", 20_000); + } + + public static void DisableScheduledTask(string id) + { + var (path, name) = SplitTaskId(id); + WindowsInventory.RunPowerShell($"$ErrorActionPreference='Stop'; Disable-ScheduledTask -TaskPath {PsQuote(path)} -TaskName {PsQuote(name)} | Out-Null", 20_000); + } + + public static void StartScheduledTask(string id) + { + var (path, name) = SplitTaskId(id); + WindowsInventory.RunPowerShell($"$ErrorActionPreference='Stop'; Start-ScheduledTask -TaskPath {PsQuote(path)} -TaskName {PsQuote(name)}", 20_000); + } + + public static void SetFirewallRule(string name, bool enabled) + { + WindowsInventory.RunPowerShell($"$ErrorActionPreference='Stop'; Set-NetFirewallRule -Name {PsQuote(name)} -Enabled {(enabled ? "True" : "False")}", 20_000); + } + + private static string PsQuote(string value) => "'" + value.Replace("'", "''") + "'"; + + private static (string Path, string Name) SplitTaskId(string id) + { + var index = id.IndexOf('|', StringComparison.Ordinal); + return index < 0 ? ("\\", id) : (id[..index], id[(index + 1)..]); + } +} + +internal static class ProcessStartInfoExtensions +{ + public static ProcessStartInfo WithArguments(this ProcessStartInfo startInfo, params string[] arguments) + { + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + return startInfo; + } +} + +internal static class NativeMethods +{ + private const int SystemProcessorPerformanceInformation = 8; + private const uint ProcessSuspendResume = 0x0800; + + [DllImport("ntdll.dll")] + private static extern int NtQuerySystemInformation(int systemInformationClass, IntPtr systemInformation, int systemInformationLength, out int returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern bool GlobalMemoryStatusEx(ref MemoryStatusEx buffer); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern bool GetSystemPowerStatus(out SystemPowerStatus powerStatus); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool Process32FirstW(IntPtr snapshot, ref ProcessEntry32 entry); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool Process32NextW(IntPtr snapshot, ref ProcessEntry32 entry); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, int processId); + + [DllImport("ntdll.dll")] + private static extern int NtSuspendProcess(IntPtr processHandle); + + [DllImport("ntdll.dll")] + private static extern int NtResumeProcess(IntPtr processHandle); + + public static void SuspendProcess(int pid) + { + WithProcessHandle(pid, handle => + { + var status = NtSuspendProcess(handle); + if (status != 0) + { + throw new InvalidOperationException($"NtSuspendProcess failed with status 0x{status:X}"); + } + }); + } + + public static void ResumeProcess(int pid) + { + WithProcessHandle(pid, handle => + { + var status = NtResumeProcess(handle); + if (status != 0) + { + throw new InvalidOperationException($"NtResumeProcess failed with status 0x{status:X}"); + } + }); + } + + private static void WithProcessHandle(int pid, Action action) + { + var handle = OpenProcess(ProcessSuspendResume, inheritHandle: false, pid); + if (handle == IntPtr.Zero) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + } + + try + { + action(handle); + } + finally + { + CloseHandle(handle); + } + } + + public static Dictionary ReadParentProcessIds() + { + const uint snapshotProcesses = 0x00000002; + var handle = CreateToolhelp32Snapshot(snapshotProcesses, 0); + if (handle == new IntPtr(-1)) + { + return []; + } + + try + { + var result = new Dictionary(); + var entry = new ProcessEntry32 { Size = (uint)Marshal.SizeOf() }; + if (!Process32FirstW(handle, ref entry)) + { + return result; + } + + do + { + result[(int)entry.ProcessId] = (int)entry.ParentProcessId; + entry.Size = (uint)Marshal.SizeOf(); + } + while (Process32NextW(handle, ref entry)); + return result; + } + finally + { + CloseHandle(handle); + } + } + + public static ProcessorTimes[] ReadProcessorTimes() + { + var count = Math.Max(1, Environment.ProcessorCount); + var size = Marshal.SizeOf(); + var length = size * count; + var ptr = Marshal.AllocHGlobal(length); + try + { + var status = NtQuerySystemInformation(SystemProcessorPerformanceInformation, ptr, length, out var returned); + if (status != 0 || returned <= 0) + { + return new ProcessorTimes[count]; + } + + var actualCount = Math.Max(1, Math.Min(count, returned / size)); + var values = new ProcessorTimes[actualCount]; + for (var i = 0; i < actualCount; i++) + { + var item = Marshal.PtrToStructure(ptr + i * size); + values[i] = new ProcessorTimes(item.IdleTime, item.KernelTime + item.UserTime, item.KernelTime + item.UserTime - item.IdleTime); + } + + return values; + } + finally + { + Marshal.FreeHGlobal(ptr); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessorPerformanceInformation + { + public long IdleTime; + public long KernelTime; + public long UserTime; + public long DpcTime; + public long InterruptTime; + public uint InterruptCount; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + internal struct MemoryStatusEx + { + public uint Length; + public uint MemoryLoad; + public ulong TotalPhys; + public ulong AvailPhys; + public ulong TotalPageFile; + public ulong AvailPageFile; + public ulong TotalVirtual; + public ulong AvailVirtual; + public ulong AvailExtendedVirtual; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct SystemPowerStatus + { + public byte AcLineStatus; + public byte BatteryFlag; + public byte BatteryLifePercent; + public byte SystemStatusFlag; + public int BatteryLifeTime; + public int BatteryFullLifeTime; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct ProcessEntry32 + { + public uint Size; + public uint Usage; + public uint ProcessId; + public IntPtr DefaultHeapId; + public uint ModuleId; + public uint Threads; + public uint ParentProcessId; + public int PriorityBase; + public uint Flags; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string ExecutableFile; + } +} + +internal readonly record struct ProcessorTimes(long Idle, long Total, long Busy); +internal enum ViewMode +{ + Monitor, + Usb, + Services, + Network, + Startup, + Events, + Drivers, + Security, + Tasks, + Firewall, + Users +} + +internal enum PendingActionKind +{ + KillProcess, + DisableDevice, + EnableDevice, + StopService, + RestartService, + StartService, + SetProcessPriority, + KillProcessTree, + SuspendProcess, + ResumeProcess, + EnableTask, + DisableTask, + StartTask, + EnableFirewallRule, + DisableFirewallRule +} + +internal readonly record struct PendingAction(PendingActionKind Kind, string Id, string Name); +internal enum LogLevel +{ + Info, + Warn, + Error +} + +internal readonly record struct LogEntry(DateTime Time, LogLevel Level, string Message); +internal readonly record struct HealthScore(int Score, string Label); +internal readonly record struct SystemSnapshot(string MachineName, string OsDescription, TimeSpan Uptime, int? BatteryPercent, bool? OnAcPower); +internal readonly record struct CpuSnapshot(double TotalPercent, double[] CorePercents, IReadOnlyList History); +internal readonly record struct MemorySnapshot(ulong TotalBytes, ulong UsedBytes, ulong AvailableBytes, ulong PageFileTotalBytes, ulong PageFileUsedBytes, double UsedPercent, IReadOnlyList History); +internal readonly record struct AdapterSnapshot(string Name, double DownloadBytesPerSec, double UploadBytesPerSec); +internal readonly record struct NetworkSnapshot(double DownloadBytesPerSec, double UploadBytesPerSec, long TotalDownloadedBytes, long TotalUploadedBytes, IReadOnlyList Adapters, IReadOnlyList History, double HistoryMax); +internal readonly record struct DiskSnapshot(string Name, string Format, long TotalBytes, long UsedBytes, long FreeBytes, double UsedPercent); +internal readonly record struct ProcessSnapshot(int Pid, int ParentPid, string Name, double CpuPercent, long MemoryBytes, int ThreadCount, int HandleCount, string Priority, bool Responding, DateTime? StartTime, string? Path, int NetworkConnectionCount, int EstablishedConnectionCount, IReadOnlyList CpuHistory, IReadOnlyList MemoryHistory); +internal readonly record struct UsbDeviceSnapshot(string Name, string Status, string Class, string InstanceId, bool Present); +internal readonly record struct UsbDriverSnapshot(string DeviceName, string Provider, string Version, string InfName, string DeviceClass, string DeviceId); +internal readonly record struct ServiceSnapshot(string Name, string DisplayName, string State, string StartMode, bool AcceptStop, int ProcessId, string DependsOn, string DependedOnBy, string? Path); +internal readonly record struct NetworkConnectionSnapshot(string Protocol, string State, string LocalEndpoint, string RemoteEndpoint, int ProcessId, string ProcessName); +internal readonly record struct StartupItemSnapshot(string Name, string Source, string Location, string Command); +internal readonly record struct EventSnapshot(DateTime? TimeCreated, string LogName, string Level, string Provider, int Id, string Message); +internal readonly record struct DriverSnapshot(string Name, string DisplayName, string State, string StartMode, string? Path); +internal readonly record struct ScheduledTaskSnapshot(string Id, string TaskPath, string TaskName, string State, bool Enabled, DateTime? LastRunTime, DateTime? NextRunTime, string Action); +internal readonly record struct FirewallRuleSnapshot(string Name, string DisplayName, bool Enabled, string Direction, string Action, string Profile, string Group); +internal readonly record struct LocalUserSnapshot(string Kind, string Name, bool Enabled, bool IsAdmin, DateTime? LastLogon, string? Description, string Members); +internal readonly record struct SecuritySnapshot(string DefenderRealTime, string DefenderAntivirus, int DefenderSignatureAgeDays, string FirewallDomain, string FirewallPrivate, string FirewallPublic, string LatestHotFix, bool PendingReboot, string GpuSummary, string ThermalSummary) +{ + public static SecuritySnapshot Empty { get; } = new("n/a", "n/a", -1, "n/a", "n/a", "n/a", "n/a", false, "n/a", "n/a"); + + public string ToDisplayText() => $"Defender RT {DefenderRealTime}; AV {DefenderAntivirus}; FW D/P/P {FirewallDomain}/{FirewallPrivate}/{FirewallPublic}; LatestHotFix {LatestHotFix}; PendingReboot {PendingReboot}; GPU {GpuSummary}; Thermal {ThermalSummary}"; +} +internal readonly record struct Snapshot(DateTime Time, SystemSnapshot System, CpuSnapshot Cpu, MemorySnapshot Memory, NetworkSnapshot Network, IReadOnlyList Disks, IReadOnlyList Processes, IReadOnlyList UsbDevices, IReadOnlyList UsbDrivers, IReadOnlyList Services, IReadOnlyList NetworkConnections, IReadOnlyList StartupItems, IReadOnlyList Events, IReadOnlyList Drivers, IReadOnlyList ScheduledTasks, IReadOnlyList FirewallRules, IReadOnlyList LocalUsers, SecuritySnapshot Security) +{ + public static Snapshot Empty { get; } = new( + DateTime.Now, + new SystemSnapshot(Environment.MachineName, RuntimeInformation.OSDescription.Trim(), TimeSpan.Zero, null, null), + new CpuSnapshot(0, [], []), + new MemorySnapshot(0, 0, 0, 0, 0, 0, []), + new NetworkSnapshot(0, 0, 0, 0, [], [], 1), + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + SecuritySnapshot.Empty); +} + +internal sealed class Canvas +{ + private readonly int width; + private readonly int height; + private readonly Cell[,] cells; + + public Canvas(int width, int height) + { + this.width = width; + this.height = height; + cells = new Cell[height, width]; + for (var row = 0; row < height; row++) + { + for (var col = 0; col < width; col++) + { + cells[row, col] = new Cell(' ', Theme.Reset); + } + } + } + + public void Box(int x, int y, int w, int h, string title) + { + if (w < 2 || h < 2) + { + return; + } + + var left = x - 1; + var top = y - 1; + var right = Math.Min(width - 1, left + w - 1); + var bottom = Math.Min(height - 1, top + h - 1); + var color = Theme.Border; + + Set(top, left, '╭', color); + Set(top, right, '╮', color); + Set(bottom, left, '╰', color); + Set(bottom, right, '╯', color); + for (var col = left + 1; col < right; col++) + { + Set(top, col, '─', color); + Set(bottom, col, '─', color); + } + for (var row = top + 1; row < bottom; row++) + { + Set(row, left, '│', color); + Set(row, right, '│', color); + } + + Text(y, x + 2, Theme.Title, $" {title} "); + } + + public void Text(int y, int x, string color, string text) + { + var row = y - 1; + var col = x - 1; + if (row < 0 || row >= height || col >= width) + { + return; + } + + foreach (var ch in text) + { + if (col >= width) + { + break; + } + if (col >= 0) + { + Set(row, col, ch, color); + } + col++; + } + } + + public string Render() + { + var sb = new StringBuilder(width * height + height * 24); + sb.Append("\x1b[H"); + string? currentColor = null; + for (var row = 0; row < height; row++) + { + for (var col = 0; col < width; col++) + { + var cell = cells[row, col]; + if (cell.Color != currentColor) + { + sb.Append(cell.Color); + currentColor = cell.Color; + } + sb.Append(cell.Ch); + } + if (row < height - 1) + { + sb.Append('\n'); + } + } + sb.Append(Theme.Reset); + return sb.ToString(); + } + + private void Set(int row, int col, char ch, string color) + { + if (row < 0 || row >= height || col < 0 || col >= width) + { + return; + } + cells[row, col] = new Cell(ch, color); + } + + private readonly record struct Cell(char Ch, string Color); +} + +internal static class Graph +{ + private static readonly char[] Blocks = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + public static string Line(IReadOnlyList values, int width, double max) + { + if (width <= 0) + { + return string.Empty; + } + if (values.Count == 0) + { + return new string(' ', width); + } + + max = Math.Max(1, max); + var start = Math.Max(0, values.Count - width); + var sb = new StringBuilder(width); + for (var i = start; i < values.Count; i++) + { + var level = (int)Math.Round(Math.Clamp(values[i] / max, 0, 1) * (Blocks.Length - 1)); + sb.Append(Blocks[level]); + } + if (sb.Length < width) + { + sb.Insert(0, new string(' ', width - sb.Length)); + } + return sb.ToString(); + } +} + +internal static class Text +{ + public static string Fit(string value, int width) + { + if (width <= 0) + { + return string.Empty; + } + return value.Length <= width ? value : value[..Math.Max(0, width - 1)] + "…"; + } +} + +internal static class Units +{ + public const double Gib = 1024.0 * 1024.0 * 1024.0; + + public static string Size(long bytes) + { + return Size((double)bytes); + } + + public static string Size(ulong bytes) + { + return Size((double)bytes); + } + + public static string Rate(double bytesPerSecond) + { + return $"{Size(bytesPerSecond)}/s"; + } + + public static string Duration(TimeSpan value) + { + if (value.TotalDays >= 1) + { + return $"{(int)value.TotalDays}d {value.Hours:00}h {value.Minutes:00}m"; + } + return $"{value.Hours:00}h {value.Minutes:00}m {value.Seconds:00}s"; + } + + private static string Size(double bytes) + { + string[] units = ["B", "KiB", "MiB", "GiB", "TiB"]; + var value = bytes; + var unit = 0; + while (value >= 1024 && unit < units.Length - 1) + { + value /= 1024; + unit++; + } + return $"{value:0.0} {units[unit]}"; + } +} + +internal static class Color +{ + public static string Percent(double percent) + { + return percent switch + { + >= 90 => Theme.Error, + >= 70 => Theme.Warn, + >= 40 => Theme.Highlight, + _ => Theme.Ok + }; + } +} + +internal static class Theme +{ + public const string Reset = "\x1b[0m"; + public const string Bg = "\x1b[48;2;14;18;28m"; + public const string TransBlue = "\x1b[38;2;91;206;250m"; + public const string TransPink = "\x1b[38;2;245;169;184m"; + public const string TransWhite = "\x1b[38;2;255;255;255m"; + public const string Border = "\x1b[38;2;91;206;250m"; + public const string Title = "\x1b[1;38;2;245;169;184m"; + public const string Highlight = "\x1b[38;2;91;206;250m"; + public const string SoftText = "\x1b[38;2;205;219;236m"; + public const string Muted = "\x1b[38;2;255;255;255m"; + public const string Cpu = "\x1b[38;2;245;169;184m"; + public const string Memory = "\x1b[38;2;255;255;255m"; + public const string Net = "\x1b[38;2;91;206;250m"; + public const string Ok = "\x1b[38;2;147;232;178m"; + public const string Warn = "\x1b[38;2;255;219;128m"; + public const string Error = "\x1b[38;2;255;118;150m"; + public const string Selected = "\x1b[1;38;2;255;255;255m\x1b[48;2;76;48;72m"; +} + +internal static class Terminal +{ + private const int StdOutputHandle = -11; + private const uint EnableVirtualTerminalProcessing = 0x0004; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); + + public static void EnableVirtualTerminal() + { + var handle = GetStdHandle(StdOutputHandle); + if (handle == IntPtr.Zero || !GetConsoleMode(handle, out var mode)) + { + return; + } + SetConsoleMode(handle, mode | EnableVirtualTerminalProcessing); + } + + public static void Write(string value) + { + Console.Write(value); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b252ff9 --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# MandyTop + +A Windows Terminal resource monitor inspired by btop, styled with a transgender color palette. + +## Features + +- Live CPU graph with per-logical-CPU meters +- Memory graph and live network throughput graph +- Disk usage table with used/free/total values +- System panel with OS, machine name, uptime, AC/battery status +- Sortable process list with CPU, memory, threads, PID and executable path details +- Optional parent/child process tree with parent PID, handle count, priority and start time +- Process filtering and guarded process kill confirmation +- JSON snapshot export from the live view +- CSV export of the current filtered process list +- Copy the selected executable path to the Windows clipboard +- Session network totals and memory commit/available statistics +- Dedicated USB page for connected/installed USB devices and installed USB drivers +- Guarded USB device enable/disable actions through Windows PnP +- Dedicated services page with service state, start mode, PID and path details +- Guarded service start, stop and restart actions +- Service dependency details for selected services +- Network page with active TCP/UDP endpoints and owning processes +- Startup page with registry startup commands and logon/boot scheduled tasks +- Eventlog page with recent Windows errors and warnings +- System drivers page with guarded driver start/stop actions +- Security page for Defender, firewall, latest hotfix, reboot status, GPU and thermal data +- Process priority changes for the selected process +- Live action/error/export log pinned to the bottom of the terminal +- Health score from CPU, RAM, disk pressure, eventlog, Defender, firewall and pending reboot state +- Task Scheduler page with guarded task enable/disable/start actions +- Firewall rules page with guarded rule enable/disable actions +- Local users/groups page with administrator membership and last logon +- Per-process CPU/RAM mini-graphs in the selected process detail panel +- Process tree kill, process suspend and process resume actions +- Per-process network connection counts in process details +- MandyTop start splash in transgender colors +- MandyTop ASCII art in transgender colors on wide terminals +- Fast live loop: heavy Windows inventories are cached, loaded in the background, and refreshed per page instead of blocking CPU/RAM/process updates +- Scriptable `--json`, `--once`, `--version` and `--help` modes + +## Run + +```powershell +dotnet run --project .\MandyTop.csproj +``` + +Or build a release executable: + +```powershell +dotnet publish .\MandyTop.csproj -c Release -o .\publish +.\publish\MandyTop.exe +``` + +If Windows blocks loading the separate `MandyTop.dll`, build the single-file release: + +```powershell +dotnet publish .\MandyTop.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -o .\publish-v0.7-single +.\publish-v0.7-single\MandyTop.exe +``` + +A tested single-file build is included at: + +```powershell +.\release\MandyTop.exe +``` + +## Keys + +- `q` / `Esc`: quit +- `h` / `F1`: help +- `Space`: pause +- `1`: monitor/process page +- `2`: USB devices/drivers page +- `3`: services page +- `4`: network connections page +- `5`: startup entries page +- `6`: eventlog page +- `7`: system drivers page +- `8`: security/updates/firewall/hardware page +- `9`: Task Scheduler page +- `F9`: firewall rules page +- `F10`: local users/groups page +- `/` / `f`: edit the current page filter +- `0`: clear the current page filter +- `s`: save current snapshot as JSON +- `+` / `-`: change update interval +- `Up` / `Down`: move selection +- `PageUp` / `PageDown`: move faster +- `Home` / `End`: select first/last item + +### Monitor Page + +- `Tab`: cycle process sort column +- `r`: reverse process sort +- `t`: toggle the parent/child process tree +- `Backspace`: edit filter text while filtering +- `p`: arm priority change to `AboveNormal` +- `n`: arm priority change to `Normal` +- `b`: arm kill for the selected process tree +- `z`: arm suspend for the selected process +- `u`: arm resume for the selected process +- `c`: copy the selected executable path +- `e`: export visible processes as CSV +- `x` / `Delete`: arm kill for selected process +- `Enter`: confirm a pending kill + +### USB Page + +- `d` / `x` / `Delete`: arm disable for selected USB device +- `a`: arm enable for selected USB device +- `Enter`: confirm a pending USB action +- `c`: copy the selected USB InstanceId +- `e`: export visible USB devices and installed USB drivers as CSV +- `r`: refresh USB inventory + +### Services Page + +- `o` / `x` / `Delete`: arm stop for selected service +- `r`: arm restart for selected service +- `a`: arm start for selected service +- `Enter`: confirm a pending service action +- `c`: copy the selected service name +- `e`: export visible services as CSV +- `Tab`: refresh service inventory + +### Network Page + +- `x` / `Delete`: arm kill for the owning process +- `c`: copy selected connection details +- `e`: export visible connections as CSV +- `r`: refresh network inventory + +### Startup Page + +- `c`: copy selected startup command +- `e`: export visible startup entries as CSV +- `r`: refresh startup inventory + +### Eventlog Page + +- `c`: copy selected event +- `e`: export visible events as CSV +- `r`: refresh event inventory + +### Drivers Page + +- `o` / `x` / `Delete`: arm stop for selected driver +- `a`: arm start for selected driver +- `c`: copy selected driver name +- `e`: export visible drivers as CSV +- `r`: refresh driver inventory + +### Security Page + +- `c`: copy security summary +- `e`: export security summary as CSV +- `r`: refresh security inventory + +### Task Scheduler Page + +- `x`: arm start for selected scheduled task +- `a`: arm enable for selected scheduled task +- `d` / `Delete`: arm disable for selected scheduled task +- `c`: copy selected task path/name +- `e`: export visible tasks as CSV +- `r`: refresh task inventory + +### Firewall Page + +- `a`: arm enable for selected firewall rule +- `d` / `x` / `Delete`: arm disable for selected firewall rule +- `c`: copy selected rule name +- `e`: export visible firewall rules as CSV +- `r`: refresh firewall inventory + +### Users / Groups Page + +- `c`: copy selected user/group details +- `e`: export visible users/groups as CSV +- `r`: refresh users/groups inventory + +USB disable/enable, process priority changes, process suspend/resume, driver start/stop, scheduled task changes, firewall changes and many service actions require an elevated Windows Terminal. If Windows denies the operation, MandyTop keeps running and shows the error in the status bar and live log. + +Per-process network traffic byte rates are not exposed by the built-in Windows APIs used here without a heavier ETW collector. MandyTop currently aggregates network ownership by process connection counts and established connection counts. + +Performance note: the live UI loads expensive Windows inventories lazily. Pages such as Tasks, Firewall, Users, Drivers and Eventlog may take a moment to populate the first time you open them, but CPU/RAM/process rendering keeps updating while that happens. `--once` and `--json` still collect a full diagnostic snapshot synchronously. + +## Test Snapshot + +```powershell +dotnet run --project .\winbtop\winbtop.csproj -- --once +dotnet run --project .\winbtop\winbtop.csproj -- --json +``` diff --git a/release/MandyTop.exe b/release/MandyTop.exe new file mode 100644 index 0000000..b175ba2 Binary files /dev/null and b/release/MandyTop.exe differ