diff --git a/apps/frontend/src/components/cluster-topology/Cluster.tsx b/apps/frontend/src/components/cluster-topology/Cluster.tsx
index c87cdba3..9cb0d4aa 100644
--- a/apps/frontend/src/components/cluster-topology/Cluster.tsx
+++ b/apps/frontend/src/components/cluster-topology/Cluster.tsx
@@ -16,7 +16,7 @@ import { TableContainer } from "../ui/table-container"
import { StaticTableHeader } from "../ui/sortable-table-header"
import { ClusterNodeRow } from "./cluster-node-row"
import type { RootState } from "@/store.ts"
-import { getUtilizationLevel, type UtilizationLevel } from "@/state/valkey-features/cluster/clusterUtilization"
+import { getNodeUtilizationLevel, type UtilizationLevel } from "@/state/valkey-features/cluster/clusterUtilization"
import {
selectCluster, selectClusterNodeRows, selectClusterMetrics
} from "@/state/valkey-features/cluster/clusterSelectors"
@@ -75,8 +75,7 @@ export function Cluster() {
const matchesSearch = !searchQuery || clusterData.searchableText[row.searchKey]?.includes(searchQuery)
const matchesRole = roleFilter === "all" || row.role === roleFilter
- const rowUtilization = clusterData.utilization?.[row.dataKey]
- const level = getUtilizationLevel(rowUtilization?.memory_utilization_percent, rowUtilization?.cpu_utilization_percent)
+ const level = getNodeUtilizationLevel(clusterData.utilization?.[row.dataKey])
const matchesUtilization = utilizationFilter === "all"
|| (row.role === "primary" && level === utilizationFilter)
@@ -110,7 +109,7 @@ export function Cluster() {
setSearchQuery(e.target.value.toLowerCase())}
- placeholder="Search nodes by name, host, or port..."
+ placeholder="Search nodes by host, or port..."
value={searchQuery}
/>
@@ -193,7 +190,7 @@ export function ClusterNodeRow({
{UTILIZATION_BADGE[utilizationLevel].label}
diff --git a/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.ts b/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.ts
index ff55615b..9ff0e520 100644
--- a/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.ts
+++ b/apps/frontend/src/state/valkey-features/cluster/clusterSelectors.ts
@@ -2,7 +2,7 @@ import { createSelector } from "@reduxjs/toolkit"
import { VALKEY } from "@common/src/constants.ts"
import { sanitizeUrl } from "@common/src/url-utils.ts"
import * as R from "ramda"
-import { getUtilizationLevel } from "./clusterUtilization"
+import { getNodeUtilizationLevel, hasMemoryLimit } from "./clusterUtilization"
import type { NodeRow, ParsedNodeInfo, NodeUtilization, PrimaryNode } from "./clusterSlice"
import type { RootState } from "@/store.ts"
@@ -82,28 +82,29 @@ export const aggregateClusterMetrics = (
hasUtilization: false,
}
+ let hasUnboundedNode = false
+
for (const row of nodeRows) {
const nodeData = data[row.dataKey]
const nodeUtilization = utilization[row.dataKey]
if (nodeUtilization) metrics.hasUtilization = true
+ if (nodeUtilization && !hasMemoryLimit(nodeUtilization)) hasUnboundedNode = true
metrics.usedMemory += nodeUtilization?.used_memory ?? 0
- metrics.memoryLimit += nodeUtilization?.memory_limit_bytes ?? 0
+ metrics.memoryLimit += hasMemoryLimit(nodeUtilization) ? nodeUtilization?.memory_limit_bytes ?? 0 : 0
metrics.opsPerSec += Number(nodeData?.instantaneous_ops_per_sec) || 0
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"
- && getUtilizationLevel(
- nodeUtilization?.memory_utilization_percent,
- nodeUtilization?.cpu_utilization_percent,
- ) === "high") {
+ if (row.role === "primary" && getNodeUtilizationLevel(nodeUtilization) === "high") {
metrics.flaggedNodes += 1
}
}
+ if (hasUnboundedNode) metrics.memoryLimit = 0
+
return metrics
}
diff --git a/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts b/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts
index 15ca494b..afdcc7df 100644
--- a/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts
+++ b/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts
@@ -1,4 +1,4 @@
-import { createAction, createSlice } from "@reduxjs/toolkit"
+import { createAction, createSlice, current } from "@reduxjs/toolkit"
import * as R from "ramda"
export interface ReplicaNode {
@@ -34,7 +34,6 @@ export interface NodeRow {
}
export interface ParsedNodeInfo {
- server_name: string | null;
uptime_in_days: string | null;
tcp_port: string | null;
used_memory_human: string | null;
@@ -109,12 +108,15 @@ const clusterSlice = createSlice({
delete state.clusters[action.payload.clusterId]
},
setClusterData: (state, action) => {
- const { clusterId, info, utilization } = action.payload
+ const { clusterId, info, utilization, clusterNodes } = action.payload
if (!state.clusters[clusterId]) return
+ if (clusterNodes && !R.equals(current(state.clusters[clusterId].clusterNodes), clusterNodes)) {
+ state.clusters[clusterId].clusterNodes = clusterNodes
+ }
+
const parseNodeInfo = R.applySpec({
- server_name: R.path(["Server", "server_name"]),
uptime_in_days: R.path(["Server", "uptime_in_days"]),
tcp_port: R.path(["Server", "tcp_port"]),
used_memory_human: R.path(["Memory", "used_memory_human"]),
@@ -140,22 +142,18 @@ const clusterSlice = createSlice({
// Precompute searchable text for both primaries and replicas
const searchableText: Record = {}
for (const [primaryKey, primary] of Object.entries(state.clusters[clusterId].clusterNodes)) {
- const primaryData = result[primaryKey]
searchableText[primaryKey] = [
primaryKey,
primary.host,
primary.port.toString(),
- primaryData?.server_name || "",
].join(" ").toLowerCase()
for (const replica of primary.replicas) {
const replicaKey = `${replica.host}:${replica.port}`
- const replicaData = result[replicaKey]
searchableText[replicaKey] = [
replicaKey,
replica.host,
replica.port.toString(),
- replicaData?.server_name || "",
].join(" ").toLowerCase()
}
}
diff --git a/apps/frontend/src/state/valkey-features/cluster/clusterUtilization.ts b/apps/frontend/src/state/valkey-features/cluster/clusterUtilization.ts
index 56527797..c6fa9faa 100644
--- a/apps/frontend/src/state/valkey-features/cluster/clusterUtilization.ts
+++ b/apps/frontend/src/state/valkey-features/cluster/clusterUtilization.ts
@@ -1,6 +1,7 @@
import { CPU_HIGH_THRESHOLD, CPU_NORMAL_THRESHOLD,
MEMORY_HIGH_THRESHOLD, MEMORY_NORMAL_THRESHOLD } from "@common/src/constants.ts"
import * as R from "ramda"
+import type { NodeUtilization } from "./clusterSlice"
export type UtilizationLevel = "low" | "normal" | "high"
@@ -32,3 +33,14 @@ export function getUtilizationLevel(
if (levels.length === 0) return null
return levels.reduce((worst, level) => (LEVEL_RANK[level] > LEVEL_RANK[worst] ? level : worst))
}
+
+// Checks if the node has a memory limit set.
+export const hasMemoryLimit = (utilization?: NodeUtilization): boolean =>
+ utilization?.memory_basis === "maxmemory"
+
+// Returns the worst of the two utilization levels, or null if neither is available.
+export const getNodeUtilizationLevel = (utilization?: NodeUtilization): UtilizationLevel | null =>
+ getUtilizationLevel(
+ hasMemoryLimit(utilization) ? utilization?.memory_utilization_percent : null,
+ utilization?.cpu_utilization_percent,
+ )
diff --git a/apps/server/src/actions/cluster.ts b/apps/server/src/actions/cluster.ts
index ae8e84b5..e43d9ca2 100644
--- a/apps/server/src/actions/cluster.ts
+++ b/apps/server/src/actions/cluster.ts
@@ -3,12 +3,12 @@ import { type Deps, withDeps } from "./utils"
import { setClusterDashboardData } from "../set-dashboard-data"
export const setClusterData = withDeps(
- async ({ ws, clients, connectionId, action }) => {
+ async ({ ws, clients, connectionId, action, clusterNodesRegistry }) => {
const connection = clients.get(connectionId)
if (connection && connection.client instanceof GlideClusterClient) {
- const { clusterId } = action.payload
- await setClusterDashboardData(clusterId as string, connection.client, ws, connectionId)
+ const { clusterId } = action.payload
+ await setClusterDashboardData(clusterId as string, connection.client, ws, connectionId, clusterNodesRegistry)
}
},
)
diff --git a/apps/server/src/actions/connection.ts b/apps/server/src/actions/connection.ts
index 25076102..b2dc7a7f 100644
--- a/apps/server/src/actions/connection.ts
+++ b/apps/server/src/actions/connection.ts
@@ -86,7 +86,7 @@ export const connectPending = withDeps(
)
export const resetConnection = withDeps(
- async ({ ws, connectionId, clients, action }) => {
+ async ({ ws, connectionId, clients, action, clusterNodesRegistry }) => {
const entry = clients.get(connectionId)
if (!entry) {
@@ -98,7 +98,7 @@ export const resetConnection = withDeps(
const { clusterId } = action.payload as unknown as { clusterId: string }
if (client instanceof GlideClusterClient) {
- await setClusterDashboardData(clusterId, client, ws, connectionId)
+ await setClusterDashboardData(clusterId, client, ws, connectionId, clusterNodesRegistry)
}
},
)
diff --git a/apps/server/src/set-dashboard-data.ts b/apps/server/src/set-dashboard-data.ts
index f1a7c058..b6969971 100644
--- a/apps/server/src/set-dashboard-data.ts
+++ b/apps/server/src/set-dashboard-data.ts
@@ -1,9 +1,18 @@
+import * as R from "ramda"
import { GlideClusterClient, ConnectionError, ClosingError, TimeoutError } from "@valkey/valkey-glide"
import WebSocket from "ws"
import { VALKEY, METRICS_SERVER_NOT_READY, buildUrl } from "valkey-common"
import { type ParsedClusterInfo, parseClusterInfo } from "./utils"
import { computeClusterUtilization, type NodeUtilization } from "./node-utilization"
import { fetchWithTimeout } from "./actions/utils"
+import { discoverCluster } from "./connection"
+import { type ConnectionDetails } from "./actions/connection"
+import {
+ isWebMode,
+ metricsServerMap,
+ reconcileClusterMetricsServers,
+ type ClusterNodeMap
+} from "./metrics-orchestrator"
type DashboardInfo = {
info: Record
@@ -81,14 +90,56 @@ const safeComputeClusterUtilization = (
}
}
+// discoverCluster only reads the auth and TLS fields, copying them onto every rediscovered node;
+// host, port, endpointType and db are placeholders required by ConnectionDetails.
+const toDiscoveryDetails = (node: ClusterNodeMap[string]): ConnectionDetails => ({
+ host: node.host,
+ port: String(node.port),
+ username: node.username,
+ tls: node.tls,
+ verifyTlsCertificate: node.verifyTlsCertificate,
+ authType: node.authType,
+ awsRegion: node.awsRegion,
+ awsReplicationGroupId: node.awsReplicationGroupId,
+ endpointType: "cluster-endpoint",
+ db: 0,
+})
+
+const refreshClusterNodes = async (
+ clusterId: string,
+ client: GlideClusterClient,
+ clusterNodesRegistry: Map,
+): Promise => {
+ const current = clusterNodesRegistry.get(clusterId)
+ const template = current && Object.values(current)[0]
+ if (!template) return current
+
+ try {
+ const { discoveredClusterNodes } = await discoverCluster(client, {
+ connectionDetails: toDiscoveryDetails(template),
+ })
+ if (!R.equals(discoveredClusterNodes, current)) {
+ clusterNodesRegistry.set(clusterId, discoveredClusterNodes)
+ if (isWebMode) reconcileClusterMetricsServers(metricsServerMap)
+ }
+ return discoveredClusterNodes
+ } catch {
+ return current
+ }
+}
+
export async function setClusterDashboardData(
clusterId: string,
client: GlideClusterClient,
ws: WebSocket,
connectionId: string,
+ clusterNodesRegistry: Map,
) {
try {
- const rawInfo = await client.info()
+ const [rawInfo, clusterNodes] = await Promise.all([
+ client.info(),
+ refreshClusterNodes(clusterId, client, clusterNodesRegistry),
+ ])
const clusterInfo = parseClusterInfo(rawInfo)
ws.send(
@@ -98,6 +149,7 @@ export async function setClusterDashboardData(
clusterId,
info: clusterInfo,
utilization: safeComputeClusterUtilization(clusterInfo),
+ clusterNodes,
},
}),
)