From bde3882a4a9883195d2c53db21b46b5dda03a692 Mon Sep 17 00:00:00 2001 From: Raj Gahoi Date: Sun, 18 Oct 2026 14:21:43 +0530 Subject: [PATCH 1/5] feat(topology): show INFO metrics and utilization for replica nodes (#536) Signed-off-by: Raj Gahoi --- .../cluster-topology/cluster-node-row.tsx | 30 +++---- .../cluster/clusterSelectors.test.ts | 85 +++++++++++++++++++ .../cluster/clusterSelectors.ts | 4 +- apps/server/src/set-dashboard-data.ts | 2 +- 4 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 apps/frontend/src/state/valkey-features/cluster/clusterSelectors.test.ts diff --git a/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx b/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx index 0e949e29..03c57a7f 100644 --- a/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx +++ b/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx @@ -202,7 +202,7 @@ export function ClusterNodeRow({ - {role === "primary" && utilizationLevel && ( + {utilizationLevel && ( - {role === "primary" && ( - - {memoryLabel} - - )} + + {memoryLabel} + - {role === "primary" && ( - - {formatPercent(utilization?.cpu_utilization_percent)} - - )} + + {formatPercent(utilization?.cpu_utilization_percent)} + - {role === "primary" && ( - - {nodeData?.instantaneous_ops_per_sec ? formatRate(Number(nodeData.instantaneous_ops_per_sec)) : "—"} - - )} + + {nodeData?.instantaneous_ops_per_sec ? formatRate(Number(nodeData.instantaneous_ops_per_sec)) : "—"} + - {role === "primary" && {hitRatio}} + {hitRatio} - {role === "primary" && {nodeData?.connected_clients ?? "—"}} + {nodeData?.connected_clients ?? "—"} {role === "primary" && ( diff --git a/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.test.ts b/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.test.ts new file mode 100644 index 00000000..39e5bbe6 --- /dev/null +++ b/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest" +import { buildNodeRows, aggregateClusterMetrics } from "./clusterSelectors" +import type { PrimaryNode, NodeUtilization } from "./clusterSlice" + +describe("clusterSelectors", () => { + const mockClusterNodes: Record = { + "node-primary-1": { + id: "node-primary-1", + host: "127.0.0.1", + port: 7000, + slots: "0-5000", + replicas: [ + { + id: "node-replica-1", + host: "127.0.0.1", + port: 7001, + }, + ], + }, + } + + describe("buildNodeRows", () => { + it("builds node rows with primary and its replicas", () => { + const rows = buildNodeRows(mockClusterNodes) + expect(rows).toHaveLength(2) + expect(rows[0]).toMatchObject({ + role: "primary", + host: "127.0.0.1", + port: 7000, + isGroupEnd: false, + }) + expect(rows[1]).toMatchObject({ + role: "replica", + host: "127.0.0.1", + port: 7001, + isGroupEnd: true, + }) + }) + }) + + describe("aggregateClusterMetrics", () => { + it("flags high utilization nodes across both primaries and replicas", () => { + const rows = buildNodeRows(mockClusterNodes) + const mockData = { + "node-primary-1": { + instantaneous_ops_per_sec: "150", + keyspace_hits: "100", + keyspace_misses: "10", + }, + "127-0-0-1-7001": { + instantaneous_ops_per_sec: "50", + keyspace_hits: "40", + keyspace_misses: "5", + }, + } + const mockUtilization: Record = { + "node-primary-1": { + used_memory: 1000, + memory_limit_bytes: 2000, + memory_basis: "maxmemory", + memory_utilization_percent: 50, + cpu_utilization_percent: 85, // high CPU + }, + "127-0-0-1-7001": { + used_memory: 500, + memory_limit_bytes: 2000, + memory_basis: "maxmemory", + memory_utilization_percent: 25, + cpu_utilization_percent: 90, // high CPU on replica + }, + } + + const metrics = aggregateClusterMetrics(rows, mockData, mockUtilization) + + expect(metrics.hasUtilization).toBe(true) + expect(metrics.usedMemory).toBe(1500) + expect(metrics.memoryLimit).toBe(4000) + expect(metrics.opsPerSec).toBe(200) + expect(metrics.hits).toBe(140) + expect(metrics.misses).toBe(15) + // Both primary and replica with high utilization should be flagged + expect(metrics.flaggedNodes).toBe(2) + }) + }) +}) diff --git a/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.ts b/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.ts index 9ff0e520..5cefa137 100644 --- a/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.ts +++ b/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.ts @@ -97,8 +97,8 @@ export const aggregateClusterMetrics = ( metrics.hits += Number(nodeData?.keyspace_hits) || 0 metrics.misses += Number(nodeData?.keyspace_misses) || 0 - // Badges render on primaries only, so replicas must not inflate the count. - if (row.role === "primary" && getNodeUtilizationLevel(nodeUtilization) === "high") { + // Count high utilization nodes across the cluster. + if (getNodeUtilizationLevel(nodeUtilization) === "high") { metrics.flaggedNodes += 1 } } diff --git a/apps/server/src/set-dashboard-data.ts b/apps/server/src/set-dashboard-data.ts index 9d1aa462..696c79db 100644 --- a/apps/server/src/set-dashboard-data.ts +++ b/apps/server/src/set-dashboard-data.ts @@ -153,7 +153,7 @@ export async function setClusterDashboardData( ) { try { const [rawInfo, { clusterNodes, topologyError }] = await Promise.all([ - client.info(), + client.info({ route: "allNodes" }), refreshClusterNodes(clusterId, client, clusterNodesRegistry), ]) const clusterInfo = parseClusterInfo(rawInfo) From 3d71090d55b2f7b1cfbd3d8db6f75b8eef3fc1ed Mon Sep 17 00:00:00 2001 From: Raj Gahoi Date: Sun, 18 Oct 2026 18:07:43 +0530 Subject: [PATCH 2/5] fix(topology): address CodeRabbit review for replica metrics and resilience Signed-off-by: Raj Gahoi --- .../src/components/cluster-topology/Cluster.tsx | 3 +-- .../components/cluster-topology/cluster-node-row.tsx | 2 +- .../valkey-features/cluster/clusterSelectors.test.ts | 10 ++++++++-- apps/server/src/set-dashboard-data.ts | 5 ++++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/components/cluster-topology/Cluster.tsx b/apps/frontend/src/components/cluster-topology/Cluster.tsx index d3bc156c..c18d57a8 100644 --- a/apps/frontend/src/components/cluster-topology/Cluster.tsx +++ b/apps/frontend/src/components/cluster-topology/Cluster.tsx @@ -77,8 +77,7 @@ export function Cluster() { const matchesRole = roleFilter === "all" || row.role === roleFilter const level = getNodeUtilizationLevel(clusterData.utilization?.[row.dataKey]) - const matchesUtilization = utilizationFilter === "all" - || (row.role === "primary" && level === utilizationFilter) + const matchesUtilization = utilizationFilter === "all" || level === utilizationFilter return matchesSearch && matchesRole && matchesUtilization }) diff --git a/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx b/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx index 03c57a7f..77c3f6ce 100644 --- a/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx +++ b/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx @@ -157,7 +157,7 @@ export function ClusterNodeRow({ : "—" const isHostMemoryBasis = utilization?.memory_basis === "total_system_memory" const utilizationLevel = getNodeUtilizationLevel(utilization) - const isFlagged = role === "primary" && utilizationLevel === "high" + const isFlagged = utilizationLevel === "high" const memoryTooltip = hasLimit ? `${formatPercent(utilization?.memory_utilization_percent)} of configured maxmemory` diff --git a/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.test.ts b/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.test.ts index 39e5bbe6..74fb0f94 100644 --- a/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.test.ts +++ b/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.test.ts @@ -5,10 +5,10 @@ import type { PrimaryNode, NodeUtilization } from "./clusterSlice" describe("clusterSelectors", () => { const mockClusterNodes: Record = { "node-primary-1": { - id: "node-primary-1", host: "127.0.0.1", port: 7000, - slots: "0-5000", + tls: false, + verifyTlsCertificate: false, replicas: [ { id: "node-replica-1", @@ -55,18 +55,24 @@ describe("clusterSelectors", () => { } const mockUtilization: Record = { "node-primary-1": { + nodeId: "node-primary-1", used_memory: 1000, memory_limit_bytes: 2000, memory_basis: "maxmemory", memory_utilization_percent: 50, cpu_utilization_percent: 85, // high CPU + cpu_sample_interval_seconds: null, + warnings: [], }, "127-0-0-1-7001": { + nodeId: "127-0-0-1-7001", used_memory: 500, memory_limit_bytes: 2000, memory_basis: "maxmemory", memory_utilization_percent: 25, cpu_utilization_percent: 90, // high CPU on replica + cpu_sample_interval_seconds: null, + warnings: [], }, } diff --git a/apps/server/src/set-dashboard-data.ts b/apps/server/src/set-dashboard-data.ts index 696c79db..9e67abc3 100644 --- a/apps/server/src/set-dashboard-data.ts +++ b/apps/server/src/set-dashboard-data.ts @@ -153,7 +153,10 @@ export async function setClusterDashboardData( ) { try { const [rawInfo, { clusterNodes, topologyError }] = await Promise.all([ - client.info({ route: "allNodes" }), + client.info({ route: "allNodes" }).catch((err) => { + console.error(`Cluster INFO refresh failed for ${clusterId}; using primary-node data:`, err) + return client.info({ route: "allPrimaries" }) + }), refreshClusterNodes(clusterId, client, clusterNodesRegistry), ]) const clusterInfo = parseClusterInfo(rawInfo) From b734bcb77dec2dd20cb59171529aad074baca850 Mon Sep 17 00:00:00 2001 From: Raj Gahoi Date: Sun, 18 Oct 2026 18:17:08 +0530 Subject: [PATCH 3/5] ci: trigger fresh integration run Signed-off-by: Raj Gahoi From 6cdda62f341185c7d7a36a1a46e2f9e41ac45a57 Mon Sep 17 00:00:00 2001 From: Raj Gahoi Date: Sun, 18 Oct 2026 18:22:40 +0530 Subject: [PATCH 4/5] ci(trigger): retrigger integration tests Signed-off-by: Raj Gahoi From b72c3a47a5a37f9de475085f63a542fb11a3c001 Mon Sep 17 00:00:00 2001 From: Raj Gahoi Date: Sun, 18 Oct 2026 18:27:59 +0530 Subject: [PATCH 5/5] docs(cluster-topology): update Nodes Flagged and node display to include replicas Signed-off-by: Raj Gahoi --- docs-site/src/content/docs/features/cluster-topology.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/features/cluster-topology.md b/docs-site/src/content/docs/features/cluster-topology.md index c98aa47f..c2478474 100644 --- a/docs-site/src/content/docs/features/cluster-topology.md +++ b/docs-site/src/content/docs/features/cluster-topology.md @@ -19,7 +19,7 @@ At the top of the page, five summary cards display key cluster metrics: - **Cluster Memory**: Memory used across the cluster, out of the total memory limit - **Total Ops/Sec**: Operations per second across the cluster - **Cluster Hit Ratio**: Share of key lookups that found the key -- **Nodes Flagged**: Number of primaries with **High** utilization +- **Nodes Flagged**: Number of primaries and replicas with **High** utilization ## Node List @@ -29,7 +29,7 @@ Nodes are displayed in a table. Each primary row is followed by rows for its rep ### Node Display -Each node shows its **address** (e.g. `192.168.18.6:7001`) and a `PRIMARY` or `REPLICA` **role badge**. Primary rows also show: +Each node shows its **address** (e.g. `192.168.18.6:7001`) and a `PRIMARY` or `REPLICA` **role badge**. Both primary and replica rows also show: - **Utilization**: `Low`, `Normal` or `High`, based on the higher of memory and CPU usage. Hover the badge for details. See [Utilization Levels](#utilization-levels). - **Memory**: Memory used out of the node's `maxmemory` limit (e.g. `70.81M / 100 MB`), or `∞` when no limit is set