diff --git a/apps/frontend/src/components/activity-view/hotkeys/heatmap-legend.tsx b/apps/frontend/src/components/activity-view/hotkeys/heatmap-legend.tsx new file mode 100644 index 00000000..c488b4a1 --- /dev/null +++ b/apps/frontend/src/components/activity-view/hotkeys/heatmap-legend.tsx @@ -0,0 +1,40 @@ +import { Typography } from "../../ui/typography" +import { getColor, LEGEND_STEPS } from "./heatmap-scale" + +interface HeatmapLegendProps { + label: string + selectedBuckets: Set + onToggle: (step: number) => void +} + +export function HeatmapLegend({ label, selectedBuckets, onToggle }: HeatmapLegendProps) { + const hasBucketFilter = selectedBuckets.size > 0 + + return ( +
+ {label} +
+ {LEGEND_STEPS.map((step) => { + const isSelected = selectedBuckets.has(step) + return ( +
+
+ ) +} diff --git a/apps/frontend/src/components/activity-view/hotkeys/heatmap-scale.ts b/apps/frontend/src/components/activity-view/hotkeys/heatmap-scale.ts new file mode 100644 index 00000000..6ee0d489 --- /dev/null +++ b/apps/frontend/src/components/activity-view/hotkeys/heatmap-scale.ts @@ -0,0 +1,19 @@ +// for heatmap legend and color scale +export const LEGEND_STEPS = [0, 0.25, 0.5, 0.75, 1] + +// Returns the color for a given ratio +export function getColor(ratio: number): string { + const lightness = Math.round(90 - ratio * 62) + const saturation = Math.round(70 + ratio * 15) + return `hsl(0, ${saturation}%, ${lightness}%)` +} + +// Snaps a ratio to the nearest bucket (0, 0.25, 0.5, 0.75, 1) +export function snapToBucket(ratio: number): number { + return Math.round(ratio * 4) / 4 +} + +// Converts a value to a ratio between 0 and 1 based on the given min and max +export function toRatio(value: number, min: number, max: number): number { + return max === min ? 0 : (value - min) / (max - min) +} diff --git a/apps/frontend/src/components/activity-view/hotkeys/hot-keys-heatmap.tsx b/apps/frontend/src/components/activity-view/hotkeys/hot-keys-heatmap.tsx index ef7fccdc..6eb5fafb 100644 --- a/apps/frontend/src/components/activity-view/hotkeys/hot-keys-heatmap.tsx +++ b/apps/frontend/src/components/activity-view/hotkeys/hot-keys-heatmap.tsx @@ -1,204 +1,95 @@ -import { useMemo, useState } from "react" +import { useState } from "react" import * as Dialog from "@radix-ui/react-dialog" -import { Flame, Server, X } from "lucide-react" -import { truncateText } from "@common/src/truncate-text" +import { X } from "lucide-react" import { Typography } from "../../ui/typography" import { Button } from "../../ui/button" +import { TabGroup } from "../../ui/tab-group" +import { NodeHeatmap } from "./node-heatmap" +import { SlotHeatmap } from "./slot-heatmap" +import type { HotKeyEntry } from "./hot-keys" + +type HeatmapTab = "nodes" | "slots" + +const TABS: Record = { + nodes: { + title: "Node Heatmap", + description: "Hot key concentration across cluster nodes", + }, + slots: { + title: "Slot Heatmap", + description: "Hot key concentration across cluster slots — select a slot to see its keys", + }, +} interface HotKeysHeatmapModalProps { open: boolean onClose: () => void - data: [string, number, number | null, number, string?][] -} - -interface NodeStat { - nodeId: string - count: number - totalAccess: number + data: HotKeyEntry[] + showSlots: boolean + failedNodeCount: number + onKeyClick?: (keyName: string) => void } -interface HoveredTile { - nodeId: string - count: number - totalAccess: number - x: number - y: number -} - -const LEGEND_STEPS = [0, 0.25, 0.5, 0.75, 1] - -function getColor(ratio: number): string { - const lightness = Math.round(90 - ratio * 62) - const saturation = Math.round(70 + ratio * 15) - return `hsl(0, ${saturation}%, ${lightness}%)` -} - -function snapToBucket(ratio: number): number { - return Math.round(ratio * 4) / 4 -} - -export function HotKeysHeatmapModal({ open, onClose, data }: HotKeysHeatmapModalProps) { - const [hovered, setHovered] = useState(null) - const [selectedBuckets, setSelectedBuckets] = useState>(new Set()) - - const { sorted, max, min } = useMemo(() => { - const nodeStats = data.reduce((acc, [, accessCount, , , nodeId]) => { - const key = nodeId ?? "Unknown" - acc[key] ??= { nodeId: key, count: 0, totalAccess: 0 } - acc[key].count += 1 - acc[key].totalAccess += accessCount - return acc - }, {} as Record) - - const sorted: NodeStat[] = Object.values(nodeStats).sort((a, b) => b.count - a.count) - return { - sorted, - max: sorted[0]?.count ?? 1, - min: sorted.at(-1)?.count ?? 0, - } - }, [data]) - - const hasBucketFilter = selectedBuckets.size > 0 +export function HotKeysHeatmapModal({ + open, onClose, data, showSlots, failedNodeCount, onKeyClick, +}: HotKeysHeatmapModalProps) { + const [activeTab, setActiveTab] = useState("nodes") - const isActive = (ratio: number) => - !hasBucketFilter || selectedBuckets.has(snapToBucket(ratio)) + const tabs = [ + { id: "nodes" as HeatmapTab, label: "Nodes" }, + ...(showSlots ? [{ id: "slots" as HeatmapTab, label: "Slots" }] : []), + ] + const tab = showSlots ? activeTab : "nodes" - const toggleBucket = (step: number) => { - setSelectedBuckets((prev) => { - const next = new Set(prev) - if (next.has(step)) { next.delete(step) } else { next.add(step) } - return next - }) + const handleKeyClick = (keyName: string) => { + onClose() + onKeyClick?.(keyName) } - const handleMouseEnter = (stat: NodeStat, e: React.MouseEvent) => { - setHovered({ ...stat, x: e.clientX, y: e.clientY }) - } - const handleMouseMove = (e: React.MouseEvent) => { - if (hovered) setHovered((prev) => prev ? { ...prev, x: e.clientX, y: e.clientY } : null) - } - const handleMouseLeave = () => setHovered(null) - return (
-
+
- {/* Header */} -
+
- Node Heatmap + {TABS[tab].title} - - Hot key concentration across cluster nodes - + {TABS[tab].description}
- - - -
- - {/* Body */} -
- - {/* Summary chips */} -
-
- - - {sorted.length} node{sorted.length !== 1 ? "s" : ""} - -
-
- - - {data.length} hot key{data.length !== 1 ? "s" : ""} - -
+
+ {showSlots && ( + + )} + + +
+
- {/* Legend with filter */} -
-
- - Select one or multiple legends to filter nodes by hot key concentration - -
-
- {LEGEND_STEPS.map((step) => { - const isSelected = selectedBuckets.has(step) - return ( -
- -
-
- - {/* Tile grid */} -
-
- {sorted.map((stat) => { - const ratio = max === min ? 0 : (stat.count - min) / (max - min) - return ( -
handleMouseEnter(stat, e)} - onMouseLeave={handleMouseLeave} - onMouseMove={handleMouseMove} - style={{ backgroundColor: getColor(ratio) }} - /> - ) - })} -
-
-
+
+ {tab === "slots" ? ( + + ) : ( + + )}
- - {/* Hover tooltip */} - {hovered && ( -
- {truncateText(hovered.nodeId)} -
- - {hovered.count} hot key{hovered.count !== 1 ? "s" : ""} - - - {hovered.totalAccess.toLocaleString()} total accesses - -
-
- )} ) } diff --git a/apps/frontend/src/components/activity-view/hotkeys/hot-keys-toolbar.tsx b/apps/frontend/src/components/activity-view/hotkeys/hot-keys-toolbar.tsx index c9b7bf9e..12656b6d 100644 --- a/apps/frontend/src/components/activity-view/hotkeys/hot-keys-toolbar.tsx +++ b/apps/frontend/src/components/activity-view/hotkeys/hot-keys-toolbar.tsx @@ -39,7 +39,7 @@ export function HotKeysToolbar({ variant="outline" > - Node Heatmap + Heatmap )} diff --git a/apps/frontend/src/components/activity-view/hotkeys/hot-keys.tsx b/apps/frontend/src/components/activity-view/hotkeys/hot-keys.tsx index 5b3f2f8f..30518b54 100644 --- a/apps/frontend/src/components/activity-view/hotkeys/hot-keys.tsx +++ b/apps/frontend/src/components/activity-view/hotkeys/hot-keys.tsx @@ -10,7 +10,7 @@ import { MonitorNotRunningBanner, NodeErrorsBanner } from "./hot-keys-banners" import { HotKeysToolbar } from "./hot-keys-toolbar" import { HotKeysTable } from "./hot-keys-table" -export type HotKeyEntry = [string, number, number | null, number, string?] +export type HotKeyEntry = [string, number, number | null, number, string?, number?] interface HotKeysProps { data: HotKeyEntry[] | null @@ -96,8 +96,11 @@ export function HotKeys({ {banners} setIsHeatmapOpen(false)} + onKeyClick={onKeyClick} open={isHeatmapOpen} + showSlots={!!isHotSlots && sorted.some(([, , , , , slotId]) => slotId !== undefined)} /> void align?: "left" | "right" + allLabel?: string + className?: string } -export function NodeFilterDropdown({ nodes, selectedNode, onSelect, align = "left" }: NodeFilterDropdownProps) { +export function NodeFilterDropdown({ + nodes, selectedNode, onSelect, align = "left", allLabel = "All Nodes", className, +}: NodeFilterDropdownProps) { const [open, setOpen] = useState(false) const [nodeSearch, setNodeSearch] = useState("") const ref = useRef(null) @@ -33,20 +38,20 @@ export function NodeFilterDropdown({ nodes, selectedNode, onSelect, align = "lef return (
{open && ( -
+
- All Nodes + {allLabel} {filtered.length === 0 && ( diff --git a/apps/frontend/src/components/activity-view/hotkeys/node-heatmap.tsx b/apps/frontend/src/components/activity-view/hotkeys/node-heatmap.tsx new file mode 100644 index 00000000..2a2654f3 --- /dev/null +++ b/apps/frontend/src/components/activity-view/hotkeys/node-heatmap.tsx @@ -0,0 +1,121 @@ +import { useState } from "react" +import { Flame, Server } from "lucide-react" +import { truncateText } from "@common/src/truncate-text" +import { Typography } from "../../ui/typography" +import { HeatmapLegend } from "./heatmap-legend" +import { getColor, snapToBucket, toRatio } from "./heatmap-scale" +import type { HotKeyEntry } from "./hot-keys" + +interface NodeStat { + nodeId: string + count: number + totalAccess: number +} + +interface HoveredTile extends NodeStat { + x: number + y: number +} + +interface NodeHeatmapProps { + data: HotKeyEntry[] +} + +export function NodeHeatmap({ data }: NodeHeatmapProps) { + const [hovered, setHovered] = useState(null) + const [selectedBuckets, setSelectedBuckets] = useState>(new Set()) + + const nodeStats = data.reduce((acc, [, accessCount, , , nodeId]) => { + const key = nodeId ?? "Unknown" + acc[key] ??= { nodeId: key, count: 0, totalAccess: 0 } + acc[key].count += 1 + acc[key].totalAccess += accessCount + return acc + }, {} as Record) + + const sorted: NodeStat[] = Object.values(nodeStats).sort((a, b) => b.count - a.count) + const max = sorted[0]?.count ?? 1 + const min = sorted.at(-1)?.count ?? 0 + + const hasBucketFilter = selectedBuckets.size > 0 + const isActive = (ratio: number) => !hasBucketFilter || selectedBuckets.has(snapToBucket(ratio)) + + const toggleBucket = (step: number) => { + setSelectedBuckets((prev) => { + const next = new Set(prev) + if (next.has(step)) { next.delete(step) } else { next.add(step) } + return next + }) + } + + const handleMouseEnter = (stat: NodeStat, e: React.MouseEvent) => { + setHovered({ ...stat, x: e.clientX, y: e.clientY }) + } + const handleMouseMove = (e: React.MouseEvent) => { + if (hovered) setHovered((prev) => prev ? { ...prev, x: e.clientX, y: e.clientY } : null) + } + const handleMouseLeave = () => setHovered(null) + + return ( +
+
+
+ + + {sorted.length} node{sorted.length !== 1 ? "s" : ""} + +
+
+ + + {data.length} hot key{data.length !== 1 ? "s" : ""} + +
+
+ +
+ + +
+
+ {sorted.map((stat) => { + const ratio = toRatio(stat.count, min, max) + return ( +
handleMouseEnter(stat, e)} + onMouseLeave={handleMouseLeave} + onMouseMove={handleMouseMove} + style={{ backgroundColor: getColor(ratio) }} + /> + ) + })} +
+
+
+ + {hovered && ( +
+ {truncateText(hovered.nodeId)} +
+ + {hovered.count} hot key{hovered.count !== 1 ? "s" : ""} + + + {hovered.totalAccess.toLocaleString()} total accesses + +
+
+ )} +
+ ) +} diff --git a/apps/frontend/src/components/activity-view/hotkeys/slot-heatmap.tsx b/apps/frontend/src/components/activity-view/hotkeys/slot-heatmap.tsx new file mode 100644 index 00000000..2ce62423 --- /dev/null +++ b/apps/frontend/src/components/activity-view/hotkeys/slot-heatmap.tsx @@ -0,0 +1,268 @@ +import { useState } from "react" +import { Grid2x2X } from "lucide-react" +import { convertTTL } from "@common/src/ttl-conversion" +import { formatBytes } from "@common/src/bytes-conversion" +import { truncateText } from "@common/src/truncate-text" +import { Typography } from "../../ui/typography" +import { EmptyState } from "../../ui/empty-state" +import { HeatmapLegend } from "./heatmap-legend" +import { NodeFilterDropdown } from "./node-filter-dropdown" +import { getColor, snapToBucket } from "./heatmap-scale" +import type { HotKeyEntry } from "./hot-keys" + +interface SlotGroup { + slotId: number + rows: HotKeyEntry[] + totalFreq: number + nodeId?: string +} + +interface HoveredSlot { + group: SlotGroup + x: number + y: number +} + +interface SlotHeatmapProps { + hotKeys: HotKeyEntry[] + failedNodeCount: number + onKeyClick?: (keyName: string) => void +} + +const toTileRatio = (value: number, min: number, max: number) => + max === min ? 1 : (value - min) / (max - min) + +const groupKeysBySlot = (hotKeys: HotKeyEntry[]): SlotGroup[] => { + const grouped = new Map() + + for (const row of hotKeys) { + const slotId = row[5] + if (slotId === undefined || slotId === null) continue + + const group = grouped.get(slotId) ?? { + slotId, + rows: [], + totalFreq: 0, + nodeId: row[4], + } + group.rows.push(row) + group.totalFreq += row[1] + grouped.set(slotId, group) + } + + return [...grouped.values()].sort( + (a, b) => b.rows.length - a.rows.length || b.totalFreq - a.totalFreq, + ) +} + +function SlotTile({ group, ratio, dimmed, selected, onSelect, onHover, onMove, onLeave }: { + group: SlotGroup + ratio: number + dimmed: boolean + selected: boolean + onSelect: () => void + onHover: (e: React.MouseEvent) => void + onMove: (e: React.MouseEvent) => void + onLeave: () => void +}) { + return ( + + ) +} + +function SlotDetails({ group, totalHotKeys, onKeyClick }: { + group: SlotGroup | null + totalHotKeys: number + onKeyClick?: (keyName: string) => void +}) { + if (!group) { + return ( +
+ Select a slot to see the hot keys it holds +
+ ) + } + + return ( +
+
+ Slot {group.slotId} + + {group.rows.length} of your top {totalHotKeys} hot key{totalHotKeys !== 1 ? "s" : ""} + +
+ +
    + {group.rows.map(([keyName, , size, ttl], index) => ( +
  • + +
  • + ))} +
+ +
+ + Owned by Node {truncateText(group.nodeId ?? "—")} + +
+
+ ) +} + +export function SlotHeatmap({ hotKeys, failedNodeCount, onKeyClick }: SlotHeatmapProps) { + const [selectedBuckets, setSelectedBuckets] = useState>(new Set()) + const [selectedSlotId, setSelectedSlotId] = useState(null) + const [selectedNode, setSelectedNode] = useState("all") + const [hovered, setHovered] = useState(null) + + const allGroups = groupKeysBySlot(hotKeys) + + if (allGroups.length === 0) { + return ( + } + title="No Hot Slots Found" + /> + ) + } + + const nodes = [...new Set(allGroups.flatMap((group) => group.nodeId ?? []))].sort() + const activeNode = nodes.includes(selectedNode) ? selectedNode : "all" + const groups = activeNode === "all" + ? allGroups + : allGroups.filter((group) => group.nodeId === activeNode) + + const max = groups[0].rows.length + const min = groups.at(-1)!.rows.length + const totalHotKeys = groups.reduce((sum, group) => sum + group.rows.length, 0) + + const handleNodeSelect = (node: string) => { + setSelectedNode(node) + setSelectedSlotId(null) + } + + const hasBucketFilter = selectedBuckets.size > 0 + const isActive = (ratio: number) => !hasBucketFilter || selectedBuckets.has(snapToBucket(ratio)) + + const toggleBucket = (step: number) => { + setSelectedBuckets((prev) => { + const next = new Set(prev) + if (next.has(step)) { next.delete(step) } else { next.add(step) } + return next + }) + } + + const selectedGroup = groups.find((group) => group.slotId === selectedSlotId) ?? null + + const chips = [ + `${groups.length} hot slot${groups.length !== 1 ? "s" : ""}`, + `${totalHotKeys} hot key${totalHotKeys !== 1 ? "s" : ""}`, + ] + + return ( +
+
+ {chips.map((chip) => ( + + {chip} + + ))} + + {failedNodeCount > 0 && ( + + Partial — {failedNodeCount} node{failedNodeCount !== 1 ? "s" : ""} failed to report + + )} +
+ + + +
+
+
+
+ {groups.map((group) => { + const ratio = toTileRatio(group.rows.length, min, max) + return ( + setHovered({ group, x: e.clientX, y: e.clientY })} + onLeave={() => setHovered(null)} + onMove={(e) => setHovered((prev) => prev ? { ...prev, x: e.clientX, y: e.clientY } : null)} + onSelect={() => setSelectedSlotId(group.slotId)} + ratio={ratio} + selected={selectedSlotId === group.slotId} + /> + ) + })} +
+
+ + Shows the slots holding your top {totalHotKeys} hot key{totalHotKeys !== 1 ? "s" : ""}, not every slot in the cluster. + +
+ +
+ +
+
+ + {hovered && ( +
+ Slot {hovered.group.slotId} +
+ + {hovered.group.rows.length} of your top {totalHotKeys} hot key{totalHotKeys !== 1 ? "s" : ""} + + + {truncateText(hovered.group.nodeId ?? "—")} + +
+
+ )} +
+ ) +} diff --git a/apps/frontend/src/state/valkey-features/hotkeys/hotKeysSlice.ts b/apps/frontend/src/state/valkey-features/hotkeys/hotKeysSlice.ts index f0a729e2..38e4a883 100644 --- a/apps/frontend/src/state/valkey-features/hotkeys/hotKeysSlice.ts +++ b/apps/frontend/src/state/valkey-features/hotkeys/hotKeysSlice.ts @@ -27,7 +27,7 @@ export const selectHotKeysLastCollectedAt = (targetId: string) => (state: RootSt interface HotKeysState { // Keyed by `targetId`: `clusterId` (cluster) or db-less `nodeId` (standalone). [targetId: string]: { - hotKeys: [string, number, number | null, number, string?][] + hotKeys: [string, number, number | null, number, string?, number?][] checkAt: string | null, monitorRunning: boolean, nodeId: string | null, @@ -82,7 +82,7 @@ const hotKeysSlice = createSlice({ if (!state[targetId]) { state[targetId] = { hotKeys: [], - checkAt: null, + checkAt: null, monitorRunning: false, nodeId: null, status: ERROR, diff --git a/apps/frontend/src/state/valkey-features/keys/keyBrowserSlice.test.ts b/apps/frontend/src/state/valkey-features/keys/keyBrowserSlice.test.ts index e363437f..af4f47d3 100644 --- a/apps/frontend/src/state/valkey-features/keys/keyBrowserSlice.test.ts +++ b/apps/frontend/src/state/valkey-features/keys/keyBrowserSlice.test.ts @@ -152,18 +152,74 @@ describe("keyBrowserSlice", () => { getKeyTypeFulfilled({ connectionId: "conn-1", key: "mykey", - keyType: "String", + name: "mykey", + type: "string", ttl: -1, size: 100, + elements: "myvalue", }), ) - expect(state["conn-1"].keys[0].type).toBe("String") + expect(state["conn-1"].keys[0].type).toBe("string") expect(state["conn-1"].keys[0].ttl).toBe(-1) expect(state["conn-1"].keys[0].size).toBe(100) + expect(state["conn-1"].keys[0].elements).toBe("myvalue") expect(state["conn-1"].keyTypeLoading["mykey"]).toBeUndefined() }) + it("should add the key when it is not already in the keys array", () => { + const previousState = { + "conn-1": { + ...defaultConnectionState, + keyTypeLoading: { hotkey: true }, + }, + } + + const state = keyBrowserReducer( + previousState, + getKeyTypeFulfilled({ + connectionId: "conn-1", + key: "hotkey", + name: "hotkey", + type: "string", + ttl: -1, + size: 56, + elements: "hotvalue", + }), + ) + + expect(state["conn-1"].keys).toEqual([ + { name: "hotkey", type: "string", ttl: -1, size: 56, elements: "hotvalue" }, + ]) + expect(state["conn-1"].keyTypeLoading["hotkey"]).toBeUndefined() + }) + + it("should store elementsWarning when the value is too large to display", () => { + const previousState = { + "conn-1": { + ...defaultConnectionState, + keyTypeLoading: { bigkey: true }, + }, + } + + const state = keyBrowserReducer( + previousState, + getKeyTypeFulfilled({ + connectionId: "conn-1", + key: "bigkey", + name: "bigkey", + type: "list", + ttl: -1, + size: 99999999, + collectionSize: 1000, + elementsWarning: "too big", + }), + ) + + expect(state["conn-1"].keys[0].elementsWarning).toBe("too big") + expect(state["conn-1"].keys[0].elements).toBeUndefined() + }) + it("should update collection size if provided", () => { const previousState = { "conn-1": { @@ -178,7 +234,8 @@ describe("keyBrowserSlice", () => { getKeyTypeFulfilled({ connectionId: "conn-1", key: "mylist", - keyType: "List", + name: "mylist", + type: "list", ttl: -1, size: 50, collectionSize: 10, diff --git a/apps/frontend/src/state/valkey-features/keys/keyBrowserSlice.ts b/apps/frontend/src/state/valkey-features/keys/keyBrowserSlice.ts index 48adf952..a056382e 100644 --- a/apps/frontend/src/state/valkey-features/keys/keyBrowserSlice.ts +++ b/apps/frontend/src/state/valkey-features/keys/keyBrowserSlice.ts @@ -6,6 +6,9 @@ interface KeyInfo { ttl?: number; size?: number; collectionSize?: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + elements?: any; + elementsWarning?: string; } interface KeyBrowserState { @@ -19,14 +22,17 @@ interface KeyBrowserState { }; } -export const defaultConnectionState = { +// Called per connection so each gets its own nested objects, not frozen shared ones. +export const createConnectionState = (): KeyBrowserState[string] => ({ keys: [], cursor: "0", loading: false, error: null, keyTypeLoading: {}, totalKeys: 0, -} +}) + +export const defaultConnectionState = createConnectionState() const initialState: KeyBrowserState = {} @@ -44,7 +50,7 @@ const keyBrowserSlice = createSlice({ ) => { const { connectionId } = action.payload if (!state[connectionId]) { - state[connectionId] = { ...defaultConnectionState } + state[connectionId] = createConnectionState() } state[connectionId].loading = true state[connectionId].error = null @@ -85,40 +91,29 @@ const keyBrowserSlice = createSlice({ ) => { const { connectionId, key } = action.payload if (!state[connectionId]) { - state[connectionId] = { ...defaultConnectionState } + state[connectionId] = createConnectionState() } state[connectionId].keyTypeLoading[key] = true }, getKeyTypeFulfilled: ( state, - action: PayloadAction<{ - connectionId: string; - key: string; - keyType: string; - ttl: number; - size: number; - collectionSize?: number; - }>, + action: PayloadAction< + { + connectionId: string; + key: string; + } & Omit & { name?: string } + >, ) => { - const { connectionId, key, keyType, ttl, size, collectionSize } = - action.payload + const { connectionId, key, name, ...keyInfo } = action.payload if (!state[connectionId]) { - state[connectionId] = { ...defaultConnectionState } + state[connectionId] = createConnectionState() } - const existingKey = state[connectionId].keys.find( - (k) => k.name === key, - ) - if (existingKey) { - existingKey.type = keyType - existingKey.ttl = ttl - if (size !== undefined) existingKey.size = size - if (collectionSize !== undefined) - existingKey.collectionSize = collectionSize + const updatedKey: KeyInfo = { name: name ?? key, ...keyInfo } + const index = state[connectionId].keys.findIndex((k) => k.name === key) + if (index === -1) { + state[connectionId].keys.push(updatedKey) } else { - state[connectionId].keys.push({ - name: key, type: keyType, ttl, size, - ...(collectionSize !== undefined ? { collectionSize } : {}), - }) + state[connectionId].keys[index] = updatedKey } delete state[connectionId].keyTypeLoading[key] }, @@ -141,7 +136,7 @@ const keyBrowserSlice = createSlice({ ) => { const { connectionId, key } = action.payload if (!state[connectionId]) { - state[connectionId] = { ...defaultConnectionState } + state[connectionId] = createConnectionState() } state[connectionId].keyTypeLoading[key] = true }, @@ -194,7 +189,7 @@ const keyBrowserSlice = createSlice({ ) => { const { connectionId } = action.payload if (!state[connectionId]) { - state[connectionId] = { ...defaultConnectionState } + state[connectionId] = createConnectionState() } state[connectionId].loading = true state[connectionId].error = null @@ -247,7 +242,7 @@ const keyBrowserSlice = createSlice({ ) => { const { connectionId } = action.payload if (!state[connectionId]) { - state[connectionId] = { ...defaultConnectionState } + state[connectionId] = createConnectionState() } state[connectionId].loading = true state[connectionId].error = null diff --git a/apps/metrics/src/analyzers/calculate-hot-keys.js b/apps/metrics/src/analyzers/calculate-hot-keys.js index 02c78022..5c850043 100644 --- a/apps/metrics/src/analyzers/calculate-hot-keys.js +++ b/apps/metrics/src/analyzers/calculate-hot-keys.js @@ -94,12 +94,12 @@ export const calculateHotKeysFromHotSlots = async (client, { count = 50 } = {}) cursorToSlot = Number(cursor) & 0x3FFF } while (cursorToSlot === slotId && cursor !== 0) - - return keys + + return { slotId, keys } }) - const slotKeys = await Promise.all(slotPromises) - const allKeys = slotKeys.flat() + const slotEntries = await Promise.all(slotPromises) + const allKeys = slotEntries.flatMap(({ slotId, keys }) => keys.map((key) => ({ key, slotId }))) // Pipeline OBJECT FREQ in chunks to avoid overwhelming the server const { PIPELINE_CHUNK_SIZE } = VALKEY_CLIENT @@ -107,7 +107,7 @@ export const calculateHotKeysFromHotSlots = async (client, { count = 50 } = {}) for (let i = 0; i < allKeys.length; i += PIPELINE_CHUNK_SIZE) { const chunk = allKeys.slice(i, i + PIPELINE_CHUNK_SIZE) const batch = new Batch(false) - for (const key of chunk) { + for (const { key } of chunk) { batch.customCommand(["OBJECT", "FREQ", key]) } const chunkResults = await client.exec(batch) @@ -118,13 +118,15 @@ export const calculateHotKeysFromHotSlots = async (client, { count = 50 } = {}) for (let i = 0; i < allKeys.length; i++) { const freq = parseInt(freqResults[i]) if (isNaN(freq) || freq <= 1) continue + + const { key, slotId } = allKeys[i] if (heap.size() < count) { - heap.push({ key: allKeys[i], freq }) + heap.push({ key, freq, slotId }) } else if (freq > heap.peek().freq) { heap.pop() - heap.push({ key: allKeys[i], freq }) + heap.push({ key, freq, slotId }) } } - return heap.toArray().map(({ key, freq }) => [key, freq]) -} + return heap.toArray().map(({ key, freq, slotId }) => [key, freq, slotId]) +} diff --git a/apps/metrics/src/analyzers/enrich-hot-keys.js b/apps/metrics/src/analyzers/enrich-hot-keys.js index 84a0c96e..941907ab 100644 --- a/apps/metrics/src/analyzers/enrich-hot-keys.js +++ b/apps/metrics/src/analyzers/enrich-hot-keys.js @@ -22,8 +22,8 @@ export const enrichHotKeys = (client) => async (hotKeyPairs) => { } } - return hotKeyPairs.map(([keyName, count], i) => { + return hotKeyPairs.map(([keyName, count, ...rest], i) => { const [ttl, memoryUsage] = results.slice(i * 2, i * 2 + 2) - return [keyName, count, memoryUsage ?? null, ttl ?? -1] + return [keyName, count, memoryUsage ?? null, ttl ?? -1, ...rest] }) } diff --git a/apps/server/src/actions/hotkeys.ts b/apps/server/src/actions/hotkeys.ts index 8cd4f021..87fd9433 100644 --- a/apps/server/src/actions/hotkeys.ts +++ b/apps/server/src/actions/hotkeys.ts @@ -133,16 +133,16 @@ export const hotKeysRequested = withDeps( return } - type HotKeyTuple = [string, number, number | null, number, string] + type HotKeyTuple = [string, number, number | null, number, string, number?] const aggregatedHotKeys = R.pipe( R.chain(({ hotKeys, nodeId: nId }: HotKeysResponse) => - (hotKeys as unknown as [string, number, number | null, number][]).map( - ([key, count, size, ttl]) => [key, count, size, ttl, nId] as HotKeyTuple, + (hotKeys as unknown as [string, number, number | null, number, number?][]).map( + ([key, count, size, ttl, slotId]) => [key, count, size, ttl, nId, slotId] as HotKeyTuple, ), ), - R.reduce((acc: Record, [key, count, size, ttl, nId]: HotKeyTuple) => ({ + R.reduce((acc: Record, [key, count, size, ttl, nId, slotId]: HotKeyTuple) => ({ ...acc, - [key]: [key, (acc[key]?.[1] ?? 0) + count, acc[key]?.[2] ?? size, acc[key]?.[3] ?? ttl, nId] as HotKeyTuple, + [key]: [key, (acc[key]?.[1] ?? 0) + count, acc[key]?.[2] ?? size, acc[key]?.[3] ?? ttl, nId, acc[key]?.[5] ?? slotId] as HotKeyTuple, }), {}), R.values, R.sort(R.descend(R.nth(1) as (x: HotKeyTuple) => number)), @@ -151,6 +151,8 @@ export const hotKeysRequested = withDeps( const { checkAt, nodeId } = results[0] const monitorRunning = results.every((r) => r.monitorRunning) const lastCollectedAt = results.reduce((max, r) => Math.max(max, r.lastCollectedAt ?? 0), 0) || null - const aggregatedResponse = { hotKeys: aggregatedHotKeys, monitorRunning, checkAt, nodeId, lastCollectedAt } as unknown as HotKeysResponse + const aggregatedResponse = { + hotKeys: aggregatedHotKeys, monitorRunning, checkAt, nodeId, lastCollectedAt, + } as unknown as HotKeysResponse sendHotKeysFulfilled(ws, { clusterId: clusterId as string }, aggregatedResponse, nodeErrors) })