Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
37b7b1a
feat(cluster): preserve independent replication identity
yordis Sep 21, 2026
e322bb8
fix(cluster): preserve replication identity for monitoring
yordis Sep 21, 2026
9a1a9fc
fix(cluster): advertise the replication identity
yordis Sep 21, 2026
28f5203
feat(cluster): isolate internal replication traffic
yordis Sep 21, 2026
09439b0
fix(monitoring): match isolated replication members
yordis Sep 21, 2026
bcefb75
chore(transport): retire the internal TCP runtime
yordis Sep 21, 2026
2e91033
fix(tests): prove read-only delete rejection
yordis Sep 21, 2026
2e33e61
fix(tests): follow the gRPC replication endpoint
yordis Sep 21, 2026
43d6ce1
fix(tests): follow the gRPC membership matcher
yordis Sep 21, 2026
d79424b
fix(protocol): keep retired API out of the lockfile
yordis Sep 21, 2026
f14644b
feat(monitoring): preserve connection visibility over gRPC
yordis Sep 21, 2026
1a63389
fix(monitoring): preserve queue visibility
yordis Sep 21, 2026
834235c
fix(monitoring): report replication status independently
yordis Sep 21, 2026
1c35ce7
fix(cluster): avoid drift from gRPC service contracts
yordis Sep 22, 2026
5b387fb
fix(tests): preserve read-only replica write coverage
yordis Sep 22, 2026
a80b6af
chore(transport): align with merged cluster endpoint contract
yordis Sep 22, 2026
fc98ac5
fix(grpc): prevent orphaned subscriptions on disconnect
yordis Sep 23, 2026
1e0b002
fix(grpc): preserve forwarded authentication failures
yordis Sep 23, 2026
d13926c
fix(monitoring): preserve connection visibility across endpoint changes
yordis Sep 23, 2026
7430d58
fix(tests): keep forwarded authentication coverage in CI
yordis Sep 23, 2026
bfe81b0
fix(monitoring): retain verified authentication coverage
yordis Sep 23, 2026
bcb371f
chore(monitoring): guard against stale connection visibility
yordis Sep 23, 2026
5c1281e
fix(monitoring): preserve connection visibility during queue failures
yordis Sep 23, 2026
ec97ff0
fix(monitoring): distinguish unavailable from independent connection …
yordis Sep 23, 2026
8eadee8
fix(monitoring): restrict operational diagnostics to authorized callers
yordis Sep 23, 2026
df93d11
fix(monitoring): preserve independent replication diagnostics on queu…
yordis Sep 23, 2026
c3b1ff7
fix(monitoring): protect replication diagnostics with their own permi…
yordis Sep 23, 2026
1692c4f
chore(monitoring): align diagnostics with merged transport parity
yordis Sep 24, 2026
7133fb0
fix(monitoring): preserve replication failure visibility
yordis Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Verify dashboard behavior
run: node --test src/EventStore.ClusterNode/tests/queue-dashboard.test.js
- name: Install net10.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
Expand Down
37 changes: 25 additions & 12 deletions proto.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1554,21 +1554,21 @@
]
},
{
"name": "TcpStatsReq"
"name": "ConnectionStatsReq"
},
{
"name": "TcpStatsResp",
"name": "ConnectionStatsResp",
"fields": [
{
"id": 1,
"name": "connections",
"type": "TcpConnectionStats",
"type": "ConnectionStats",
"is_repeated": true
}
]
},
{
"name": "TcpConnectionStats",
"name": "ConnectionStats",
"fields": [
{
"id": 1,
Expand Down Expand Up @@ -1603,22 +1603,32 @@
{
"id": 7,
"name": "pending_send_bytes",
"type": "int32"
"type": "int64"
},
{
"id": 8,
"name": "pending_received_bytes",
"type": "int32"
"type": "int64"
},
{
"id": 9,
"name": "is_external_connection",
"name": "is_tls",
"type": "bool"
},
{
"id": 10,
"name": "is_ssl_connection",
"type": "bool"
"name": "protocol",
"type": "string"
},
{
"id": 11,
"name": "application",
"type": "string"
},
{
"id": 12,
"name": "connected_at",
"type": "google.protobuf.Timestamp"
}
]
},
Expand Down Expand Up @@ -1693,9 +1703,9 @@
"out_streamed": true
},
{
"name": "TcpStats",
"in_type": "TcpStatsReq",
"out_type": "TcpStatsResp"
"name": "ConnectionStats",
"in_type": "ConnectionStatsReq",
"out_type": "ConnectionStatsResp"
},
{
"name": "ReplicationStats",
Expand All @@ -1708,6 +1718,9 @@
"imports": [
{
"path": "google/protobuf/struct.proto"
},
{
"path": "google/protobuf/timestamp.proto"
}
],
"package": {
Expand Down
222 changes: 134 additions & 88 deletions src/EventStore.ClusterNode/Components/Pages/Observability.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<div>
<p class="text-sm font-black uppercase tracking-[0.28em] text-es-green">Observability</p>
<h1 class="mt-4 text-5xl font-black tracking-tight text-es-ink sm:text-6xl">A map of the signals the node already emits.</h1>
<p class="mt-5 text-lg leading-8 text-es-muted">Inspect live queue pressure, processing throughput, health, metrics, replication, TCP, and grouped runtime statistics.</p>
<p class="mt-5 text-lg leading-8 text-es-muted">Inspect live queue pressure, processing throughput, health, metrics, replication, and grouped runtime statistics.</p>
</div>
<div class="grid gap-3 rounded-[2rem] border border-white/80 bg-white/85 p-6 shadow-[0_20px_70px_rgba(23,32,51,0.09)] sm:grid-cols-2">
<MetricCard Label="Queues" Value="@(Page?.QueueCountLabel ?? "-")" QueueMetric="queue-count" />
Expand All @@ -20,6 +20,116 @@
</div>
</section>

<section class="mt-8">
<div class="mb-4 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-black uppercase tracking-[0.22em] text-es-green">Network boundary</p>
<h2 class="mt-2 text-3xl font-black tracking-tight text-es-ink">Shared HTTP and gRPC connections</h2>
<p class="mt-2 text-sm text-es-muted">Active connections accepted by the node endpoint, including per-second traffic rates and pending bytes.</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="rounded-full border border-es-green/20 bg-es-green/10 px-4 py-2 text-sm font-bold text-es-forest" data-network-status>@NetworkStatusLabel</span>
<span class="rounded-full border border-es-ink/10 bg-white px-4 py-2 text-sm font-bold text-es-muted" data-network-page-status>@NetworkPageStatusLabel</span>
<button type="button" class="rounded-full border border-es-ink/10 bg-white px-4 py-2 text-sm font-bold text-es-ink transition hover:border-es-green/30 hover:text-es-green disabled:cursor-not-allowed disabled:opacity-40" data-network-page="previous" disabled>Previous</button>
<button type="button" class="rounded-full border border-es-ink/10 bg-white px-4 py-2 text-sm font-bold text-es-ink transition hover:border-es-green/30 hover:text-es-green disabled:cursor-not-allowed disabled:opacity-40" data-network-page="next" disabled>Next</button>
</div>
</div>

<div class="overflow-hidden rounded-[2rem] border border-white/80 bg-white/85 shadow-[0_20px_70px_rgba(23,32,51,0.09)]">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-es-ink/10 text-left text-sm">
<thead class="bg-es-ink/5 text-xs font-black uppercase tracking-[0.18em] text-es-muted">
<tr>
<th class="px-5 py-4">Connection</th>
<th class="px-5 py-4">Client</th>
<th class="px-5 py-4">Type</th>
<th class="px-5 py-4">Remote endpoint</th>
<th class="px-5 py-4 text-right">Sent rate</th>
<th class="px-5 py-4 text-right">Sent current</th>
<th class="px-5 py-4 text-right">Sent pending</th>
<th class="px-5 py-4 text-right">Received rate</th>
<th class="px-5 py-4 text-right">Received current</th>
<th class="px-5 py-4 text-right">Received pending</th>
</tr>
</thead>
<tbody class="divide-y divide-es-ink/10" data-network-table-body>
@if (NetworkRows.Count == 0)
{
<tr><td class="px-5 py-4 text-es-muted" colspan="10">@NetworkEmptyMessage</td></tr>
}
else
{
foreach (var connection in NetworkRows.Take(5))
{
<tr>
<td class="px-5 py-4 font-mono text-xs text-es-muted">@connection.ConnectionId</td>
<td class="px-5 py-4 font-bold text-es-ink">@Display(connection.ClientName)</td>
<td class="px-5 py-4 text-es-muted">@connection.Application · @Display(connection.Protocol) · @(connection.IsTls ? "TLS" : "Cleartext")</td>
<td class="px-5 py-4 font-mono text-xs text-es-muted" title="@ConnectionDetails(connection)">@connection.RemoteEndPoint</td>
<td class="px-5 py-4 text-right font-mono text-es-muted">Waiting</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@FormatBytes(connection.TotalBytesSent)</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@FormatBytes(connection.PendingSendBytes)</td>
<td class="px-5 py-4 text-right font-mono text-es-muted">Waiting</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@FormatBytes(connection.TotalBytesReceived)</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@FormatBytes(connection.PendingReceivedBytes)</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</section>

<section class="mt-8">
<div class="mb-4">
<p class="text-xs font-black uppercase tracking-[0.22em] text-es-green">Cluster transport</p>
<h2 class="mt-2 text-3xl font-black tracking-tight text-es-ink">gRPC replication connections</h2>
<p class="mt-2 text-sm text-es-muted">Live database replication sessions on the shared HTTP/2 endpoint.</p>
</div>

<div class="overflow-hidden rounded-[2rem] border border-white/80 bg-white/85 shadow-[0_20px_70px_rgba(23,32,51,0.09)]">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-es-ink/10 text-left text-sm">
<thead class="bg-es-ink/5 text-xs font-black uppercase tracking-[0.18em] text-es-muted">
<tr>
<th class="px-5 py-4">Endpoint</th>
<th class="px-5 py-4">Connection</th>
<th class="px-5 py-4 text-right">Sent</th>
<th class="px-5 py-4 text-right">Received</th>
<th class="px-5 py-4 text-right">Pending</th>
<th class="px-5 py-4 text-right">Send queue</th>
</tr>
</thead>
<tbody class="divide-y divide-es-ink/10" data-replication-table-body>
@if (Page is not null && !string.IsNullOrWhiteSpace(Page.ReplicationMessage))
{
<tr><td class="px-5 py-4 text-es-muted" colspan="6">@Page.ReplicationMessage</td></tr>
}
else if (Page is not null && Page.ReplicationConnections.Count > 0)
{
@foreach (var connection in Page.ReplicationConnections)
{
<tr>
<td class="px-5 py-4 font-bold text-es-ink">@connection.Endpoint</td>
<td class="px-5 py-4 font-mono text-xs text-es-muted" title="Subscription @connection.SubscriptionId">@connection.ConnectionId</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@FormatBytes(connection.TotalBytesSent)</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@FormatBytes(connection.TotalBytesReceived)</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@FormatBytes(connection.PendingSendBytes + connection.PendingReceivedBytes)</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@connection.SendQueueSize.ToString("N0", CultureInfo.InvariantCulture)</td>
</tr>
}
}
else
{
<tr><td class="px-5 py-4 text-es-muted" colspan="6">No active gRPC replication connections.</td></tr>
}
</tbody>
</table>
</div>
</div>
</section>

<section class="mt-8">
<details id="dashboard-snapshot" class="rounded-[2rem] border border-white/80 bg-white/85 p-6 shadow-[0_20px_70px_rgba(23,32,51,0.09)]">
<summary class="cursor-pointer text-sm font-black uppercase tracking-[0.22em] text-es-green">Dashboard snapshot</summary>
Expand Down Expand Up @@ -87,58 +197,6 @@
}
</section>

<section class="mt-8">
<div class="mb-4 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-black uppercase tracking-[0.22em] text-es-green">TCP dashboard</p>
<h2 class="mt-2 text-3xl font-black tracking-tight text-es-ink">Realtime connections</h2>
<p class="mt-2 text-sm text-es-muted">Shows active connections with per-second sent and received byte rates.</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="rounded-full border border-es-green/20 bg-es-green/10 px-4 py-2 text-sm font-bold text-es-forest" data-tcp-status>@TcpStatusLabel</span>
<span class="rounded-full border border-es-ink/10 bg-white px-4 py-2 text-sm font-bold text-es-muted" data-tcp-page-status>@TcpPageStatusLabel</span>
<button type="button" class="rounded-full border border-es-ink/10 bg-white px-4 py-2 text-sm font-bold text-es-ink transition hover:border-es-green/30 hover:text-es-green disabled:cursor-not-allowed disabled:opacity-40" data-tcp-page="previous" disabled>Previous</button>
<button type="button" class="rounded-full border border-es-ink/10 bg-white px-4 py-2 text-sm font-bold text-es-ink transition hover:border-es-green/30 hover:text-es-green disabled:cursor-not-allowed disabled:opacity-40" data-tcp-page="next" disabled>Next</button>
</div>
</div>

<div class="overflow-hidden rounded-[2rem] border border-white/80 bg-white/85 shadow-[0_20px_70px_rgba(23,32,51,0.09)]">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-es-ink/10 text-left text-sm">
<thead class="bg-es-ink/5 text-xs font-black uppercase tracking-[0.18em] text-es-muted">
<tr>
<th class="px-5 py-4">Connection</th>
<th class="px-5 py-4">Client</th>
<th class="px-5 py-4">Type</th>
<th class="px-5 py-4">IP Address</th>
<th class="px-5 py-4 text-right">Sent rate</th>
<th class="px-5 py-4 text-right">Sent current</th>
<th class="px-5 py-4 text-right">Sent pending</th>
<th class="px-5 py-4 text-right">Received rate</th>
<th class="px-5 py-4 text-right">Received current</th>
<th class="px-5 py-4 text-right">Received pending</th>
</tr>
</thead>
<tbody class="divide-y divide-es-ink/10" data-tcp-table-body>
@if (TcpRows.Count == 0)
{
<tr>
<td class="px-5 py-4 text-es-muted" colspan="10">@TcpEmptyMessage</td>
</tr>
}
else
{
foreach (var connection in TcpRows.Take(5))
{
@RenderTcpRow(connection)
}
}
</tbody>
</table>
</div>
</div>
</section>

</div>

@code {
Expand All @@ -148,29 +206,30 @@
private string Expanded { get; set; } = "";

private string DashboardPayloadJson => Page?.ClientPayloadJson ?? "{}";
private IReadOnlyList<TcpConnectionRow> TcpRows => Page?.TcpConnections ?? Array.Empty<TcpConnectionRow>();
private string TcpErrorMessage => !string.IsNullOrWhiteSpace(Page?.TcpMessage)
? Page.TcpMessage
: Page?.Message ?? "";
private string TcpStatusLabel {
get {
if (Page is null)
return "Connecting TCP stats...";

if (!string.IsNullOrWhiteSpace(TcpErrorMessage))
return "TCP unavailable";

return TcpRows.Count == 0
? "TCP live"
: string.Create(CultureInfo.InvariantCulture, $"TCP live · {TcpRows.Count} connection{(TcpRows.Count == 1 ? "" : "s")}");
}
}
private string TcpPageStatusLabel => TcpRows.Count == 0
private IReadOnlyList<NodeConnectionSnapshot> NetworkRows =>
Page?.NodeConnections ?? Array.Empty<NodeConnectionSnapshot>();
private string NetworkStatusLabel => Page is null
? "Connecting network stats..."
: !Page.NetworkAvailable
? "Network unavailable"
: NetworkRows.Count == 0
? "Network live"
: string.Create(CultureInfo.InvariantCulture, $"Network live · {NetworkRows.Count} connection{(NetworkRows.Count == 1 ? "" : "s")}");
private string NetworkEmptyMessage => Page is null
? "Waiting for network statistics..."
: Page.NetworkAvailable
? "No active shared-endpoint connections."
: "Network statistics are unavailable.";
private string NetworkPageStatusLabel => NetworkRows.Count == 0
? "No pages"
: string.Create(CultureInfo.InvariantCulture, $"Page 1 of {Math.Max(1, (int)Math.Ceiling(TcpRows.Count / 5.0))}");
private string TcpEmptyMessage => string.IsNullOrWhiteSpace(TcpErrorMessage)
? "No TCP connections are currently reported."
: TcpErrorMessage;
: string.Create(CultureInfo.InvariantCulture, $"Page 1 of {Math.Max(1, (int)Math.Ceiling(NetworkRows.Count / 5.0))}");

private static string FormatBytes(long value) =>
value.ToString("N0", CultureInfo.InvariantCulture) + " B";
private static string Display(string value) => string.IsNullOrWhiteSpace(value) ? "<none>" : value;
private static string ConnectionDetails(NodeConnectionSnapshot connection) =>
string.Create(CultureInfo.InvariantCulture,
$"Local {connection.LocalEndPoint}, connected {connection.ConnectedAt:u}");

protected override async Task OnParametersSetAsync() {
Page = null;
Expand Down Expand Up @@ -216,19 +275,6 @@
<td class="px-5 py-4 font-mono text-xs text-es-muted">@row.CurrentLastMessageLabel</td>
</tr>;

private RenderFragment RenderTcpRow(TcpConnectionRow connection) => @<tr class="bg-white/70 text-es-ink">
<td class="max-w-[14rem] truncate px-5 py-4 font-mono text-xs text-es-muted">@connection.IdLabel</td>
<td class="px-5 py-4 font-bold text-es-ink">@connection.ClientLabel</td>
<td class="px-5 py-4 text-es-muted">@connection.TypeLabel</td>
<td class="px-5 py-4 font-mono text-xs text-es-muted">@connection.RemoteEndPointLabel</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@connection.SentRateLabel</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@connection.TotalBytesSentLabel</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@connection.PendingSendBytesLabel</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@connection.ReceivedRateLabel</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@connection.TotalBytesReceivedLabel</td>
<td class="px-5 py-4 text-right font-mono text-es-ink">@connection.PendingReceivedBytesLabel</td>
</tr>;

private bool IsExpanded(string groupName) =>
ParseExpandedGroups().Contains(groupName);

Expand Down
Loading
Loading