From 619acc3c08cb6fcd16a36fc091875f06378c60fb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 25 Feb 2026 13:09:11 -0500 Subject: [PATCH] feat: add sort options to /connz (Gap 10.3) Add ConnzSortOption enum (ConnectionId, Start, BytesTo, BytesFrom, MsgsTo, MsgsFrom, Subscriptions, Pending, Uptime, Idle, LastActivity) and ConnzSorter static class with Parse(string?) and Sort(IEnumerable, ConnzSortOption, bool) methods. Add SortBy and SortDescending properties to ConnzFilterOptions and wire them through ConnzFilterOptions.Parse (?sort= and ?desc= query params). Add 10 unit tests in ConnzSortTests covering Parse defaults, known values, unknown string fallback, and sort ordering for key fields. Go reference: server/monitor_sort_opts.go, server/monitor.go Connz(). --- src/NATS.Server/Monitoring/Connz.cs | 226 +++++++++++++----- .../Monitoring/ConnzSortTests.cs | 179 ++++++++++++++ 2 files changed, 344 insertions(+), 61 deletions(-) create mode 100644 tests/NATS.Server.Tests/Monitoring/ConnzSortTests.cs diff --git a/src/NATS.Server/Monitoring/Connz.cs b/src/NATS.Server/Monitoring/Connz.cs index 958b4ed..e11a905 100644 --- a/src/NATS.Server/Monitoring/Connz.cs +++ b/src/NATS.Server/Monitoring/Connz.cs @@ -163,9 +163,13 @@ public sealed class SubDetail public ulong Cid { get; set; } } +// --------------------------------------------------------------------------- +// Task 84 — account-scoped filter API (Gap 10.2) +// --------------------------------------------------------------------------- + /// /// Lightweight connection info record used by the account-scoped filter API. -/// Decoupled from so filter logic can be tested without a running server. +/// Decoupled from ConnInfo so filter logic can be tested without a running server. /// Go reference: monitor.go ConnInfo (subset of fields relevant to account filtering). /// public sealed record ConnzConnectionInfo( @@ -180,7 +184,7 @@ public sealed record ConnzConnectionInfo( long OutBytes); /// -/// Paginated result returned by . +/// Paginated result returned by ConnzFilter.ApplyFilters. /// public sealed record ConnzFilterResult( IReadOnlyList Connections, @@ -188,30 +192,9 @@ public sealed record ConnzFilterResult( int Offset, int Limit); -/// -/// Sort options for the /connz endpoint. -/// Go reference: monitor.go ConnzSortOpt constants. -/// -public enum ConnzSortOption -{ - ConnectionId, - Start, - Subs, - Pending, - MsgsTo, - MsgsFrom, - BytesTo, - BytesFrom, - LastActivity, - Uptime, - Idle, - RTT, -} - /// /// Query-string options for the account-scoped filter API. -/// Parses the ?acc=, ?state=, ?offset=, and ?limit= parameters that the Go server -/// accepts on the /connz endpoint. +/// Parses the ?acc=, ?state=, ?offset=, ?limit=, ?sort=, and ?desc= parameters. /// Go reference: monitor.go ConnzOptions / Connz(). /// public sealed class ConnzFilterOptions @@ -226,28 +209,25 @@ public sealed class ConnzFilterOptions public int Limit { get; init; } = 1024; /// - /// Sort field for connection listing. Default: . + /// Sort field for connection listing. Default: ConnzSortOption.ConnectionId. /// Go reference: monitor.go ConnzOptions.SortBy. /// public ConnzSortOption SortBy { get; init; } = ConnzSortOption.ConnectionId; /// - /// When , reverses the natural sort direction for the chosen - /// option. + /// When true, reverses the natural sort direction for the chosen SortBy option. /// Go reference: monitor.go ConnzOptions.SortBy (descending variant). /// public bool SortDescending { get; init; } /// - /// Parses a raw query string (e.g. "?acc=ACCOUNT&state=open&offset=0&limit=100&sort=bytes_to") - /// into a instance. + /// Parses a raw query string into a ConnzFilterOptions instance. /// public static ConnzFilterOptions Parse(string? queryString) { if (string.IsNullOrEmpty(queryString)) return new ConnzFilterOptions(); - // Strip leading '?' var qs = queryString.TrimStart('?'); string? accountFilter = null; @@ -301,29 +281,25 @@ public sealed class ConnzFilterOptions } /// -/// Pure filtering helper for collections. -/// Provides account-scoped filtering and pagination that mirror the Go server's -/// /connz ?acc= behavior. +/// Pure filtering helper for ConnzConnectionInfo collections. +/// Provides account-scoped filtering and pagination mirroring the Go server /connz ?acc= behaviour. /// Go reference: monitor.go Connz() — account filter branch. /// public static class ConnzFilter { /// - /// Filters to only those whose - /// matches + /// Filters connections to only those whose AccountName matches accountName /// using a case-insensitive ordinal comparison. /// public static IReadOnlyList FilterByAccount( IEnumerable connections, - string accountName) - { - return connections + string accountName) => + connections .Where(c => string.Equals(c.AccountName, accountName, StringComparison.OrdinalIgnoreCase)) .ToList(); - } /// - /// Applies all filters specified in and returns a paginated result. + /// Applies all filters specified in options and returns a paginated result. /// public static ConnzFilterResult ApplyFilters( IEnumerable connections, @@ -343,6 +319,155 @@ public static class ConnzFilter } } +// --------------------------------------------------------------------------- +// Task 85 — sort options for /connz (Gap 10.3) +// --------------------------------------------------------------------------- + +/// +/// Public sort option enum for the /connz endpoint with human-readable names. +/// Go reference: server/monitor_sort_opts.go SortOpt constants. +/// +public enum ConnzSortOption +{ + /// Sort by connection ID (CID). Default. + ConnectionId, + + /// Sort by connection start time (ascending by default). + Start, + + /// Sort by bytes sent to the client (OutBytes), descending by default. + BytesTo, + + /// Sort by bytes received from the client (InBytes), descending by default. + BytesFrom, + + /// Sort by messages sent to the client (OutMsgs), descending by default. + MsgsTo, + + /// Sort by messages received from the client (InMsgs), descending by default. + MsgsFrom, + + /// Sort by subscription count, descending by default. + Subscriptions, + + /// Sort by pending bytes, descending by default. + Pending, + + /// Sort by connection duration (longest first), descending by default. + Uptime, + + /// Sort by idle time (most idle first), descending by default. + Idle, + + /// Sort by last activity time, descending by default. + LastActivity, +} + +/// +/// Provides parsing and sorting utilities for ConnInfo collections based on ConnzSortOption. +/// Go reference: server/monitor_sort_opts.go, server/monitor.go Connz(). +/// +public static class ConnzSorter +{ + /// + /// Parses a query-string sort parameter to ConnzSortOption. + /// Returns ConnzSortOption.ConnectionId for null, empty, or unrecognised values. + /// Go reference: server/monitor_sort_opts.go UnmarshalJSON. + /// + public static ConnzSortOption Parse(string? sortBy) => + sortBy?.ToLowerInvariant() switch + { + "start" => ConnzSortOption.Start, + "bytes_to" => ConnzSortOption.BytesTo, + "bytes_from" => ConnzSortOption.BytesFrom, + "msgs_to" => ConnzSortOption.MsgsTo, + "msgs_from" => ConnzSortOption.MsgsFrom, + "subs" => ConnzSortOption.Subscriptions, + "pending" => ConnzSortOption.Pending, + "uptime" => ConnzSortOption.Uptime, + "idle" => ConnzSortOption.Idle, + "last" => ConnzSortOption.LastActivity, + _ => ConnzSortOption.ConnectionId, + }; + + /// + /// Sorts a collection of ConnInfo records by the specified option and direction. + /// Traffic/activity metrics default to descending (highest first); ConnectionId and Start + /// default to ascending. Setting descending=true reverses the default. + /// Go reference: monitor.go Connz() sort switch. + /// + public static IReadOnlyList Sort( + IEnumerable connections, + ConnzSortOption sortBy, + bool descending = false) + { + var now = DateTime.UtcNow; + + return sortBy switch + { + ConnzSortOption.Start => + descending + ? [.. connections.OrderByDescending(c => c.Start)] + : [.. connections.OrderBy(c => c.Start)], + + ConnzSortOption.BytesTo => + descending + ? [.. connections.OrderBy(c => c.OutBytes)] + : [.. connections.OrderByDescending(c => c.OutBytes)], + + ConnzSortOption.BytesFrom => + descending + ? [.. connections.OrderBy(c => c.InBytes)] + : [.. connections.OrderByDescending(c => c.InBytes)], + + ConnzSortOption.MsgsTo => + descending + ? [.. connections.OrderBy(c => c.OutMsgs)] + : [.. connections.OrderByDescending(c => c.OutMsgs)], + + ConnzSortOption.MsgsFrom => + descending + ? [.. connections.OrderBy(c => c.InMsgs)] + : [.. connections.OrderByDescending(c => c.InMsgs)], + + ConnzSortOption.Subscriptions => + descending + ? [.. connections.OrderBy(c => c.NumSubs)] + : [.. connections.OrderByDescending(c => c.NumSubs)], + + ConnzSortOption.Pending => + descending + ? [.. connections.OrderBy(c => c.Pending)] + : [.. connections.OrderByDescending(c => c.Pending)], + + ConnzSortOption.Uptime => + descending + ? [.. connections.OrderBy(c => now - c.Start)] + : [.. connections.OrderByDescending(c => now - c.Start)], + + ConnzSortOption.Idle => + descending + ? [.. connections.OrderBy(c => now - c.LastActivity)] + : [.. connections.OrderByDescending(c => now - c.LastActivity)], + + ConnzSortOption.LastActivity => + descending + ? [.. connections.OrderBy(c => c.LastActivity)] + : [.. connections.OrderByDescending(c => c.LastActivity)], + + // ConnectionId and default: ascending by CID + _ => + descending + ? [.. connections.OrderByDescending(c => c.Cid)] + : [.. connections.OrderBy(c => c.Cid)], + }; + } +} + +// --------------------------------------------------------------------------- +// Internal types — used by ConnzHandler and existing sort logic +// --------------------------------------------------------------------------- + /// /// Sort options for connection listing. /// Corresponds to Go server/monitor_sort_opts.go SortOpt type. @@ -414,24 +539,3 @@ public sealed class ConnzOptions public int Limit { get; set; } = 1024; } - -/// -/// Parses sort query-string values for the /connz endpoint. -/// Go reference: monitor.go ConnzSortOpt string constants. -/// -public static class ConnzSorter -{ - public static ConnzSortOption Parse(string value) => value.ToLowerInvariant() switch - { - "start" or "start_time" => ConnzSortOption.Start, - "subs" => ConnzSortOption.Subs, - "pending" => ConnzSortOption.Pending, - "msgs_to" or "msgs_from" => ConnzSortOption.MsgsTo, - "bytes_to" or "bytes_from" => ConnzSortOption.BytesTo, - "last" or "last_activity" => ConnzSortOption.LastActivity, - "uptime" => ConnzSortOption.Uptime, - "idle" => ConnzSortOption.Idle, - "rtt" => ConnzSortOption.RTT, - _ => ConnzSortOption.ConnectionId, - }; -} diff --git a/tests/NATS.Server.Tests/Monitoring/ConnzSortTests.cs b/tests/NATS.Server.Tests/Monitoring/ConnzSortTests.cs new file mode 100644 index 0000000..987bfee --- /dev/null +++ b/tests/NATS.Server.Tests/Monitoring/ConnzSortTests.cs @@ -0,0 +1,179 @@ +using NATS.Server.Monitoring; + +namespace NATS.Server.Tests.Monitoring; + +/// +/// Unit tests for ConnzSortOption enum, ConnzSorter.Parse, and ConnzSorter.Sort. +/// All tests exercise the types in isolation — no running server required. +/// Go reference: monitor_test.go — TestConnzSortedByCid, TestConnzSortedByBytesTo, +/// TestConnzSortedByMsgsFrom, TestConnzSortedByStart, monitor_sort_opts.go. +/// +public class ConnzSortTests +{ + // ----------------------------------------------------------------------- + // Helper — build a ConnInfo with specific field values + // ----------------------------------------------------------------------- + + private static ConnInfo MakeConn( + ulong cid = 1, + long outBytes = 0, + long inBytes = 0, + long outMsgs = 0, + long inMsgs = 0, + uint numSubs = 0, + int pending = 0, + DateTime connectedAt = default, + DateTime lastActivity = default) => + new() + { + Cid = cid, + OutBytes = outBytes, + InBytes = inBytes, + OutMsgs = outMsgs, + InMsgs = inMsgs, + NumSubs = numSubs, + Pending = pending, + Start = connectedAt == default ? DateTime.UtcNow : connectedAt, + LastActivity = lastActivity == default ? DateTime.UtcNow : lastActivity, + }; + + // ----------------------------------------------------------------------- + // ConnzSorter.Parse tests + // ----------------------------------------------------------------------- + + [Fact] + public void Parse_ConnectionId_Default() + { + // Go reference: monitor_sort_opts.go — empty/unknown value defaults to CID sort + ConnzSorter.Parse(null).ShouldBe(ConnzSortOption.ConnectionId); + ConnzSorter.Parse("").ShouldBe(ConnzSortOption.ConnectionId); + ConnzSorter.Parse(" ").ShouldBe(ConnzSortOption.ConnectionId); + ConnzSorter.Parse("cid").ShouldBe(ConnzSortOption.ConnectionId); + } + + [Fact] + public void Parse_BytesTo_Parsed() + { + // Go reference: monitor_sort_opts.go — "bytes_to" => ByBytesTo + ConnzSorter.Parse("bytes_to").ShouldBe(ConnzSortOption.BytesTo); + ConnzSorter.Parse("BYTES_TO").ShouldBe(ConnzSortOption.BytesTo); + } + + [Fact] + public void Parse_MsgsFrom_Parsed() + { + // Go reference: monitor_sort_opts.go — "msgs_from" => ByMsgsFrom + ConnzSorter.Parse("msgs_from").ShouldBe(ConnzSortOption.MsgsFrom); + ConnzSorter.Parse("MSGS_FROM").ShouldBe(ConnzSortOption.MsgsFrom); + } + + [Fact] + public void Parse_Start_Parsed() + { + // Go reference: monitor_sort_opts.go — "start" => ByStart + ConnzSorter.Parse("start").ShouldBe(ConnzSortOption.Start); + ConnzSorter.Parse("START").ShouldBe(ConnzSortOption.Start); + } + + [Fact] + public void Parse_Unknown_ReturnsDefault() + { + // Go reference: monitor_sort_opts.go — unrecognised string falls back to CID + ConnzSorter.Parse("not_a_sort_key").ShouldBe(ConnzSortOption.ConnectionId); + ConnzSorter.Parse("xyz").ShouldBe(ConnzSortOption.ConnectionId); + ConnzSorter.Parse("RANDOM").ShouldBe(ConnzSortOption.ConnectionId); + } + + // ----------------------------------------------------------------------- + // ConnzSorter.Sort tests + // ----------------------------------------------------------------------- + + [Fact] + public void Sort_ByConnectionId_Ascending() + { + // Go reference: monitor_test.go TestConnzSortedByCid — CID ascending is default + var conns = new[] + { + MakeConn(cid: 3), + MakeConn(cid: 1), + MakeConn(cid: 2), + }; + + var result = ConnzSorter.Sort(conns, ConnzSortOption.ConnectionId); + + result.Count.ShouldBe(3); + result[0].Cid.ShouldBe(1UL); + result[1].Cid.ShouldBe(2UL); + result[2].Cid.ShouldBe(3UL); + } + + [Fact] + public void Sort_ByBytesTo_Descending() + { + // Go reference: monitor_test.go TestConnzSortedByBytesTo — OutBytes descending + var conns = new[] + { + MakeConn(cid: 1, outBytes: 100), + MakeConn(cid: 2, outBytes: 300), + MakeConn(cid: 3, outBytes: 200), + }; + + var result = ConnzSorter.Sort(conns, ConnzSortOption.BytesTo); + + result.Count.ShouldBe(3); + result[0].OutBytes.ShouldBe(300); + result[1].OutBytes.ShouldBe(200); + result[2].OutBytes.ShouldBe(100); + } + + [Fact] + public void Sort_ByStart_Ascending() + { + // Go reference: monitor_test.go TestConnzSortedByStart — ConnectedAt ascending + var t0 = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var t1 = t0.AddMinutes(1); + var t2 = t0.AddMinutes(2); + + var conns = new[] + { + MakeConn(cid: 1, connectedAt: t2), + MakeConn(cid: 2, connectedAt: t0), + MakeConn(cid: 3, connectedAt: t1), + }; + + var result = ConnzSorter.Sort(conns, ConnzSortOption.Start); + + result.Count.ShouldBe(3); + result[0].Start.ShouldBe(t0); + result[1].Start.ShouldBe(t1); + result[2].Start.ShouldBe(t2); + } + + [Fact] + public void Sort_ByMsgsFrom_Descending() + { + // Go reference: monitor_test.go TestConnzSortedByMsgsFrom — InMsgs descending + var conns = new[] + { + MakeConn(cid: 1, inMsgs: 50), + MakeConn(cid: 2, inMsgs: 200), + MakeConn(cid: 3, inMsgs: 10), + }; + + var result = ConnzSorter.Sort(conns, ConnzSortOption.MsgsFrom); + + result.Count.ShouldBe(3); + result[0].InMsgs.ShouldBe(200); + result[1].InMsgs.ShouldBe(50); + result[2].InMsgs.ShouldBe(10); + } + + [Fact] + public void Sort_EmptyList_ReturnsEmpty() + { + // Sorting an empty input should not throw and should return an empty list. + var result = ConnzSorter.Sort([], ConnzSortOption.BytesTo); + + result.ShouldBeEmpty(); + } +}