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<ConnInfo>,
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().
This commit is contained in:
Joseph Doherty
2026-02-25 13:09:11 -05:00
parent 9ece600ebc
commit 619acc3c08
2 changed files with 344 additions and 61 deletions

View File

@@ -163,9 +163,13 @@ public sealed class SubDetail
public ulong Cid { get; set; }
}
// ---------------------------------------------------------------------------
// Task 84 — account-scoped filter API (Gap 10.2)
// ---------------------------------------------------------------------------
/// <summary>
/// Lightweight connection info record used by the account-scoped filter API.
/// Decoupled from <see cref="ConnInfo"/> 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).
/// </summary>
public sealed record ConnzConnectionInfo(
@@ -180,7 +184,7 @@ public sealed record ConnzConnectionInfo(
long OutBytes);
/// <summary>
/// Paginated result returned by <see cref="ConnzFilter.ApplyFilters"/>.
/// Paginated result returned by ConnzFilter.ApplyFilters.
/// </summary>
public sealed record ConnzFilterResult(
IReadOnlyList<ConnzConnectionInfo> Connections,
@@ -188,30 +192,9 @@ public sealed record ConnzFilterResult(
int Offset,
int Limit);
/// <summary>
/// Sort options for the /connz endpoint.
/// Go reference: monitor.go ConnzSortOpt constants.
/// </summary>
public enum ConnzSortOption
{
ConnectionId,
Start,
Subs,
Pending,
MsgsTo,
MsgsFrom,
BytesTo,
BytesFrom,
LastActivity,
Uptime,
Idle,
RTT,
}
/// <summary>
/// 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().
/// </summary>
public sealed class ConnzFilterOptions
@@ -226,28 +209,25 @@ public sealed class ConnzFilterOptions
public int Limit { get; init; } = 1024;
/// <summary>
/// Sort field for connection listing. Default: <see cref="ConnzSortOption.ConnectionId"/>.
/// Sort field for connection listing. Default: ConnzSortOption.ConnectionId.
/// Go reference: monitor.go ConnzOptions.SortBy.
/// </summary>
public ConnzSortOption SortBy { get; init; } = ConnzSortOption.ConnectionId;
/// <summary>
/// When <see langword="true"/>, reverses the natural sort direction for the chosen
/// <see cref="SortBy"/> option.
/// When true, reverses the natural sort direction for the chosen SortBy option.
/// Go reference: monitor.go ConnzOptions.SortBy (descending variant).
/// </summary>
public bool SortDescending { get; init; }
/// <summary>
/// Parses a raw query string (e.g. "?acc=ACCOUNT&amp;state=open&amp;offset=0&amp;limit=100&amp;sort=bytes_to")
/// into a <see cref="ConnzFilterOptions"/> instance.
/// Parses a raw query string into a ConnzFilterOptions instance.
/// </summary>
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
}
/// <summary>
/// Pure filtering helper for <see cref="ConnzConnectionInfo"/> 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.
/// </summary>
public static class ConnzFilter
{
/// <summary>
/// Filters <paramref name="connections"/> to only those whose
/// <see cref="ConnzConnectionInfo.AccountName"/> matches <paramref name="accountName"/>
/// Filters connections to only those whose AccountName matches accountName
/// using a case-insensitive ordinal comparison.
/// </summary>
public static IReadOnlyList<ConnzConnectionInfo> FilterByAccount(
IEnumerable<ConnzConnectionInfo> connections,
string accountName)
{
return connections
string accountName) =>
connections
.Where(c => string.Equals(c.AccountName, accountName, StringComparison.OrdinalIgnoreCase))
.ToList();
}
/// <summary>
/// Applies all filters specified in <paramref name="options"/> and returns a paginated result.
/// Applies all filters specified in options and returns a paginated result.
/// </summary>
public static ConnzFilterResult ApplyFilters(
IEnumerable<ConnzConnectionInfo> connections,
@@ -343,6 +319,155 @@ public static class ConnzFilter
}
}
// ---------------------------------------------------------------------------
// Task 85 — sort options for /connz (Gap 10.3)
// ---------------------------------------------------------------------------
/// <summary>
/// Public sort option enum for the /connz endpoint with human-readable names.
/// Go reference: server/monitor_sort_opts.go SortOpt constants.
/// </summary>
public enum ConnzSortOption
{
/// <summary>Sort by connection ID (CID). Default.</summary>
ConnectionId,
/// <summary>Sort by connection start time (ascending by default).</summary>
Start,
/// <summary>Sort by bytes sent to the client (OutBytes), descending by default.</summary>
BytesTo,
/// <summary>Sort by bytes received from the client (InBytes), descending by default.</summary>
BytesFrom,
/// <summary>Sort by messages sent to the client (OutMsgs), descending by default.</summary>
MsgsTo,
/// <summary>Sort by messages received from the client (InMsgs), descending by default.</summary>
MsgsFrom,
/// <summary>Sort by subscription count, descending by default.</summary>
Subscriptions,
/// <summary>Sort by pending bytes, descending by default.</summary>
Pending,
/// <summary>Sort by connection duration (longest first), descending by default.</summary>
Uptime,
/// <summary>Sort by idle time (most idle first), descending by default.</summary>
Idle,
/// <summary>Sort by last activity time, descending by default.</summary>
LastActivity,
}
/// <summary>
/// Provides parsing and sorting utilities for ConnInfo collections based on ConnzSortOption.
/// Go reference: server/monitor_sort_opts.go, server/monitor.go Connz().
/// </summary>
public static class ConnzSorter
{
/// <summary>
/// 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.
/// </summary>
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,
};
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<ConnInfo> Sort(
IEnumerable<ConnInfo> 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
// ---------------------------------------------------------------------------
/// <summary>
/// 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;
}
/// <summary>
/// Parses sort query-string values for the /connz endpoint.
/// Go reference: monitor.go ConnzSortOpt string constants.
/// </summary>
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,
};
}

View File

@@ -0,0 +1,179 @@
using NATS.Server.Monitoring;
namespace NATS.Server.Tests.Monitoring;
/// <summary>
/// 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.
/// </summary>
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();
}
}