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 0e949e29..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` @@ -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..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