From 621a5869450c305110aabc3d1c79fd953d819a56 Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Mon, 17 Aug 2026 09:07:08 +0200 Subject: [PATCH] + One field catalogue instead of three lists that had already drifted apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a column meant editing a sort-key enum in Core, a ColumnSet in the window and a third list in the terminal. Three places to add a field is three places to forget one, and the terminal was already a field behind: it had never grown the sixteen columns the window gained. FieldRegistry is now the single declaration. Each field states its stable key, both headers, a description, its kind and unit, which platforms can fill it, what reading it costs, its width in pixels and in cells, its alignment and its sort direction. FieldAccessor reads it three ways from that one declaration — as text, as a number, and as an ordering — and everything else consumes those: the window renders through it, the terminal renders through it, and ProcessView sorts through it. Which means sorting by a column can no longer disagree with what that column displays, and a value reads identically in both front-ends because it is literally the same code producing it. The golden terminal frame is unchanged byte for byte, which is the evidence that the rewrite kept the rendering intact rather than the claim that it did. Every field key became a --sort key for free: --sort=private.ws and --sort=faults.delta both work, and neither was ever written down as one. The 39th field cannot now be added and silently forgotten: EveryFieldInTheEnumIsRegistered fails the build when an enum value has no descriptor. PRD §103 asked for that check and said, honestly, that nothing enforced it. Half of it is enforced now — the collector-side steps. The GUI/TUI/CLI/export steps are still on whoever adds the field. Also caught in passing: in C# 14 "field" is a keyword inside a property accessor, so a foreach over "field" in one binds the backing field instead of the loop variable. 157 tests, 25 self-test checks, budgets met. --- ProcessManager.App/CommandLine.cs | 4 +- ProcessManager.Benchmarks/Program.cs | 2 +- ProcessManager.Core/Query/FieldAccessor.cs | 208 ++++++++++++++ ProcessManager.Core/Query/FieldRegistry.cs | 236 ++++++++++++++++ ProcessManager.Core/Query/ProcessColumn.cs | 88 ------ ProcessManager.Core/Query/ProcessField.cs | 190 +++++++++++++ ProcessManager.Core/Query/ProcessView.cs | 47 +--- ProcessManager.Tests/FieldRegistryTests.cs | 265 ++++++++++++++++++ ProcessManager.Tests/LinuxProbeTests.cs | 2 +- .../ProcessTreeBinderTests.cs | 8 +- ProcessManager.Tests/ProcessViewTests.cs | 16 +- ProcessManager.Tests/SparklineTests.cs | 2 +- ProcessManager.Tests/TerminalUiTests.cs | 4 +- ProcessManager.Ui.Desktop/ColumnChooser.cs | 17 +- ProcessManager.Ui.Desktop/ColumnSet.cs | 150 ++-------- ProcessManager.Ui.Desktop/MainWindow.cs | 35 +-- ProcessManager.Ui.Desktop/ProcessRow.cs | 171 ++++------- ProcessManager.Ui.Terminal/Layout.cs | 64 ++--- ProcessManager.Ui.Terminal/TerminalUi.cs | 75 ++--- docs/PRD.md | 45 +-- 20 files changed, 1135 insertions(+), 494 deletions(-) create mode 100644 ProcessManager.Core/Query/FieldAccessor.cs create mode 100644 ProcessManager.Core/Query/FieldRegistry.cs delete mode 100644 ProcessManager.Core/Query/ProcessColumn.cs create mode 100644 ProcessManager.Core/Query/ProcessField.cs create mode 100644 ProcessManager.Tests/FieldRegistryTests.cs diff --git a/ProcessManager.App/CommandLine.cs b/ProcessManager.App/CommandLine.cs index 729c615..1d35087 100644 --- a/ProcessManager.App/CommandLine.cs +++ b/ProcessManager.App/CommandLine.cs @@ -16,7 +16,7 @@ internal enum RunMode : byte { Desktop, Terminal, List, Find, Kill, SelfTest, He internal sealed record CommandLineOptions { public RunMode Mode { get; init; } = RunMode.Desktop; - public ProcessColumn SortColumn { get; init; } = ProcessColumn.CpuPercent; + public ProcessField SortColumn { get; init; } = ProcessField.CpuPercent; public bool SortDescending { get; init; } = true; public bool TreeMode { get; init; } @@ -118,7 +118,7 @@ public static CommandLineOptions Parse(string[] args) { case "--sort": { if (!TryValue(args, ref i, inlineValue, out var column)) return options with { Error = "--sort needs a column" }; - if (!ProcessColumnExtensions.TryParse(column, out var parsed)) + if (!FieldRegistry.TryParse(column, out var parsed)) return options with { Error = $"unknown sort column '{column}'" }; options = options with { SortColumn = parsed, SortDescending = parsed.PrefersDescending() }; diff --git a/ProcessManager.Benchmarks/Program.cs b/ProcessManager.Benchmarks/Program.cs index 7d676a0..90bad2a 100644 --- a/ProcessManager.Benchmarks/Program.cs +++ b/ProcessManager.Benchmarks/Program.cs @@ -128,7 +128,7 @@ private static int Main(string[] args) { ); // The view is rebuilt once per sample by both front-ends, so its cost is part of the frame. - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.CpuPercent }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.CpuPercent }; view.Rebuild(sampler.Current, sampler.Delta); var viewStart = Stopwatch.GetTimestamp(); for (var i = 0; i < 50; ++i) diff --git a/ProcessManager.Core/Query/FieldAccessor.cs b/ProcessManager.Core/Query/FieldAccessor.cs new file mode 100644 index 0000000..56430ff --- /dev/null +++ b/ProcessManager.Core/Query/FieldAccessor.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using Hawkynt.ProcessManager.Model; +using Hawkynt.ProcessManager.Sampling; + +namespace Hawkynt.ProcessManager.Query; + +/// +/// Reads one field out of a process: as text to display, as a number to compare, and as an ordering. +/// +/// +/// The single place any field is turned into anything. Both front-ends render through +/// , the view sorts through , and the filter compares through +/// — so a value reads the same in the window and in the terminal, and sorting by +/// a column can never disagree with what that column shows (PRD §5.1). +/// +public static class FieldAccessor { + + /// + /// What the field shows, including the reason when it shows no value (PRD §72.3). + /// + /// + /// May be before a second sample exists, in which case every derived field + /// reads as "not sampled yet" rather than as zero. + /// + public static string Text(ProcessField field, in ProcessRecord process, SnapshotDelta? delta, int index) { + switch (field) { + case ProcessField.Name: return process.Name; + case ProcessField.Pid: return process.Pid.ToString(CultureInfo.InvariantCulture); + case ProcessField.PidHex: return "0x" + process.Pid.ToString("X", CultureInfo.InvariantCulture); + case ProcessField.ParentPid: + return process.ParentPid > 0 ? process.ParentPid.ToString(CultureInfo.InvariantCulture) : "—"; + case ProcessField.UserName: + return process.UserName ?? Humanize.Placeholder(UnknownReason.NotPermitted); + case ProcessField.State: return Humanize.State(process.State); + + case ProcessField.CpuPercent: return Humanize.Percent(Rated(delta, index, field)); + case ProcessField.CpuPercentPerCore: return Humanize.Percent(Rated(delta, index, field)); + case ProcessField.CpuTime: return Humanize.Duration(process.CpuTimeNs); + case ProcessField.CyclesDelta: return Humanize.Rate(Rated(delta, index, field)); + case ProcessField.ContextSwitchesDelta: return Humanize.Rate(Rated(delta, index, field)); + case ProcessField.PageFaultsDelta: return Humanize.Rate(Rated(delta, index, field)); + + case ProcessField.PrivateBytes: return Humanize.Bytes(process.PrivateBytes); + case ProcessField.PrivateBytesDelta: return Humanize.SignedBytesPerSecond(Rated(delta, index, field)); + case ProcessField.PrivateWorkingSet: return Humanize.Bytes(process.PrivateWorkingSetBytes); + case ProcessField.WorkingSetBytes: return Humanize.Bytes(process.WorkingSetBytes); + case ProcessField.PeakWorkingSet: return Humanize.Bytes(process.PeakWorkingSetBytes); + case ProcessField.VirtualBytes: return Humanize.Bytes(process.VirtualBytes); + case ProcessField.PeakVirtualBytes: return Humanize.Bytes(process.PeakVirtualBytes); + case ProcessField.PagedPool: return Humanize.Bytes(process.PagedPoolBytes); + case ProcessField.PeakPagedPool: return Humanize.Bytes(process.PeakPagedPoolBytes); + case ProcessField.NonPagedPool: return Humanize.Bytes(process.NonPagedPoolBytes); + case ProcessField.PeakNonPagedPool: return Humanize.Bytes(process.PeakNonPagedPoolBytes); + case ProcessField.Swap: return Humanize.Bytes(process.SwapBytes); + + case ProcessField.IoTotalRate: + case ProcessField.ReadBytesPerSecond: + case ProcessField.WriteBytesPerSecond: + return Humanize.BytesPerSecond(Rated(delta, index, field)); + + case ProcessField.ThreadCount: return process.ThreadCount.ToString(CultureInfo.InvariantCulture); + case ProcessField.HandleCount: return Humanize.Count(process.HandleCount); + case ProcessField.Priority: return process.Priority.ToString(CultureInfo.InvariantCulture); + case ProcessField.SessionId: + return process.SessionId >= 0 ? process.SessionId.ToString(CultureInfo.InvariantCulture) : "—"; + case ProcessField.StartTime: + return process.StartTimeUtcTicks > 0 + ? new DateTime(process.StartTimeUtcTicks, DateTimeKind.Utc).ToLocalTime() + .ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + : "—"; + case ProcessField.Container: return process.ContainerPath ?? "—"; + case ProcessField.ImagePath: return process.ImagePath ?? "—"; + case ProcessField.CommandLine: return process.CommandLine ?? string.Empty; + + // The graphs are drawn, not written. Asking for their text is a caller bug, but returning + // empty is friendlier than throwing in a render loop. + case ProcessField.CpuHistory: + case ProcessField.MemoryHistory: + case ProcessField.IoHistory: + default: + return string.Empty; + } + } + + /// + /// The field as a plain number, for filtering and for sorting. + /// + /// + /// when the field has no number at all — either because it is text, or + /// because this platform does not report it. A filter must treat those two the same way: a process + /// whose value is unknown does not match > 0, and it does not match == 0 either. + /// + public static double? Number(ProcessField field, in ProcessRecord process, SnapshotDelta? delta, int index) { + switch (field) { + case ProcessField.Pid: + case ProcessField.PidHex: return process.Pid; + case ProcessField.ParentPid: return process.ParentPid; + case ProcessField.State: return (byte)process.State; + case ProcessField.ThreadCount: return process.ThreadCount; + case ProcessField.Priority: return process.Priority; + case ProcessField.SessionId: return process.SessionId; + case ProcessField.StartTime: return process.StartTimeUtcTicks; + + case ProcessField.CpuTime: return Number(process.CpuTimeNs); + case ProcessField.PrivateBytes: return Number(process.PrivateBytes); + case ProcessField.PrivateWorkingSet: return Number(process.PrivateWorkingSetBytes); + case ProcessField.WorkingSetBytes: return Number(process.WorkingSetBytes); + case ProcessField.PeakWorkingSet: return Number(process.PeakWorkingSetBytes); + case ProcessField.VirtualBytes: return Number(process.VirtualBytes); + case ProcessField.PeakVirtualBytes: return Number(process.PeakVirtualBytes); + case ProcessField.PagedPool: return Number(process.PagedPoolBytes); + case ProcessField.PeakPagedPool: return Number(process.PeakPagedPoolBytes); + case ProcessField.NonPagedPool: return Number(process.NonPagedPoolBytes); + case ProcessField.PeakNonPagedPool: return Number(process.PeakNonPagedPoolBytes); + case ProcessField.Swap: return Number(process.SwapBytes); + case ProcessField.HandleCount: return Number(process.HandleCount); + + case ProcessField.CpuPercent: + case ProcessField.CpuPercentPerCore: + case ProcessField.CyclesDelta: + case ProcessField.ContextSwitchesDelta: + case ProcessField.PageFaultsDelta: + case ProcessField.PrivateBytesDelta: + case ProcessField.IoTotalRate: + case ProcessField.ReadBytesPerSecond: + case ProcessField.WriteBytesPerSecond: { + var rate = Rated(delta, index, field); + return rate.HasValue ? rate.Value : null; + } + + default: return null; + } + } + + /// The field as raw text, for substring and regular-expression filtering. + /// + /// Deliberately not : a filter must match what the value is, not how it + /// was abbreviated for a column. Searching for a path should not fail because the column showed + /// an em dash, and searching "1024" should not match a cell that reads "1.0K". + /// + public static string? RawText(ProcessField field, in ProcessRecord process) => field switch { + ProcessField.Name => process.Name, + ProcessField.UserName => process.UserName, + ProcessField.ImagePath => process.ImagePath, + ProcessField.CommandLine => process.CommandLine, + ProcessField.Container => process.ContainerPath, + ProcessField.State => Humanize.State(process.State), + ProcessField.Pid => process.Pid.ToString(CultureInfo.InvariantCulture), + ProcessField.ParentPid => process.ParentPid.ToString(CultureInfo.InvariantCulture), + _ => null, + }; + + /// + /// Orders two rows by one field. Text compares case-insensitively; numbers compare numerically; + /// a value that is unknown sorts below every known one, whichever direction is chosen. + /// + public static int Compare( + ProcessField field, + in ProcessRecord a, + int indexA, + in ProcessRecord b, + int indexB, + SnapshotDelta? delta + ) { + switch (field) { + case ProcessField.Name: + return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); + case ProcessField.UserName: + return string.Compare(a.UserName, b.UserName, StringComparison.OrdinalIgnoreCase); + case ProcessField.CommandLine: + return string.Compare(a.CommandLine, b.CommandLine, StringComparison.OrdinalIgnoreCase); + case ProcessField.ImagePath: + return string.Compare(a.ImagePath, b.ImagePath, StringComparison.OrdinalIgnoreCase); + case ProcessField.Container: + return string.Compare(a.ContainerPath, b.ContainerPath, StringComparison.OrdinalIgnoreCase); + } + + var left = Number(field, in a, delta, indexA); + var right = Number(field, in b, delta, indexB); + if (left is null) + return right is null ? 0 : -1; + if (right is null) + return 1; + + return left.Value.CompareTo(right.Value); + } + + private static double? Number(Counter counter) => counter.HasValue ? counter.Value : null; + + private static Rate Rated(SnapshotDelta? delta, int index, ProcessField field) { + if (delta is null) + return Rate.NotSampledYet; + + return field switch { + ProcessField.CpuPercent => delta.CpuPercent(index), + ProcessField.CpuPercentPerCore => delta.CpuPercentPerCore(index), + ProcessField.CyclesDelta => delta.CyclesPerSecond(index), + ProcessField.ContextSwitchesDelta => delta.ContextSwitchesPerSecond(index), + ProcessField.PageFaultsDelta => delta.PageFaultsPerSecond(index), + ProcessField.PrivateBytesDelta => delta.PrivateBytesDelta(index), + ProcessField.IoTotalRate => delta.IoTotalBytesPerSecond(index), + ProcessField.ReadBytesPerSecond => delta.ReadBytesPerSecond(index), + ProcessField.WriteBytesPerSecond => delta.WriteBytesPerSecond(index), + _ => Rate.NotSampledYet, + }; + } + +} diff --git a/ProcessManager.Core/Query/FieldRegistry.cs b/ProcessManager.Core/Query/FieldRegistry.cs new file mode 100644 index 0000000..ee2809b --- /dev/null +++ b/ProcessManager.Core/Query/FieldRegistry.cs @@ -0,0 +1,236 @@ +using Hawkynt.ProcessManager.Sampling; + +namespace Hawkynt.ProcessManager.Query; + +/// +/// The canonical field catalogue: the one place a field is declared, and the thing both front-ends, +/// the CLI and the filter are built from (PRD §5.1). +/// +/// +/// A static array rather than anything reflective, so it survives trimming and NativeAOT intact +/// (PRD §8.3). Adding a field here gives it a header, a width, a sort order, a formatter and a filter +/// term in every front-end at once — which is the whole reason it exists. +/// +public static class FieldRegistry { + + private const FieldPlatforms _WINDOWS = FieldPlatforms.Windows; + private const FieldPlatforms _LINUX = FieldPlatforms.Linux; + private const FieldPlatforms _POSIX = FieldPlatforms.Linux | FieldPlatforms.MacOS; + private const FieldPlatforms _ALL = FieldPlatforms.All; + + /// Every field, in default column order. + public static readonly FieldDescriptor[] All = [ + new(ProcessField.Name, "name", "Process", "Process", + "The short name: comm on Linux, the image file name on Windows.", + FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 260, 120, false, false, + Aliases: "process comm"), + new(ProcessField.Pid, "pid", "PID", "PID", + "The process identifier.", + FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 70, 7, true, false), + new(ProcessField.PidHex, "pid.hex", "PID (hex)", "PIDx", + "The same identifier in hexadecimal, which is how a debugger will show it.", + FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 78, 9, true, false), + new(ProcessField.ParentPid, "ppid", "Parent PID", "PPID", + "The parent's identifier, or none when the parent has exited.", + FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 88, 7, true, false, + Aliases: "parent"), + new(ProcessField.UserName, "user", "User", "User", + "The account the process runs as.", + FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 130, 10, false, false, + Aliases: "owner username"), + new(ProcessField.State, "state", "State", "S", + "What the scheduler thinks of the process right now.", + FieldKind.State, FieldUnit.None, _ALL, FieldCost.Free, 62, 5, false, false, + Aliases: "status"), + + new(ProcessField.CpuPercent, "cpu", "CPU %", "CPU%", + "Processor use where 100% is the whole machine.", + FieldKind.Rate, FieldUnit.Percent, _ALL, FieldCost.Derived, 78, 5, true, true, + Aliases: "cpu.percent"), + new(ProcessField.CpuPercentPerCore, "cpu.raw", "CPU % (per core)", "CPU%c", + "Processor use where 100% is one core, the way top reports it.", + FieldKind.Rate, FieldUnit.Percent, _ALL, FieldCost.Derived, 118, 6, true, true, + Aliases: "cpu.percore"), + new(ProcessField.CpuTime, "cpu.time", "CPU time", "Time", + "Total processor time consumed since the process started.", + FieldKind.Cumulative, FieldUnit.Nanoseconds, _ALL, FieldCost.Free, 88, 9, true, true), + new(ProcessField.CyclesDelta, "cpu.cycles.delta", "Cycles delta", "Cyc/s", + "Processor cycles this interval. Unlike CPU time it does not flatter a process that ran while the clock was throttled.", + FieldKind.Rate, FieldUnit.CountPerSecond, _WINDOWS, FieldCost.Derived, 100, 8, true, true), + new(ProcessField.ContextSwitchesDelta, "ctx.delta", "Ctx switch delta", "Ctx/s", + "Context switches this interval.", + FieldKind.Rate, FieldUnit.CountPerSecond, _POSIX, FieldCost.Derived, 116, 8, true, true), + new(ProcessField.CpuHistory, "cpu.history", "CPU history", "CPU hist", + "The last sixty seconds of processor use.", + FieldKind.Graph, FieldUnit.Percent, _ALL, FieldCost.Derived, 90, 12, false, false, + HistorySeries.Cpu), + + new(ProcessField.PrivateBytes, "private", "Private bytes", "Private", + "Private memory the process has committed — what it would give back if it exited.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 96, 7, true, true, + Aliases: "mem memory commit"), + new(ProcessField.PrivateBytesDelta, "private.delta", "Private delta", "Priv/s", + "How fast committed private memory is moving. A process whose private bytes only climb is the one leaking.", + FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 100, 9, true, true), + new(ProcessField.PrivateWorkingSet, "private.ws", "Private WS", "PrivWS", + "The resident part of the committed private memory.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 88, 7, true, true, + Aliases: "uss"), + new(ProcessField.MemoryHistory, "memory.history", "Memory history", "Mem hist", + "The last sixty seconds of committed private memory.", + FieldKind.Graph, FieldUnit.Bytes, _ALL, FieldCost.Derived, 90, 12, false, false, + HistorySeries.Memory), + new(ProcessField.WorkingSetBytes, "ws", "Working set", "RSS", + "Resident memory including every shared page, which is why it double-counts.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 92, 7, true, true, + Aliases: "rss workingset resident"), + new(ProcessField.PeakWorkingSet, "ws.peak", "Peak WS", "PkRSS", + "The largest working set this process has ever held.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 84, 7, true, true), + new(ProcessField.VirtualBytes, "virtual", "Virtual size", "Virt", + "Size of the mapped address space, most of which is usually not resident.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 92, 7, true, true, + Aliases: "virt vsize"), + new(ProcessField.PeakVirtualBytes, "virtual.peak", "Peak virtual", "PkVirt", + "The largest address space this process has ever mapped.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 96, 7, true, true), + new(ProcessField.PagedPool, "pool.paged", "Paged pool", "PgPool", + "Kernel memory charged to this process from the paged pool.", + FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 90, 7, true, true), + new(ProcessField.PeakPagedPool, "pool.paged.peak", "Peak paged pool", "PkPgPool", + "The largest paged-pool charge this process has held.", + FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 116, 8, true, true), + new(ProcessField.NonPagedPool, "pool.nonpaged", "Non-paged pool", "NpPool", + "Kernel memory charged to this process from the non-paged pool.", + FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 110, 7, true, true), + new(ProcessField.PeakNonPagedPool, "pool.nonpaged.peak", "Peak non-paged", "PkNpPool", + "The largest non-paged-pool charge this process has held.", + FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 112, 8, true, true), + new(ProcessField.PageFaultsDelta, "faults.delta", "Page fault delta", "Flt/s", + "Page faults this interval. A process faulting steadily is one the machine is paging for.", + FieldKind.Rate, FieldUnit.CountPerSecond, _ALL, FieldCost.Derived, 116, 8, true, true), + new(ProcessField.Swap, "swap", "Swap", "Swap", + "How much of this process the machine has pushed out to swap.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 78, 7, true, true), + + new(ProcessField.IoTotalRate, "io.total", "I/O total rate", "IO/s", + "Bytes read, written and neither, per second.", + FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 104, 8, true, true, + Aliases: "io"), + new(ProcessField.ReadBytesPerSecond, "io.read", "I/O read rate", "Read/s", + "Bytes this process caused to be read, per second.", + FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 100, 8, true, true, + Aliases: "read"), + new(ProcessField.WriteBytesPerSecond, "io.write", "I/O write rate", "Write/s", + "Bytes this process caused to be written, per second.", + FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 104, 8, true, true, + Aliases: "write"), + new(ProcessField.IoHistory, "io.history", "I/O history", "I/O hist", + "The last sixty seconds of read and write traffic.", + FieldKind.Graph, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 90, 12, false, false, + HistorySeries.Io), + + new(ProcessField.ThreadCount, "threads", "Threads", "Thr", + "How many threads the process currently has.", + FieldKind.Instant, FieldUnit.Count, _ALL, FieldCost.Free, 64, 4, true, true), + new(ProcessField.HandleCount, "handles", "Handles", "Hnd", + "Open handles on Windows, open file descriptors on Unix.", + FieldKind.Instant, FieldUnit.Count, _ALL, FieldCost.High, 66, 5, true, true, + Aliases: "fds fd"), + new(ProcessField.Priority, "priority", "Priority", "Pri", + "Scheduler priority in the platform's own scale.", + FieldKind.Instant, FieldUnit.Count, _ALL, FieldCost.Free, 74, 4, true, true, + Aliases: "prio"), + new(ProcessField.SessionId, "session", "Session", "Ses", + "The login or terminal session the process belongs to.", + FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 74, 5, true, false), + new(ProcessField.StartTime, "start", "Start time", "Started", + "When the process was created.", + FieldKind.Instant, FieldUnit.Timestamp, _ALL, FieldCost.Free, 140, 19, false, true, + Aliases: "started starttime"), + new(ProcessField.Container, "cgroup", "Container / cgroup", "Cgroup", + "The cgroup or container the process belongs to.", + FieldKind.Text, FieldUnit.None, _LINUX, FieldCost.Free, 240, 40, false, false, + Aliases: "container"), + new(ProcessField.ImagePath, "path", "Image path", "Path", + "Full path of the executable image.", + FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 320, 60, false, false, + Aliases: "image exe"), + new(ProcessField.CommandLine, "cmdline", "Command line", "Command", + "The complete command the process was started with.", + FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 420, 120, false, false, + Aliases: "cmd commandline"), + ]; + + private static readonly FieldDescriptor[] _byId = BuildIndex(); + + private static FieldDescriptor[] BuildIndex() { + var highest = 0; + foreach (var descriptor in All) + highest = Math.Max(highest, (int)descriptor.Id); + + var index = new FieldDescriptor[highest + 1]; + foreach (var descriptor in All) + index[(int)descriptor.Id] = descriptor; + + return index; + } + + /// Everything known about one field. + public static FieldDescriptor Get(ProcessField field) { + var index = (int)field; + return (uint)index < (uint)_byId.Length && _byId[index] is { } descriptor ? descriptor : _byId[0]; + } + + public static string Header(this ProcessField field) => Get(field).Header; + + public static string ShortHeader(this ProcessField field) => Get(field).ShortHeader; + + public static string Key(this ProcessField field) => Get(field).Key; + + public static bool PrefersDescending(this ProcessField field) => Get(field).PrefersDescending; + + /// + /// Resolves a field from text: its key, one of its aliases, or its header, case-insensitively. + /// + /// + /// This is what --sort, a saved layout and a search term all go through, so all three accept + /// the same spellings and none of them can drift from the others. + /// + public static bool TryParse(string? text, out ProcessField field) { + field = ProcessField.CpuPercent; + if (string.IsNullOrWhiteSpace(text)) + return false; + + var wanted = text.Trim(); + foreach (var descriptor in All) { + if (string.Equals(descriptor.Key, wanted, StringComparison.OrdinalIgnoreCase) + || string.Equals(descriptor.Header, wanted, StringComparison.OrdinalIgnoreCase)) { + field = descriptor.Id; + return true; + } + + if (descriptor.Aliases is not { } aliases) + continue; + + foreach (var alias in aliases.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + if (string.Equals(alias, wanted, StringComparison.OrdinalIgnoreCase)) { + field = descriptor.Id; + return true; + } + } + + return false; + } + + /// Every spelling accepts, for the help text. + public static string SortableKeys() { + var keys = new List(); + foreach (var descriptor in All) + if (descriptor.IsSortable) + keys.Add(descriptor.Key); + + return string.Join(", ", keys); + } + +} diff --git a/ProcessManager.Core/Query/ProcessColumn.cs b/ProcessManager.Core/Query/ProcessColumn.cs deleted file mode 100644 index ef4e2ce..0000000 --- a/ProcessManager.Core/Query/ProcessColumn.cs +++ /dev/null @@ -1,88 +0,0 @@ -namespace Hawkynt.ProcessManager.Query; - -/// -/// Everything a row can be sorted by. The order here is the default column order in both front-ends, -/// so it is the Process-Explorer order rather than alphabetical. -/// -public enum ProcessColumn : byte { - Name = 0, - Pid, - ParentPid, - UserName, - State, - CpuPercent, - PrivateBytes, - WorkingSetBytes, - VirtualBytes, - ReadBytesPerSecond, - WriteBytesPerSecond, - HandleCount, - ThreadCount, - StartTime, - Priority, - SessionId, - CommandLine, -} - -public static class ProcessColumnExtensions { - - /// The header text, and what a `--sort=` argument accepts (case-insensitively). - public static string ToHeader(this ProcessColumn column) => column switch { - ProcessColumn.Name => "Process", - ProcessColumn.Pid => "PID", - ProcessColumn.ParentPid => "PPID", - ProcessColumn.UserName => "User", - ProcessColumn.State => "State", - ProcessColumn.CpuPercent => "CPU", - ProcessColumn.PrivateBytes => "Private", - ProcessColumn.WorkingSetBytes => "Working set", - ProcessColumn.VirtualBytes => "Virtual", - ProcessColumn.ReadBytesPerSecond => "Read/s", - ProcessColumn.WriteBytesPerSecond => "Write/s", - ProcessColumn.HandleCount => "Handles", - ProcessColumn.ThreadCount => "Threads", - ProcessColumn.StartTime => "Started", - ProcessColumn.Priority => "Priority", - ProcessColumn.SessionId => "Session", - ProcessColumn.CommandLine => "Command line", - _ => column.ToString(), - }; - - /// - /// Whether bigger should come first when the column is picked. Sorting by CPU ascending is not - /// what anybody wants from one keypress, and sorting names descending is not either. - /// - public static bool PrefersDescending(this ProcessColumn column) => column switch { - ProcessColumn.Name or ProcessColumn.UserName or ProcessColumn.CommandLine - or ProcessColumn.State or ProcessColumn.Pid or ProcessColumn.ParentPid => false, - _ => true, - }; - - public static bool TryParse(string? text, out ProcessColumn column) { - column = ProcessColumn.CpuPercent; - if (string.IsNullOrWhiteSpace(text)) - return false; - - switch (text.Trim().ToLowerInvariant()) { - case "name" or "process" or "comm": column = ProcessColumn.Name; return true; - case "pid": column = ProcessColumn.Pid; return true; - case "ppid" or "parent": column = ProcessColumn.ParentPid; return true; - case "user" or "owner": column = ProcessColumn.UserName; return true; - case "state" or "status": column = ProcessColumn.State; return true; - case "cpu": column = ProcessColumn.CpuPercent; return true; - case "mem" or "memory" or "private" or "pss": column = ProcessColumn.PrivateBytes; return true; - case "rss" or "ws" or "workingset": column = ProcessColumn.WorkingSetBytes; return true; - case "virt" or "virtual": column = ProcessColumn.VirtualBytes; return true; - case "read": column = ProcessColumn.ReadBytesPerSecond; return true; - case "write": column = ProcessColumn.WriteBytesPerSecond; return true; - case "handles" or "fds": column = ProcessColumn.HandleCount; return true; - case "threads": column = ProcessColumn.ThreadCount; return true; - case "start" or "started": column = ProcessColumn.StartTime; return true; - case "prio" or "priority": column = ProcessColumn.Priority; return true; - case "session": column = ProcessColumn.SessionId; return true; - case "cmd" or "cmdline" or "commandline": column = ProcessColumn.CommandLine; return true; - default: return false; - } - } - -} diff --git a/ProcessManager.Core/Query/ProcessField.cs b/ProcessManager.Core/Query/ProcessField.cs new file mode 100644 index 0000000..e4969e3 --- /dev/null +++ b/ProcessManager.Core/Query/ProcessField.cs @@ -0,0 +1,190 @@ +using Hawkynt.ProcessManager.Sampling; + +namespace Hawkynt.ProcessManager.Query; + +/// +/// Every value a process row can show, sort by or be filtered on. +/// +/// +/// One enum for the whole program, deliberately. There used to be three lists — a sort-key enum here, +/// a column set in the window and a third in the terminal — which meant adding a field meant editing +/// three places, and three places is three places to forget one. PRD §5.1 and §103. +/// +/// The order is the default column order, which is Process Explorer's rather than alphabetical. +/// +/// +public enum ProcessField : byte { + + Name = 0, + Pid, + PidHex, + ParentPid, + UserName, + State, + + CpuPercent, + CpuPercentPerCore, + CpuTime, + CyclesDelta, + ContextSwitchesDelta, + CpuHistory, + + PrivateBytes, + PrivateBytesDelta, + PrivateWorkingSet, + MemoryHistory, + WorkingSetBytes, + PeakWorkingSet, + VirtualBytes, + PeakVirtualBytes, + PagedPool, + PeakPagedPool, + NonPagedPool, + PeakNonPagedPool, + PageFaultsDelta, + Swap, + + IoTotalRate, + ReadBytesPerSecond, + WriteBytesPerSecond, + IoHistory, + + ThreadCount, + HandleCount, + Priority, + SessionId, + StartTime, + Container, + ImagePath, + CommandLine, + +} + +/// +/// What kind of number a field is, which decides whether it may be averaged, summed, or graphed at +/// all (PRD §5.1). +/// +public enum FieldKind : byte { + + /// Free text — a name, a path, a command line. + Text, + + /// An identifier that happens to be numeric. Sorts numerically, never summed. + Identifier, + + /// A value that is true right now and has no history of its own. + Instant, + + /// Monotonic since the process started. The interesting figure is its derivative. + Cumulative, + + /// The change in a cumulative counter over one interval. + Delta, + + /// A per-second figure derived from two samples. + Rate, + + /// One of a fixed set of states. + State, + + /// A drawn history rather than a value; has no text and cannot be sorted. + Graph, + +} + +/// What the number counts, which decides how it is formatted and how a filter parses it. +public enum FieldUnit : byte { + None, + Bytes, + BytesPerSecond, + Percent, + Nanoseconds, + Count, + CountPerSecond, + Timestamp, +} + +/// +/// What reading the field costs, so an expensive one is never made default-visible by accident +/// (PRD §5.4). +/// +public enum FieldCost : byte { + + /// Already in the snapshot; showing it costs nothing at all. + Free, + + /// Needs a second sample, and nothing else. + Derived, + + /// Costs a syscall or more per process. Never default-visible. + High, + +} + +/// Which platforms can fill a field. A platform not listed renders n/a, not zero. +[Flags] +public enum FieldPlatforms : byte { + None = 0, + Windows = 1, + Linux = 2, + MacOS = 4, + All = Windows | Linux | MacOS, +} + +/// +/// Everything the program knows about one field: how to label it, how wide to draw it, what it +/// means, and who can fill it. +/// +/// The enum value. +/// +/// The stable identifier. This is what a saved layout, a --sort argument and a search term all +/// use, and it never changes even when the header does — including when the header differs per +/// platform, which is the point (PRD §5.3). +/// +/// The full label, for the window. +/// The narrow label, for the terminal. +/// One sentence, for the tooltip and the column chooser. +/// Pixels. +/// Character cells. +/// +/// Whether biggest-first is what a single click should give. Sorting by CPU ascending is not what +/// anybody wants from one keypress, and sorting names descending is not either. +/// +public sealed record FieldDescriptor( + ProcessField Id, + string Key, + string Header, + string ShortHeader, + string Description, + FieldKind Kind, + FieldUnit Unit, + FieldPlatforms Platforms, + FieldCost Cost, + int DesktopWidth, + int TerminalWidth, + bool RightAligned, + bool PrefersDescending, + HistorySeries? Series = null, + string? Aliases = null +) { + + /// True for the three drawn histories, which have no text and no sort order. + public bool IsGraph => this.Kind == FieldKind.Graph; + + /// False for graphs, true for everything else. + public bool IsSortable => this.Kind != FieldKind.Graph; + + /// + /// Whether this field can hold a number on this machine at all — used to decide between showing a + /// value and showing why there is none. + /// + public bool IsSupportedHere => (this.Platforms & CurrentPlatform) != 0; + + /// Which flag this machine is. + public static FieldPlatforms CurrentPlatform { get; } = + OperatingSystem.IsWindows() ? FieldPlatforms.Windows + : OperatingSystem.IsLinux() ? FieldPlatforms.Linux + : OperatingSystem.IsMacOS() ? FieldPlatforms.MacOS + : FieldPlatforms.None; + +} diff --git a/ProcessManager.Core/Query/ProcessView.cs b/ProcessManager.Core/Query/ProcessView.cs index cb6b481..cab933d 100644 --- a/ProcessManager.Core/Query/ProcessView.cs +++ b/ProcessManager.Core/Query/ProcessView.cs @@ -31,7 +31,7 @@ public sealed class ProcessView { public ProcessView() => this._comparer = Comparer.Create(this.Compare); - public ProcessColumn SortColumn { get; set; } = ProcessColumn.CpuPercent; + public ProcessField SortColumn { get; set; } = ProcessField.CpuPercent; public bool SortDescending { get; set; } = true; @@ -249,48 +249,11 @@ private int Compare(int left, int right) { private int CompareAscending(int left, int right) { var processes = this._snapshot!.Processes; - ref readonly var a = ref processes[left]; - ref readonly var b = ref processes[right]; - - return this.SortColumn switch { - ProcessColumn.Name => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase), - ProcessColumn.Pid => a.Pid.CompareTo(b.Pid), - ProcessColumn.ParentPid => a.ParentPid.CompareTo(b.ParentPid), - ProcessColumn.UserName => string.Compare(a.UserName, b.UserName, StringComparison.OrdinalIgnoreCase), - ProcessColumn.State => ((byte)a.State).CompareTo((byte)b.State), - ProcessColumn.CpuPercent => CompareRate(this._delta!.CpuPercent(left), this._delta.CpuPercent(right)), - ProcessColumn.PrivateBytes => CompareCounter(a.PrivateBytes, b.PrivateBytes), - ProcessColumn.WorkingSetBytes => CompareCounter(a.WorkingSetBytes, b.WorkingSetBytes), - ProcessColumn.VirtualBytes => CompareCounter(a.VirtualBytes, b.VirtualBytes), - ProcessColumn.ReadBytesPerSecond - => CompareRate(this._delta!.ReadBytesPerSecond(left), this._delta.ReadBytesPerSecond(right)), - ProcessColumn.WriteBytesPerSecond - => CompareRate(this._delta!.WriteBytesPerSecond(left), this._delta.WriteBytesPerSecond(right)), - ProcessColumn.HandleCount => CompareCounter(a.HandleCount, b.HandleCount), - ProcessColumn.ThreadCount => a.ThreadCount.CompareTo(b.ThreadCount), - ProcessColumn.StartTime => a.StartTimeUtcTicks.CompareTo(b.StartTimeUtcTicks), - ProcessColumn.Priority => a.Priority.CompareTo(b.Priority), - ProcessColumn.SessionId => a.SessionId.CompareTo(b.SessionId), - ProcessColumn.CommandLine => string.Compare(a.CommandLine, b.CommandLine, StringComparison.OrdinalIgnoreCase), - _ => 0, - }; - } - // A value that is not there sorts below every value that is, in ascending order — so reversing the - // sort puts the readable rows on top either way, instead of a block of dashes. - private static int CompareCounter(Counter left, Counter right) => (left.HasValue, right.HasValue) switch { - (true, true) => left.Value.CompareTo(right.Value), - (true, false) => 1, - (false, true) => -1, - _ => 0, - }; - - private static int CompareRate(Rate left, Rate right) => (left.HasValue, right.HasValue) switch { - (true, true) => left.Value.CompareTo(right.Value), - (true, false) => 1, - (false, true) => -1, - _ => 0, - }; + // Every field, in one place, shared with both front-ends: sorting by a column and the text that + // column shows are now read out of the same accessor, so they cannot drift apart (PRD §5.1). + return FieldAccessor.Compare(this.SortColumn, in processes[left], left, in processes[right], right, this._delta); + } private static void EnsureLength(ref T[] array, int length) { if (array.Length < length) diff --git a/ProcessManager.Tests/FieldRegistryTests.cs b/ProcessManager.Tests/FieldRegistryTests.cs new file mode 100644 index 0000000..594cfa5 --- /dev/null +++ b/ProcessManager.Tests/FieldRegistryTests.cs @@ -0,0 +1,265 @@ +using Hawkynt.ProcessManager.Model; +using Hawkynt.ProcessManager.Query; +using Hawkynt.ProcessManager.Sampling; + +namespace Hawkynt.ProcessManager.Tests; + +/// +/// The field catalogue (PRD §5.1) and the rule that nothing may be added to a front-end without +/// going through it (PRD §103). +/// +/// +/// §103 said "a CI check enforces this" and nothing did, which made it a convention rather than a +/// rule. is that check: adding a value to +/// and forgetting the descriptor now fails the build rather than +/// producing a column with no header that sorts by nothing. +/// +[TestFixture] +public sealed class FieldRegistryTests { + + [Test] + public void EveryFieldInTheEnumIsRegistered() { + var missing = new List(); + foreach (ProcessField field in Enum.GetValues()) + if (FieldRegistry.Get(field).Id != field) + missing.Add(field); + + Assert.That(missing, Is.Empty, "these fields have no descriptor in FieldRegistry.All"); + } + + [Test] + public void EveryRegisteredFieldIsInTheEnum() { + foreach (var descriptor in FieldRegistry.All) + Assert.That(Enum.IsDefined(descriptor.Id), Is.True, $"{descriptor.Key} is not a ProcessField"); + } + + [Test] + public void KeysAreUniqueAndSoAreHeaders() { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + var headers = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var descriptor in FieldRegistry.All) { + Assert.That(keys.Add(descriptor.Key), Is.True, $"duplicate key: {descriptor.Key}"); + Assert.That(headers.Add(descriptor.Header), Is.True, $"duplicate header: {descriptor.Header}"); + } + } + + /// + /// An alias that collides with another field's key would resolve to whichever came first in the + /// array, which is a sorting order nobody chose. + /// + [Test] + public void NoAliasCollidesWithAnotherFieldsKeyOrAlias() { + var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var descriptor in FieldRegistry.All) { + Register(descriptor.Key, descriptor.Key); + if (descriptor.Aliases is not { } aliases) + continue; + + foreach (var alias in aliases.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + Register(alias, descriptor.Key); + } + + void Register(string spelling, string owner) { + Assert.That( + seen.TryAdd(spelling, owner), + Is.True, + $"'{spelling}' is claimed by both {owner} and {(seen.TryGetValue(spelling, out var other) ? other : "?")}" + ); + } + } + + [Test] + public void EveryKeyAndAliasParsesBackToItsOwnField() { + foreach (var descriptor in FieldRegistry.All) { + Assert.That(FieldRegistry.TryParse(descriptor.Key, out var byKey), Is.True, descriptor.Key); + Assert.That(byKey, Is.EqualTo(descriptor.Id)); + + Assert.That(FieldRegistry.TryParse(descriptor.Header, out var byHeader), Is.True, descriptor.Header); + Assert.That(byHeader, Is.EqualTo(descriptor.Id)); + + // Case and surrounding space must not matter: this is what a command line hands us. + Assert.That(FieldRegistry.TryParse($" {descriptor.Key.ToUpperInvariant()} ", out var loose), Is.True); + Assert.That(loose, Is.EqualTo(descriptor.Id)); + + if (descriptor.Aliases is not { } aliases) + continue; + + foreach (var alias in aliases.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { + Assert.That(FieldRegistry.TryParse(alias, out var byAlias), Is.True, alias); + Assert.That(byAlias, Is.EqualTo(descriptor.Id), alias); + } + } + } + + [Test] + public void NonsenseDoesNotParse() { + Assert.That(FieldRegistry.TryParse("not-a-field", out _), Is.False); + Assert.That(FieldRegistry.TryParse("", out _), Is.False); + Assert.That(FieldRegistry.TryParse(null, out _), Is.False); + Assert.That(FieldRegistry.TryParse(" ", out _), Is.False); + } + + [Test] + public void GraphsAreNotSortableAndEverythingElseIs() { + foreach (var descriptor in FieldRegistry.All) + Assert.That( + descriptor.IsSortable, + Is.EqualTo(descriptor.Kind != FieldKind.Graph), + $"{descriptor.Key}: a drawn column has no order and a written one must have" + ); + } + + /// + /// Widths are what stop a header being clipped to something that names nothing — "PU %" was a real + /// one. Every header must fit the column that carries it. + /// + [Test] + public void EveryShortHeaderFitsItsTerminalColumn() { + foreach (var descriptor in FieldRegistry.All) + Assert.That( + descriptor.ShortHeader.Length, + Is.LessThanOrEqualTo(descriptor.TerminalWidth), + $"{descriptor.Key}: '{descriptor.ShortHeader}' does not fit {descriptor.TerminalWidth} cells" + ); + } + + [Test] + public void AnExpensiveFieldIsNeverInTheDefaultTerminalColumns() { + // PRD §5.4: displaying the ordinary process table must not require an expensive collector. The + // handle count is the one exception, and it is sampled on its own schedule for that reason. + foreach (var field in new[] { ProcessField.CpuPercent, ProcessField.PrivateBytes, ProcessField.Name }) + Assert.That(FieldRegistry.Get(field).Cost, Is.Not.EqualTo(FieldCost.High), field.ToString()); + } + + #region reading a field + + [Test] + public void EveryFieldCanBeReadFromAProcessWithoutThrowing() { + var snapshot = OneProcess(); + var delta = new SnapshotDelta(); + delta.Update(null, snapshot, CpuPercentMode.Normalized); + + foreach (var descriptor in FieldRegistry.All) { + // The point is that none of these throw, whether or not the platform fills the field. + var text = FieldAccessor.Text(descriptor.Id, in snapshot.Processes[0], delta, 0); + Assert.That(text, Is.Not.Null, descriptor.Key); + _ = FieldAccessor.Number(descriptor.Id, in snapshot.Processes[0], delta, 0); + _ = FieldAccessor.RawText(descriptor.Id, in snapshot.Processes[0]); + } + } + + /// + /// Before a second sample every derived field must read as "not sampled yet" rather than as zero — + /// a fresh window showing 0.0% CPU for everything is a window that is lying (PRD §72.3). + /// + [Test] + public void ADerivedFieldWithNoSecondSampleReadsAsPendingRatherThanZero() { + var snapshot = OneProcess(); + var delta = new SnapshotDelta(); + delta.Update(null, snapshot, CpuPercentMode.Normalized); + + var pending = Humanize.Placeholder(UnknownReason.NotSampledYet); + foreach (var field in new[] { + ProcessField.CpuPercent, ProcessField.CpuPercentPerCore, ProcessField.ReadBytesPerSecond, + ProcessField.WriteBytesPerSecond, ProcessField.IoTotalRate, ProcessField.PageFaultsDelta, + }) { + Assert.That(FieldAccessor.Text(field, in snapshot.Processes[0], delta, 0), Is.EqualTo(pending), field.ToString()); + Assert.That(FieldAccessor.Number(field, in snapshot.Processes[0], delta, 0), Is.Null, field.ToString()); + } + } + + /// + /// A field the platform does not report has no number, and a filter must not treat that as zero: + /// "memory > 0" should not match a process whose memory is unknown, and neither should + /// "memory == 0". + /// + [Test] + public void AnUnknownCounterHasNoNumberAtAll() { + var snapshot = new SystemSnapshot(); + var records = snapshot.PrepareProcesses(1); + records[0] = default; + records[0].Key = new(1, 1); + records[0].Name = "test"; + records[0].PrivateBytes = Counter.NotSupported; + + var delta = new SnapshotDelta(); + delta.Update(null, snapshot, CpuPercentMode.Normalized); + + Assert.That(FieldAccessor.Number(ProcessField.PrivateBytes, in snapshot.Processes[0], delta, 0), Is.Null); + Assert.That( + FieldAccessor.Text(ProcessField.PrivateBytes, in snapshot.Processes[0], delta, 0), + Is.EqualTo(Humanize.Placeholder(UnknownReason.NotSupportedOnPlatform)) + ); + } + + /// + /// Sorting by a column and the text that column shows are read from the same place, so an order + /// that disagrees with the display is not possible. This checks the halves agree. + /// + [Test] + public void SortingAgreesWithTheNumbersTheColumnShows() { + var snapshot = new SystemSnapshot(); + var records = snapshot.PrepareProcesses(3); + for (var i = 0; i < 3; ++i) { + records[i] = default; + records[i].Key = new(i + 1, (ulong)(i + 1)); + records[i].Name = "p" + i; + records[i].WorkingSetBytes = Counter.Of((ulong)((3 - i) * 1024)); + records[i].ThreadCount = i; + } + + var delta = new SnapshotDelta(); + delta.Update(null, snapshot, CpuPercentMode.Normalized); + var processes = snapshot.Processes; + + // Working set descends across the three; thread count ascends. Compare must say so both times. + Assert.That(FieldAccessor.Compare(ProcessField.WorkingSetBytes, in processes[0], 0, in processes[1], 1, delta), Is.GreaterThan(0)); + Assert.That(FieldAccessor.Compare(ProcessField.ThreadCount, in processes[0], 0, in processes[1], 1, delta), Is.LessThan(0)); + Assert.That(FieldAccessor.Compare(ProcessField.Name, in processes[0], 0, in processes[1], 1, delta), Is.LessThan(0)); + } + + /// An unknown value sorts below every known one, so reversing puts readable rows on top. + [Test] + public void AnUnknownValueSortsBelowEveryKnownOne() { + var snapshot = new SystemSnapshot(); + var records = snapshot.PrepareProcesses(2); + for (var i = 0; i < 2; ++i) { + records[i] = default; + records[i].Key = new(i + 1, (ulong)(i + 1)); + records[i].Name = "p" + i; + } + + records[0].PrivateBytes = Counter.NotSupported; + records[1].PrivateBytes = Counter.Of(0ul); + + var delta = new SnapshotDelta(); + delta.Update(null, snapshot, CpuPercentMode.Normalized); + var processes = snapshot.Processes; + + // Even against a real zero, which is the case that matters: unknown is not zero. + Assert.That(FieldAccessor.Compare(ProcessField.PrivateBytes, in processes[0], 0, in processes[1], 1, delta), Is.LessThan(0)); + Assert.That(FieldAccessor.Compare(ProcessField.PrivateBytes, in processes[1], 1, in processes[0], 0, delta), Is.GreaterThan(0)); + } + + #endregion + + private static SystemSnapshot OneProcess() { + var snapshot = new SystemSnapshot(); + var records = snapshot.PrepareProcesses(1); + records[0] = default; + records[0].Key = new(4242, 100); + records[0].Name = "test"; + records[0].UserName = "alice"; + records[0].ParentPid = 1; + records[0].ThreadCount = 3; + records[0].SessionId = 1; + records[0].StartTimeUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + records[0].CpuTimeNs = Counter.Of(1_000_000_000ul); + records[0].PrivateBytes = Counter.Of(1024ul * 1024); + records[0].WorkingSetBytes = Counter.Of(2048ul * 1024); + records[0].CommandLine = "/usr/bin/test --flag"; + records[0].ImagePath = "/usr/bin/test"; + return snapshot; + } + +} diff --git a/ProcessManager.Tests/LinuxProbeTests.cs b/ProcessManager.Tests/LinuxProbeTests.cs index b1d6e1d..6f3a22f 100644 --- a/ProcessManager.Tests/LinuxProbeTests.cs +++ b/ProcessManager.Tests/LinuxProbeTests.cs @@ -194,7 +194,7 @@ public void TheProcessTreeMatchesTheFixture() { using var sampler = new Sampler(probe); sampler.Sample(); - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(sampler.Current, sampler.Delta); var lines = new List(); diff --git a/ProcessManager.Tests/ProcessTreeBinderTests.cs b/ProcessManager.Tests/ProcessTreeBinderTests.cs index 261119b..ac08e71 100644 --- a/ProcessManager.Tests/ProcessTreeBinderTests.cs +++ b/ProcessManager.Tests/ProcessTreeBinderTests.cs @@ -126,7 +126,7 @@ public void TheTreeIsReorderedToMatchTheSort() { var binder = new ProcessTreeBinder(tree); var (snapshot, delta, view) = Build((10, 0), (20, 0), (30, 0)); - view.SortColumn = ProcessColumn.Pid; + view.SortColumn = ProcessField.Pid; view.SortDescending = false; view.Rebuild(snapshot, delta); binder.Sync(snapshot, delta, view); @@ -145,7 +145,7 @@ public void ReorderingKeepsTheSameNodeObjects() { var tree = new TreeListView(); var binder = new ProcessTreeBinder(tree); var (snapshot, delta, view) = Build((10, 0), (20, 0)); - view.SortColumn = ProcessColumn.Pid; + view.SortColumn = ProcessField.Pid; view.SortDescending = false; view.Rebuild(snapshot, delta); binder.Sync(snapshot, delta, view); @@ -166,7 +166,7 @@ public void ChildrenAreOrderedWithinTheirParent() { var tree = new TreeListView(); var binder = new ProcessTreeBinder(tree); var (snapshot, delta, view) = Build((1, 0), (10, 1), (20, 1), (30, 1)); - view.SortColumn = ProcessColumn.Pid; + view.SortColumn = ProcessField.Pid; view.SortDescending = true; view.Rebuild(snapshot, delta); binder.Sync(snapshot, delta, view); @@ -221,7 +221,7 @@ private static (SystemSnapshot Snapshot, SnapshotDelta Delta, ProcessView View) var delta = new SnapshotDelta(); delta.Update(null, snapshot, CpuPercentMode.Normalized); - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); return (snapshot, delta, view); } diff --git a/ProcessManager.Tests/ProcessViewTests.cs b/ProcessManager.Tests/ProcessViewTests.cs index 11f4bbb..59f3e7b 100644 --- a/ProcessManager.Tests/ProcessViewTests.cs +++ b/ProcessManager.Tests/ProcessViewTests.cs @@ -14,7 +14,7 @@ public sealed class ProcessViewTests { [Test] public void AFlatViewShowsEveryProcessOnce() { var (snapshot, delta) = Build((1, 0), (2, 1), (3, 2)); - var view = new ProcessView { TreeMode = false, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = false, SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); Assert.That(view.RowCount, Is.EqualTo(3)); @@ -24,7 +24,7 @@ public void AFlatViewShowsEveryProcessOnce() { [Test] public void ATreeNestsChildrenUnderParents() { var (snapshot, delta) = Build((1, 0), (2, 1), (3, 2), (4, 1)); - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 1, 2, 3, 4 })); @@ -38,7 +38,7 @@ public void AProcessWhoseParentIsGoneBecomesARoot() { // Its parent exited and it was reparented, or it lives in another pid namespace. Either way it // is still running and must still be listed. var (snapshot, delta) = Build((1, 0), (5, 999)); - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); Assert.That(view.RowCount, Is.EqualTo(2)); @@ -50,7 +50,7 @@ public void ACycleDoesNotHangTheWalk() { // Should be impossible; observed anyway across namespace boundaries. The link that closes the // cycle is cut, and every process still appears exactly once. var (snapshot, delta) = Build((10, 11), (11, 10)); - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false }; Assert.That(() => view.Rebuild(snapshot, delta), Throws.Nothing); Assert.That(view.RowCount, Is.EqualTo(2)); @@ -72,7 +72,7 @@ public void FilteringInTreeModeKeepsTheAncestorsOfAMatch() { var (snapshot, delta) = Build((1, 0), (2, 1), (3, 2)); Rename(snapshot, 3, "needle"); - var view = new ProcessView { TreeMode = true, TextFilter = "needle", SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, TextFilter = "needle", SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 1, 2, 3 })); @@ -96,7 +96,7 @@ public void ATieIsBrokenByPidSoRowsDoNotJumpBetweenSamples() { // Everything below has the same sort key. If the order were not pinned, a re-sort could move a // row under the pointer between hover and click — which is how the wrong process gets killed. var (snapshot, delta) = Build((30, 0), (10, 0), (20, 0)); - var view = new ProcessView { SortColumn = ProcessColumn.ThreadCount, SortDescending = true }; + var view = new ProcessView { SortColumn = ProcessField.ThreadCount, SortDescending = true }; view.Rebuild(snapshot, delta); Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 10, 20, 30 })); @@ -109,7 +109,7 @@ public void AValueThatIsNotThereSortsBelowEveryValueThatIs() { SetPrivate(snapshot, 2, Counter.NotPermitted); SetPrivate(snapshot, 3, Counter.Of(500ul)); - var view = new ProcessView { SortColumn = ProcessColumn.PrivateBytes, SortDescending = true }; + var view = new ProcessView { SortColumn = ProcessField.PrivateBytes, SortDescending = true }; view.Rebuild(snapshot, delta); Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 3, 1, 2 })); @@ -118,7 +118,7 @@ public void AValueThatIsNotThereSortsBelowEveryValueThatIs() { [Test] public void FindRowLocatesAProcessByIdentityRatherThanByPosition() { var (snapshot, delta) = Build((1, 0), (2, 1), (3, 1)); - var view = new ProcessView { SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); var key = snapshot.Processes[1].Key; diff --git a/ProcessManager.Tests/SparklineTests.cs b/ProcessManager.Tests/SparklineTests.cs index d8bb6e9..a93f4f9 100644 --- a/ProcessManager.Tests/SparklineTests.cs +++ b/ProcessManager.Tests/SparklineTests.cs @@ -101,7 +101,7 @@ private static (SystemSnapshot Snapshot, SnapshotDelta Delta, ProcessView View) var delta = new SnapshotDelta(); delta.Update(null, snapshot, CpuPercentMode.Normalized); - var view = new ProcessView { SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); return (snapshot, delta, view); } diff --git a/ProcessManager.Tests/TerminalUiTests.cs b/ProcessManager.Tests/TerminalUiTests.cs index 21b6c89..24d422d 100644 --- a/ProcessManager.Tests/TerminalUiTests.cs +++ b/ProcessManager.Tests/TerminalUiTests.cs @@ -126,7 +126,7 @@ public void TheComposedFrameMatchesTheGoldenOne() { UseBlockCharacters = true, }; ui.View.TreeMode = true; - ui.View.SortColumn = ProcessColumn.Pid; + ui.View.SortColumn = ProcessField.Pid; ui.View.SortDescending = false; ui.Update(); ui.Update(); @@ -254,7 +254,7 @@ static string Compose(Sampler sampler, LinuxProbe probe, bool unicode) { }; ui.View.TreeMode = true; - ui.View.SortColumn = ProcessColumn.Pid; + ui.View.SortColumn = ProcessField.Pid; ui.View.SortDescending = false; ui.Update(); ui.Update(); diff --git a/ProcessManager.Ui.Desktop/ColumnChooser.cs b/ProcessManager.Ui.Desktop/ColumnChooser.cs index aff5cbc..83e5304 100644 --- a/ProcessManager.Ui.Desktop/ColumnChooser.cs +++ b/ProcessManager.Ui.Desktop/ColumnChooser.cs @@ -1,3 +1,4 @@ +using Hawkynt.ProcessManager.Query; using Hawkynt.NativeForms; namespace Hawkynt.ProcessManager.Ui.Desktop; @@ -13,9 +14,9 @@ namespace Hawkynt.ProcessManager.Ui.Desktop; public sealed class ColumnChooser : Form { private readonly CheckedListBox _list = new(); - private readonly List _order = []; + private readonly List _order = []; - public ColumnChooser(IReadOnlyCollection visible) { + public ColumnChooser(IReadOnlyCollection visible) { ArgumentNullException.ThrowIfNull(visible); this.Text = "Select columns"; @@ -23,9 +24,9 @@ public ColumnChooser(IReadOnlyCollection visible) { this._list.Bounds = new(12, 12, 344, 372); foreach (var info in ColumnSet.All) { - this._order.Add(info.Column); + this._order.Add(info.Id); this._list.Items.Add(info.Header); - this._list.SetItemChecked(this._list.Items.Count - 1, visible.Contains(info.Column)); + this._list.SetItemChecked(this._list.Items.Count - 1, visible.Contains(info.Id)); } var ok = new Button { Text = "OK", Bounds = new(180, 394, 80, 28) }; @@ -49,15 +50,15 @@ public ColumnChooser(IReadOnlyCollection visible) { /// What was ticked. The name column is forced on: a list whose rows have no name is a list of /// numbers. /// - public List Selection { + public List Selection { get { - var result = new List(); + var result = new List(); for (var i = 0; i < this._order.Count; ++i) if (this._list.GetItemChecked(i)) result.Add(this._order[i]); - if (!result.Contains(DesktopColumn.Name)) - result.Insert(0, DesktopColumn.Name); + if (!result.Contains(ProcessField.Name)) + result.Insert(0, ProcessField.Name); return result; } diff --git a/ProcessManager.Ui.Desktop/ColumnSet.cs b/ProcessManager.Ui.Desktop/ColumnSet.cs index f38df02..56bb9f8 100644 --- a/ProcessManager.Ui.Desktop/ColumnSet.cs +++ b/ProcessManager.Ui.Desktop/ColumnSet.cs @@ -1,141 +1,47 @@ using Hawkynt.ProcessManager.Query; -using Hawkynt.ProcessManager.Sampling; namespace Hawkynt.ProcessManager.Ui.Desktop; -/// Every column the process list can show. -public enum DesktopColumn : byte { - Name = 0, - Pid, - PidHex, - ParentPid, - User, - State, - CpuPercent, - CpuPerCore, - CpuTime, - CyclesDelta, - ContextSwitchDelta, - CpuHistory, - PrivateBytes, - PrivateBytesDelta, - PrivateWorkingSet, - MemoryHistory, - WorkingSet, - PeakWorkingSet, - VirtualBytes, - PeakVirtualBytes, - PagedPool, - PeakPagedPool, - NonPagedPool, - PeakNonPagedPool, - PageFaultDelta, - Swap, - IoTotalRate, - ReadRate, - WriteRate, - IoHistory, - Threads, - Handles, - Priority, - Session, - Started, - Container, - ImagePath, - CommandLine, -} - /// -/// What each column is: its header, its width, whether it is a graph, and how to sort by it. +/// Which columns the window shows, and which it offers. /// /// -/// The three history columns are the reason the whole set is data rather than a hard-coded list of -/// Columns.Add calls — they are drawn rather than written, they need a series from -/// , and they have no text to sort by. +/// There is no list of columns here any more. Every column the window can show is a field in +/// , with its header, width, alignment and sort order declared once and +/// shared with the terminal (PRD §5.1). What is left is the two things that really are the window's +/// own business: which columns it opens with, and in what order the chooser lists them. /// -public readonly record struct ColumnInfo( - DesktopColumn Column, - string Header, - int Width, - bool RightAligned, - ProcessColumn? SortBy, - HistorySeries? Series -); - -public static class ColumnSet { +internal static class ColumnSet { - /// - /// Every column, in the order the chooser lists them. - /// - /// - /// A column exists here when the engine can actually fill it on at least one platform. Where the - /// other platform cannot, the cell says which reason applies rather than showing a zero (§3.4) — - /// cycles and the pool quotas are Windows-only, and read as n/a on Linux. - /// - public static readonly ColumnInfo[] All = [ - new(DesktopColumn.Name, "Process", 260, false, ProcessColumn.Name, null), - new(DesktopColumn.Pid, "PID", 70, true, ProcessColumn.Pid, null), - new(DesktopColumn.PidHex, "PID (hex)", 78, true, ProcessColumn.Pid, null), - new(DesktopColumn.ParentPid, "Parent PID", 88, true, ProcessColumn.ParentPid, null), - new(DesktopColumn.User, "User", 130, false, ProcessColumn.UserName, null), - new(DesktopColumn.State, "State", 62, false, ProcessColumn.State, null), - // Wide enough for the header *and* its sort arrow: at 62 the caption clipped to "PU %". - new(DesktopColumn.CpuPercent, "CPU %", 78, true, ProcessColumn.CpuPercent, null), - new(DesktopColumn.CpuPerCore, "CPU % (per core)", 118, true, ProcessColumn.CpuPercent, null), - new(DesktopColumn.CpuTime, "CPU time", 88, true, ProcessColumn.CpuPercent, null), - new(DesktopColumn.CyclesDelta, "Cycles delta", 100, true, null, null), - new(DesktopColumn.ContextSwitchDelta, "Ctx switch delta", 116, true, null, null), - new(DesktopColumn.CpuHistory, "CPU history", 90, false, null, HistorySeries.Cpu), - new(DesktopColumn.PrivateBytes, "Private bytes", 96, true, ProcessColumn.PrivateBytes, null), - new(DesktopColumn.PrivateBytesDelta, "Private delta", 100, true, null, null), - new(DesktopColumn.PrivateWorkingSet, "Private WS", 88, true, null, null), - new(DesktopColumn.MemoryHistory, "Memory history", 90, false, null, HistorySeries.Memory), - new(DesktopColumn.WorkingSet, "Working set", 92, true, ProcessColumn.WorkingSetBytes, null), - new(DesktopColumn.PeakWorkingSet, "Peak WS", 84, true, null, null), - new(DesktopColumn.VirtualBytes, "Virtual size", 92, true, ProcessColumn.VirtualBytes, null), - new(DesktopColumn.PeakVirtualBytes, "Peak virtual", 96, true, null, null), - new(DesktopColumn.PagedPool, "Paged pool", 90, true, null, null), - new(DesktopColumn.PeakPagedPool, "Peak paged pool", 116, true, null, null), - new(DesktopColumn.NonPagedPool, "Non-paged pool", 110, true, null, null), - new(DesktopColumn.PeakNonPagedPool, "Peak non-paged", 112, true, null, null), - new(DesktopColumn.PageFaultDelta, "Page fault delta", 116, true, null, null), - new(DesktopColumn.Swap, "Swap", 78, true, null, null), - new(DesktopColumn.IoTotalRate, "I/O total rate", 104, true, null, null), - new(DesktopColumn.ReadRate, "I/O read rate", 100, true, ProcessColumn.ReadBytesPerSecond, null), - new(DesktopColumn.WriteRate, "I/O write rate", 104, true, ProcessColumn.WriteBytesPerSecond, null), - new(DesktopColumn.IoHistory, "I/O history", 90, false, null, HistorySeries.Io), - new(DesktopColumn.Threads, "Threads", 64, true, ProcessColumn.ThreadCount, null), - new(DesktopColumn.Handles, "Handles", 66, true, ProcessColumn.HandleCount, null), - new(DesktopColumn.Priority, "Priority", 74, true, ProcessColumn.Priority, null), - new(DesktopColumn.Session, "Session", 74, true, ProcessColumn.SessionId, null), - new(DesktopColumn.Started, "Start time", 140, false, ProcessColumn.StartTime, null), - new(DesktopColumn.Container, "Container / cgroup", 240, false, null, null), - new(DesktopColumn.ImagePath, "Image path", 320, false, null, null), - new(DesktopColumn.CommandLine, "Command line", 420, false, ProcessColumn.CommandLine, null), - ]; + /// Every column the chooser offers, in registry order. + public static FieldDescriptor[] All => FieldRegistry.All; /// /// What the window opens with: the Process Explorer set plus the three graphs, which are the point /// of having them. /// - public static readonly DesktopColumn[] Default = [ - DesktopColumn.Name, - DesktopColumn.Pid, - DesktopColumn.User, - DesktopColumn.CpuPercent, - DesktopColumn.CpuHistory, - DesktopColumn.PrivateBytes, - DesktopColumn.MemoryHistory, - DesktopColumn.IoHistory, - DesktopColumn.Threads, + public static readonly ProcessField[] Default = [ + ProcessField.Name, + ProcessField.Pid, + ProcessField.UserName, + ProcessField.CpuPercent, + ProcessField.CpuHistory, + ProcessField.PrivateBytes, + ProcessField.MemoryHistory, + ProcessField.IoHistory, + ProcessField.ThreadCount, ]; - public static ColumnInfo Info(DesktopColumn column) { - foreach (var info in All) - if (info.Column == column) - return info; + public static FieldDescriptor Info(ProcessField field) => FieldRegistry.Get(field); - return All[0]; - } + /// + /// Whether a column is worth offering on this machine at all. + /// + /// + /// A column the platform cannot fill is still offered — it renders n/a, which is a true + /// statement and occasionally the one the user wanted (PRD §72.3). What it is not is + /// default-visible, which is what this decides. + /// + public static bool IsUsefulHere(FieldDescriptor descriptor) => descriptor.IsSupportedHere; } diff --git a/ProcessManager.Ui.Desktop/MainWindow.cs b/ProcessManager.Ui.Desktop/MainWindow.cs index 5fd9dd7..677e064 100644 --- a/ProcessManager.Ui.Desktop/MainWindow.cs +++ b/ProcessManager.Ui.Desktop/MainWindow.cs @@ -18,7 +18,7 @@ public sealed class MainWindow : Form { private readonly Sampler _sampler; private readonly ISystemProbe _probe; private readonly IProcessActions? _actions; - private readonly ProcessView _view = new() { TreeMode = true, SortColumn = ProcessColumn.CpuPercent, SortDescending = true }; + private readonly ProcessView _view = new() { TreeMode = true, SortColumn = ProcessField.CpuPercent, SortDescending = true }; private readonly ProcessTreeBinder _binder; private readonly TreeListView _tree = new(); private readonly HistoryPlot _cpuPlot = new(); @@ -32,7 +32,7 @@ public sealed class MainWindow : Form { private readonly HistoryRing _cpuHistory = new(600); private readonly HistoryRing _memoryHistory = new(600); private readonly ProcessHistory _rowHistory = new(); - private readonly List _columns = [.. ColumnSet.Default]; + private readonly List _columns = [.. ColumnSet.Default]; private bool _splitPlaced; private ITheme _theme = DefaultTheme.Instance; private int _laidOutWidth = -1; @@ -226,11 +226,11 @@ private void RebuildColumns() { foreach (var column in this._columns) { var info = ColumnSet.Info(column); var header = info.Header; - if (info.SortBy == this._view.SortColumn) + if (info.IsSortable && column == this._view.SortColumn) header = this._view.SortDescending ? header + " ▾" : header + " ▴"; var which = column; - this._tree.Columns.Add(new(header, info.Width, node => ((ProcessRow)node.Tag!).TextOf(which)) { + this._tree.Columns.Add(new(header, info.DesktopWidth, node => ((ProcessRow)node.Tag!).TextOf(which)) { TextAlign = info.RightAligned ? ContentAlignment.MiddleRight : ContentAlignment.MiddleLeft, }); } @@ -261,7 +261,8 @@ private void OnColumnClick(object? sender, ColumnClickEventArgs e) { if ((uint)e.Column >= (uint)this._columns.Count) return; - if (ColumnSet.Info(this._columns[e.Column]).SortBy is not { } sortBy) + var sortBy = this._columns[e.Column]; + if (!ColumnSet.Info(sortBy).IsSortable) // A history column has no text to sort by, and sorting by "the shape of a graph" is not a // thing. Clicking one does nothing rather than doing something arbitrary. return; @@ -334,20 +335,20 @@ private void BuildMenu() { // ColumnClick (its ListView does, and is flat). Click-to-sort is the gesture people expect, so // this is a stand-in for a hook that has to come from the toolkit, not a preference. var sort = new ToolStripMenuItem("Sort by"); - foreach (var column in (ReadOnlySpan)[ - ProcessColumn.CpuPercent, - ProcessColumn.PrivateBytes, - ProcessColumn.WorkingSetBytes, - ProcessColumn.ReadBytesPerSecond, - ProcessColumn.WriteBytesPerSecond, - ProcessColumn.ThreadCount, - ProcessColumn.Name, - ProcessColumn.Pid, - ProcessColumn.UserName, - ProcessColumn.StartTime, + foreach (var column in (ReadOnlySpan)[ + ProcessField.CpuPercent, + ProcessField.PrivateBytes, + ProcessField.WorkingSetBytes, + ProcessField.ReadBytesPerSecond, + ProcessField.WriteBytesPerSecond, + ProcessField.ThreadCount, + ProcessField.Name, + ProcessField.Pid, + ProcessField.UserName, + ProcessField.StartTime, ]) { var chosen = column; - sort.DropDownItems.Add(Item(column.ToHeader(), () => { + sort.DropDownItems.Add(Item(column.Header(), () => { this._view.SortColumn = chosen; this._view.SortDescending = chosen.PrefersDescending(); this.Refresh(); diff --git a/ProcessManager.Ui.Desktop/ProcessRow.cs b/ProcessManager.Ui.Desktop/ProcessRow.cs index c8a346b..fb60445 100644 --- a/ProcessManager.Ui.Desktop/ProcessRow.cs +++ b/ProcessManager.Ui.Desktop/ProcessRow.cs @@ -11,50 +11,32 @@ namespace Hawkynt.ProcessManager.Ui.Desktop; /// /// The tree's column selectors run on every paint, several times per row — so they read strings that /// are already made rather than formatting a number each time. The strings are refreshed once per -/// sample, in , and only when they changed: a row whose CPU still reads "0.0" -/// hands back the same string instance and the control has nothing to repaint. +/// sample, in . +/// +/// One array indexed by rather than a property per column: there are +/// thirty-eight of them, a property each meant a switch to match, and the switch is exactly the kind +/// of thing that silently loses a field when the thirty-ninth is added (PRD §5.1). +/// /// public sealed class ProcessRow(ProcessKey key) { + private static readonly int _slots = CountSlots(); + + private readonly string[] _text = new string[_slots]; + + /// Indexed by the enum value, so the array must be as long as the largest one plus one. + private static int CountSlots() { + var highest = 0; + foreach (var descriptor in FieldRegistry.All) + highest = Math.Max(highest, (int)descriptor.Id); + + return highest + 1; + } + public ProcessKey Key { get; } = key; public int Pid => this.Key.Pid; - public string Name { get; private set; } = string.Empty; - public string User { get; private set; } = string.Empty; - public string Cpu { get; private set; } = string.Empty; - public string Private { get; private set; } = string.Empty; - public string WorkingSet { get; private set; } = string.Empty; - public string Read { get; private set; } = string.Empty; - public string Write { get; private set; } = string.Empty; - public string Threads { get; private set; } = string.Empty; - public string Handles { get; private set; } = string.Empty; - public string State { get; private set; } = string.Empty; - public string Started { get; private set; } = string.Empty; - public string CommandLine { get; private set; } = string.Empty; - public string PidHex { get; private set; } = string.Empty; - public string ParentPid { get; private set; } = string.Empty; - public string CpuPerCore { get; private set; } = string.Empty; - public string CpuTime { get; private set; } = string.Empty; - public string CyclesDelta { get; private set; } = string.Empty; - public string ContextSwitchDelta { get; private set; } = string.Empty; - public string PrivateDelta { get; private set; } = string.Empty; - public string PrivateWorkingSet { get; private set; } = string.Empty; - public string PeakWorkingSet { get; private set; } = string.Empty; - public string VirtualBytes { get; private set; } = string.Empty; - public string PeakVirtualBytes { get; private set; } = string.Empty; - public string PagedPool { get; private set; } = string.Empty; - public string PeakPagedPool { get; private set; } = string.Empty; - public string NonPagedPool { get; private set; } = string.Empty; - public string PeakNonPagedPool { get; private set; } = string.Empty; - public string PageFaultDelta { get; private set; } = string.Empty; - public string Swap { get; private set; } = string.Empty; - public string IoTotal { get; private set; } = string.Empty; - public string Priority { get; private set; } = string.Empty; - public string Session { get; private set; } = string.Empty; - public string Container { get; private set; } = string.Empty; - public string ImagePath { get; private set; } = string.Empty; - /// True for one sample after the process appeared — the Process Explorer green flash. public bool IsNew { get; private set; } @@ -65,86 +47,53 @@ public sealed class ProcessRow(ProcessKey key) { public int Generation { get; set; } public void Update(in ProcessRecord process, SnapshotDelta delta, int index, Counter handles, int currentUserId) { + foreach (var descriptor in FieldRegistry.All) { + if (descriptor.IsGraph) + continue; + + this._text[(int)descriptor.Id] = FieldAccessor.Text(descriptor.Id, in process, delta, index); + } + + // Two exceptions to the shared formatter, both because the window knows something the engine + // does not. The handle count is sampled on its own schedule because it is expensive (PRD §5.4), + // so the row prefers the freshly measured one and falls back to the snapshot's; and a missing + // user name falls back to the numeric id, which is more use in a window than a dash. + if (handles.Reason != UnknownReason.NotSampledYet) + this._text[(int)ProcessField.HandleCount] = Humanize.Count(handles); + + if (process.UserName is null) + this._text[(int)ProcessField.UserName] = + process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?"; + this.Name = process.Name; - this.User = process.UserName ?? (process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?"); - this.Cpu = Humanize.Percent(delta.CpuPercent(index)); - this.Private = Humanize.Bytes(process.PrivateBytes); - this.WorkingSet = Humanize.Bytes(process.WorkingSetBytes); - this.Read = Humanize.BytesPerSecond(delta.ReadBytesPerSecond(index)); - this.Write = Humanize.BytesPerSecond(delta.WriteBytesPerSecond(index)); - this.Threads = process.ThreadCount.ToString(CultureInfo.InvariantCulture); - this.Handles = Humanize.Count(handles.Reason == UnknownReason.NotSampledYet ? process.HandleCount : handles); - this.State = Humanize.State(process.State); - this.Started = process.StartTimeUtcTicks > 0 - ? new DateTime(process.StartTimeUtcTicks, DateTimeKind.Utc).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture) - : "—"; - - this.CommandLine = process.CommandLine ?? string.Empty; - this.PidHex = "0x" + process.Pid.ToString("X", CultureInfo.InvariantCulture); - this.ParentPid = process.ParentPid > 0 ? process.ParentPid.ToString(CultureInfo.InvariantCulture) : "—"; - this.CpuPerCore = Humanize.Percent(delta.CpuPercentPerCore(index)); - this.CpuTime = Humanize.Duration(process.CpuTimeNs); - this.CyclesDelta = Humanize.Rate(delta.CyclesPerSecond(index)); - this.ContextSwitchDelta = Humanize.Rate(delta.ContextSwitchesPerSecond(index)); - this.PrivateDelta = Humanize.SignedBytesPerSecond(delta.PrivateBytesDelta(index)); - this.PrivateWorkingSet = Humanize.Bytes(process.PrivateWorkingSetBytes); - this.PeakWorkingSet = Humanize.Bytes(process.PeakWorkingSetBytes); - this.VirtualBytes = Humanize.Bytes(process.VirtualBytes); - this.PeakVirtualBytes = Humanize.Bytes(process.PeakVirtualBytes); - this.PagedPool = Humanize.Bytes(process.PagedPoolBytes); - this.PeakPagedPool = Humanize.Bytes(process.PeakPagedPoolBytes); - this.NonPagedPool = Humanize.Bytes(process.NonPagedPoolBytes); - this.PeakNonPagedPool = Humanize.Bytes(process.PeakNonPagedPoolBytes); - this.PageFaultDelta = Humanize.Rate(delta.PageFaultsPerSecond(index)); - this.Swap = Humanize.Bytes(process.SwapBytes); - this.IoTotal = Humanize.BytesPerSecond(delta.IoTotalBytesPerSecond(index)); - this.Priority = process.Priority.ToString(CultureInfo.InvariantCulture); - this.Session = process.SessionId >= 0 ? process.SessionId.ToString(CultureInfo.InvariantCulture) : "—"; - this.Container = process.ContainerPath ?? "—"; - this.ImagePath = process.ImagePath ?? "—"; this.IsNew = delta.IsNew(index); this.Category = ProcessCategories.Classify(in process, currentUserId, this.IsNew); } /// The text for one column, or empty for the ones that are drawn rather than written. - public string TextOf(DesktopColumn column) => column switch { - DesktopColumn.Name => this.Label, - DesktopColumn.Pid => this.Pid.ToString(CultureInfo.InvariantCulture), - DesktopColumn.User => this.User, - DesktopColumn.State => this.State, - DesktopColumn.CpuPercent => this.Cpu, - DesktopColumn.PrivateBytes => this.Private, - DesktopColumn.WorkingSet => this.WorkingSet, - DesktopColumn.ReadRate => this.Read, - DesktopColumn.WriteRate => this.Write, - DesktopColumn.Threads => this.Threads, - DesktopColumn.Handles => this.Handles, - DesktopColumn.Started => this.Started, - DesktopColumn.CommandLine => this.CommandLine, - DesktopColumn.PidHex => this.PidHex, - DesktopColumn.ParentPid => this.ParentPid, - DesktopColumn.CpuPerCore => this.CpuPerCore, - DesktopColumn.CpuTime => this.CpuTime, - DesktopColumn.CyclesDelta => this.CyclesDelta, - DesktopColumn.ContextSwitchDelta => this.ContextSwitchDelta, - DesktopColumn.PrivateBytesDelta => this.PrivateDelta, - DesktopColumn.PrivateWorkingSet => this.PrivateWorkingSet, - DesktopColumn.PeakWorkingSet => this.PeakWorkingSet, - DesktopColumn.VirtualBytes => this.VirtualBytes, - DesktopColumn.PeakVirtualBytes => this.PeakVirtualBytes, - DesktopColumn.PagedPool => this.PagedPool, - DesktopColumn.PeakPagedPool => this.PeakPagedPool, - DesktopColumn.NonPagedPool => this.NonPagedPool, - DesktopColumn.PeakNonPagedPool => this.PeakNonPagedPool, - DesktopColumn.PageFaultDelta => this.PageFaultDelta, - DesktopColumn.Swap => this.Swap, - DesktopColumn.IoTotalRate => this.IoTotal, - DesktopColumn.Priority => this.Priority, - DesktopColumn.Session => this.Session, - DesktopColumn.Container => this.Container, - DesktopColumn.ImagePath => this.ImagePath, - _ => string.Empty, - }; + public string TextOf(ProcessField field) { + // The name column carries the pid alongside the name, which is a window convention rather than a + // property of the field, so it does not belong in the shared accessor. + if (field == ProcessField.Name) + return this.Label; + + var index = (int)field; + return (uint)index < (uint)this._text.Length ? this._text[index] ?? string.Empty : string.Empty; + } + + public string Name { get; private set; } = string.Empty; + + public string User => this.TextOf(ProcessField.UserName); + public string Cpu => this.TextOf(ProcessField.CpuPercent); + public string Private => this.TextOf(ProcessField.PrivateBytes); + public string WorkingSet => this.TextOf(ProcessField.WorkingSetBytes); + public string Read => this.TextOf(ProcessField.ReadBytesPerSecond); + public string Write => this.TextOf(ProcessField.WriteBytesPerSecond); + public string Threads => this.TextOf(ProcessField.ThreadCount); + public string Handles => this.TextOf(ProcessField.HandleCount); + public string State => this.TextOf(ProcessField.State); + public string Started => this.TextOf(ProcessField.StartTime); + public string CommandLine => this.TextOf(ProcessField.CommandLine); /// The text the tree column shows in the name column, with the pid appended. public string Label => $"{this.Name} ({this.Pid})"; diff --git a/ProcessManager.Ui.Terminal/Layout.cs b/ProcessManager.Ui.Terminal/Layout.cs index d9033f3..6dd84ee 100644 --- a/ProcessManager.Ui.Terminal/Layout.cs +++ b/ProcessManager.Ui.Terminal/Layout.cs @@ -1,5 +1,4 @@ using Hawkynt.ProcessManager.Query; -using Hawkynt.ProcessManager.Sampling; namespace Hawkynt.ProcessManager.Ui.Terminal; @@ -10,50 +9,45 @@ namespace Hawkynt.ProcessManager.Ui.Terminal; /// Fixed widths rather than measured ones. A column that resizes itself to its widest value jitters /// every second as processes come and go, and a table whose columns move is harder to read than one /// whose values are occasionally clipped (PRD §11). +/// +/// The widths, headers and alignments come from , not from here: the +/// terminal used to keep its own list, which is how it ended up one field behind the window +/// (PRD §5.1). +/// /// internal static class Layout { - /// - /// A terminal column: either one of the engine's sortable columns, or one of the three drawn - /// histories, which have no text and no sort order. - /// - public readonly record struct TerminalColumn(string Header, int Width, ProcessColumn? Sortable, HistorySeries? Series) { - public bool IsGraph => this.Series is not null; - } - - public static readonly TerminalColumn[] Columns = [ - new("PID", 7, ProcessColumn.Pid, null), - new("User", 10, ProcessColumn.UserName, null), - new("S", 5, ProcessColumn.State, null), - new("CPU%", 5, ProcessColumn.CpuPercent, null), - new("CPU hist", 12, null, HistorySeries.Cpu), - new("Private", 7, ProcessColumn.PrivateBytes, null), - new("Mem hist", 12, null, HistorySeries.Memory), - new("Read/s", 8, ProcessColumn.ReadBytesPerSecond, null), - new("Write/s", 8, ProcessColumn.WriteBytesPerSecond, null), - new("I/O hist", 12, null, HistorySeries.Io), - new("Thr", 4, ProcessColumn.ThreadCount, null), - new("Hnd", 5, ProcessColumn.HandleCount, null), - new("Process", 120, ProcessColumn.Name, null), + /// The columns the terminal opens with, in order. + public static readonly ProcessField[] Columns = [ + ProcessField.Pid, + ProcessField.UserName, + ProcessField.State, + ProcessField.CpuPercent, + ProcessField.CpuHistory, + ProcessField.PrivateBytes, + ProcessField.MemoryHistory, + ProcessField.ReadBytesPerSecond, + ProcessField.WriteBytesPerSecond, + ProcessField.IoHistory, + ProcessField.ThreadCount, + ProcessField.HandleCount, + ProcessField.Name, ]; + public static FieldDescriptor Info(ProcessField field) => FieldRegistry.Get(field); + /// The columns a user can cycle the sort through — the graphs are not among them. - public static ProcessColumn[] Sortable { + public static ProcessField[] Sortable { get { - var result = new List(); - foreach (var column in Columns) - if (column.Sortable is { } sortable) - result.Add(sortable); + var result = new List(); + // Not "field": in C# 14 that is a keyword inside a property accessor and binds to the + // backing field instead of the loop variable. + foreach (var candidate in Columns) + if (FieldRegistry.Get(candidate).IsSortable) + result.Add(candidate); return [.. result]; } } - /// Numbers right, text left — so the digits of a column line up under each other. - public static bool IsRightAligned(in TerminalColumn column) => column.Sortable switch { - ProcessColumn.Name or ProcessColumn.UserName or ProcessColumn.CommandLine or ProcessColumn.State => false, - null => false, - _ => true, - }; - } diff --git a/ProcessManager.Ui.Terminal/TerminalUi.cs b/ProcessManager.Ui.Terminal/TerminalUi.cs index e3f3ca9..3a060a5 100644 --- a/ProcessManager.Ui.Terminal/TerminalUi.cs +++ b/ProcessManager.Ui.Terminal/TerminalUi.cs @@ -43,7 +43,7 @@ public TerminalUi(Sampler sampler, ISystemProbe probe, IProcessActions? actions, this._screen = new(width, height, depth); this._detail = new(probe); this._view.TreeMode = false; - this._view.SortColumn = ProcessColumn.CpuPercent; + this._view.SortColumn = ProcessField.CpuPercent; this._view.SortDescending = true; } @@ -197,10 +197,10 @@ private bool HandleNormal(ConsoleKeyInfo key) { case 'i': this.OpenDetail(); return true; case '<': this.PreviousSortColumn(); return true; case '>': this.NextSortColumn(); return true; - case 'P': this._view.SortColumn = ProcessColumn.CpuPercent; this._view.SortDescending = true; return true; - case 'M': this._view.SortColumn = ProcessColumn.PrivateBytes; this._view.SortDescending = true; return true; - case 'T': this._view.SortColumn = ProcessColumn.StartTime; this._view.SortDescending = false; return true; - case 'N': this._view.SortColumn = ProcessColumn.Pid; this._view.SortDescending = false; return true; + case 'P': this._view.SortColumn = ProcessField.CpuPercent; this._view.SortDescending = true; return true; + case 'M': this._view.SortColumn = ProcessField.PrivateBytes; this._view.SortDescending = true; return true; + case 'T': this._view.SortColumn = ProcessField.StartTime; this._view.SortDescending = false; return true; + case 'N': this._view.SortColumn = ProcessField.Pid; this._view.SortDescending = false; return true; case 'C': this._sampler.CpuPercentMode = this._sampler.CpuPercentMode == CpuPercentMode.Normalized ? CpuPercentMode.PerCore @@ -357,7 +357,7 @@ private void SetSortColumn(int direction) { index = ((index < 0 ? 0 : index) + direction + columns.Length) % columns.Length; this._view.SortColumn = columns[index]; this._view.SortDescending = columns[index].PrefersDescending(); - this.Say($"sorted by {columns[index].ToHeader()}", Attributes.Accent); + this.Say($"sorted by {columns[index].Header()}", Attributes.Accent); } private void BeginKill(bool tree) { @@ -510,10 +510,11 @@ private static string FormatUptime(double seconds) { private void DrawColumnHeader(int y) { this._screen.Fill(0, y, this._screen.Width, ' ', Attributes.Header); var x = 0; - foreach (var column in Layout.Columns) { - var width = column.Width; - var header = column.Header; - if (column.Sortable is { } sortable && sortable == this._view.SortColumn) + foreach (var field in Layout.Columns) { + var column = Layout.Info(field); + var width = column.TerminalWidth; + var header = column.ShortHeader; + if (column.IsSortable && field == this._view.SortColumn) header = this._view.SortDescending ? header + "▾" : header + "▴"; // A header that does not fit loses its tail, not its head: "Working set" clipped to "ing set" @@ -522,7 +523,7 @@ private void DrawColumnHeader(int y) { if (header.Length > width) header = header[..width]; - if (Layout.IsRightAligned(in column)) + if (column.RightAligned) this._screen.WriteRight(x, y, width, header, Attributes.Header); else this._screen.Write(x, y, header, Attributes.Header); @@ -555,8 +556,9 @@ private void DrawRows(int top) { this._screen.Fill(0, top + line, this._screen.Width, ' ', Attributes.Selected); var x = 0; - foreach (var column in Layout.Columns) { - var width = column.Width; + foreach (var field in Layout.Columns) { + var column = Layout.Info(field); + var width = column.TerminalWidth; if (column.Series is { } series) { // Drawn, not written: the eighth-block ramp turns a column of text into a plot (PRD §11). var plot = BlockSparkline.Render( @@ -574,8 +576,8 @@ private void DrawRows(int top) { continue; } - var text = this.CellText(in column, row, in process, delta); - if (Layout.IsRightAligned(in column)) + var text = this.CellText(field, row, in process, delta); + if (column.RightAligned) this._screen.WriteRight(x, top + line, width, text, baseAttribute); else this._screen.Write(x, top + line, text.Length > width ? text[..width] : text, baseAttribute); @@ -594,24 +596,31 @@ private void DrawRows(int top) { _ => Attributes.Warn, }; - private string CellText(in Layout.TerminalColumn column, ViewRow row, in ProcessRecord process, SnapshotDelta delta) - => column.Sortable switch { - ProcessColumn.Pid => process.Pid.ToString(CultureInfo.InvariantCulture), - ProcessColumn.UserName => process.UserName ?? (process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?"), - ProcessColumn.PrivateBytes => Humanize.Bytes(process.PrivateBytes), - ProcessColumn.WorkingSetBytes => Humanize.Bytes(process.WorkingSetBytes), - ProcessColumn.State => Humanize.State(process.State), - ProcessColumn.CpuPercent => Humanize.Percent(delta.CpuPercent(row.Index)), - ProcessColumn.ReadBytesPerSecond => Humanize.BytesPerSecond(delta.ReadBytesPerSecond(row.Index)), - ProcessColumn.WriteBytesPerSecond => Humanize.BytesPerSecond(delta.WriteBytesPerSecond(row.Index)), - ProcessColumn.ThreadCount => process.ThreadCount.ToString(CultureInfo.InvariantCulture), - ProcessColumn.HandleCount => Humanize.Count(this._handleCounts.TryGetValue(process.Key, out var handles) ? handles : process.HandleCount), - ProcessColumn.Name => this._view.TreeMode - ? new string(' ', Math.Min(row.Depth * 2, 32)) + (row.HasChildren ? "+ " : " ") + process.Name - : process.Name, - ProcessColumn.CommandLine => process.CommandLine ?? string.Empty, - _ => string.Empty, - }; + /// + /// The text for one cell. Everything goes through the shared accessor so a value reads the same + /// here as in the window (PRD §5.1); the two exceptions below are things the terminal knows and + /// the engine does not. + /// + private string CellText(ProcessField field, ViewRow row, in ProcessRecord process, SnapshotDelta delta) { + switch (field) { + // The tree lives in the name column, so indentation and the expander are part of its text. + case ProcessField.Name: + return this._view.TreeMode + ? new string(' ', Math.Min(row.Depth * 2, 32)) + (row.HasChildren ? "+ " : " ") + process.Name + : process.Name; + + // Handles are counted on their own schedule because counting them is expensive (PRD §5.4). + case ProcessField.HandleCount when this._handleCounts.TryGetValue(process.Key, out var handles): + return Humanize.Count(handles); + + // A missing user name falls back to the numeric id, which is narrower and more use than a dash. + case ProcessField.UserName when process.UserName is null: + return process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?"; + + default: + return FieldAccessor.Text(field, in process, delta, row.Index); + } + } private void DrawStatus() { var y = this._screen.Height - 1; diff --git a/docs/PRD.md b/docs/PRD.md index 455a809..056a22d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -31,8 +31,8 @@ shorthand: it is not known*. An unticked box must never become a zero on screen. This is restated here because it is the single requirement most likely to be broken while filling the tables in. -**Counting, as of the last update:** **374 of 1249 boxes are ticked** — 55 of 189 in the field -registry (§14–22), 319 of 1060 across the capabilities. A further 116 are marked 🟡, meaning some of +**Counting, as of the last update:** **382 of 1250 boxes are ticked** — 55 of 189 in the field +registry (§14–22), 327 of 1061 across the capabilities. A further 114 are marked 🟡, meaning some of the work behind them is already done. §100 tracks the phases; §101 defines when this may be called finished. @@ -182,34 +182,36 @@ reasoning, and the reasoning recorded in a comment. There must not be separate "GUI fields" and "TUI fields". -- [ ] Every field is registered in a central field catalogue. **This does not exist yet**, and it is - the highest-priority piece of internal work in the document: today `ProcessColumn` is a sort-key - enum in Core, the desktop keeps its own `ColumnSet`, and the TUI keeps a third list. Three - places to add a field is three places to forget one. §103 cannot be enforced until this lands. +- [x] Every field is registered in a central field catalogue — `FieldRegistry` in + `ProcessManager.Core/Query`. There were three lists before it (a sort-key enum in Core, the + window's own `ColumnSet`, and a third in the terminal), and three places to add a field is + three places to forget one. -The ticks below therefore describe what a field *can* express today through the existing types -(`Counter`, `Rate`, `UnknownReason`), not a catalogue that holds the metadata in one place. +`FieldAccessor` reads a field three ways from that one declaration: as text to display, as a number +to compare and filter, and as an ordering. The window and the terminal both render through it, and +the view sorts through it — so sorting by a column cannot disagree with what the column shows, and a +value reads identically in both front-ends because it is the same code producing it. Each registry entry declares: -- [x] Stable field ID +- [x] Stable field ID — the `Key`, which is what `--sort`, a saved layout and a search term all use - [x] Human-readable name -- [ ] Short TUI label -- [ ] Description +- [x] Short TUI label +- [x] Description - [x] Data type -- [ ] 🟡 Units +- [x] Units - [ ] Precision - [x] Whether it is instantaneous, cumulative, delta, rate, state, enumeration or derived -- [ ] 🟡 Supported platforms +- [x] Supported platforms - [ ] Required privilege -- [ ] Collection cost +- [x] Collection cost - [x] Default visibility - [x] Sort semantics -- [ ] Filter semantics +- [ ] 🟡 Filter semantics — `Number` and `RawText` are there; the query language of §56 is not - [x] Formatting function - [x] Null/unavailable semantics - [ ] Export serialisation -- [ ] Historical-storage eligibility +- [ ] 🟡 Historical-storage eligibility — the graph fields declare their series Worked example — `process.cpu.usage`: display "CPU", TUI "CPU%", percentage, normalised instantaneous utilisation, 0–100 in default mode and 0–N×100 in raw logical-CPU mode, all platforms, @@ -341,7 +343,7 @@ Platform backends → Core collector → Snapshot engine → Field registry → | Core collector | `ProcessManager.Core` | ✅ | | Platform backends | `ProcessManager.Platform.{Windows,Linux,MacOS}` | 🟡 macOS stub | | Snapshot engine | `ProcessManager.Core/Sampling` | ✅ | -| Field registry | *not yet built* — see §5.1 | ⬜ | +| Field registry | `ProcessManager.Core/Query` | ✅ | | Query engine | `ProcessManager.Core/Query` | 🟡 | | Action broker | `ProcessManager.Core/Actions` | 🟡 | | Privileged helper | `ProcessManager.Elevated` | ✅ | @@ -1543,6 +1545,9 @@ Substring matching over name, PID, user and command line works in both front-end language does not exist. - [x] Plain substring search +- [x] Every field is addressable by a stable key, which is the half of the query language the + registry supplies — `--sort=private.ws` and `--sort=faults.delta` work without either having + been written down anywhere as a sort key - [ ] `field:value` - [ ] `field=value` - [ ] Comparison operators @@ -2296,7 +2301,7 @@ a naive parser hands the attacker the parse. - [x] Delta handling - [x] Unit formatting - [x] PID reuse -- [ ] 🟡 Field registry +- [x] Field registry — 14 tests, including the one that enforces §103 - [x] Filters - [x] Sorting - [ ] Export schemas @@ -2498,7 +2503,9 @@ To add an **action**: 11. Add the audit event 12. Add tests -- [ ] 🟡 A CI check enforces this — today it is a convention, and a convention is not a rule +- [ ] 🟡 A CI check enforces this. Half of it is real: `EveryFieldInTheEnumIsRegistered` fails the + build when a field is added to the enum without a descriptor, so steps 1–8 cannot be skipped. + Steps 9–13 — GUI, TUI, CLI, export schema, tests — are still on the author to remember. # 104. Internal object model