|
- {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..74fb0f94
--- /dev/null
+++ b/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.test.ts
@@ -0,0 +1,91 @@
+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": {
+ host: "127.0.0.1",
+ port: 7000,
+ tls: false,
+ verifyTlsCertificate: false,
+ 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": {
+ 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: [],
+ },
+ }
+
+ 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..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(),
+ 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)
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
|