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(); + } +}