Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Typography } from "../../ui/typography"
import { getColor, LEGEND_STEPS } from "./heatmap-scale"

interface HeatmapLegendProps {
label: string
selectedBuckets: Set<number>
onToggle: (step: number) => void
}

export function HeatmapLegend({ label, selectedBuckets, onToggle }: HeatmapLegendProps) {
const hasBucketFilter = selectedBuckets.size > 0

return (
<div className="flex items-center justify-between gap-4">
<Typography variant="bodyXs">{label}</Typography>
<div className="flex gap-1 shrink-0">
{LEGEND_STEPS.map((step) => {
const isSelected = selectedBuckets.has(step)
return (
<button
aria-label={`Filter band ${LEGEND_STEPS.indexOf(step) + 1}`}
aria-pressed={isSelected}
className={`w-5 h-5 rounded-full transition-all focus:outline-none
${isSelected
? "ring-2 ring-offset-1 ring-foreground scale-110"
: hasBucketFilter
? "opacity-30 hover:opacity-70"
: "hover:scale-110 hover:ring-1 hover:ring-border"
}`}
key={step}
onClick={() => onToggle(step)}
style={{ backgroundColor: getColor(step) }}
type="button"
/>
)
})}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
227 changes: 59 additions & 168 deletions apps/frontend/src/components/activity-view/hotkeys/hot-keys-heatmap.tsx
Original file line number Diff line number Diff line change
@@ -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<HeatmapTab, { title: string; description: string }> = {
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<HoveredTile | null>(null)
const [selectedBuckets, setSelectedBuckets] = useState<Set<number>>(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<string, NodeStat>)

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<HeatmapTab>("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 (
<Dialog.Root onOpenChange={onClose} open={open}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-30 bg-black/50" />
<Dialog.Content asChild>
<div className="fixed inset-0 z-40 flex items-center justify-center p-4">
<div className="w-1/2 h-1/2 bg-background rounded-xl border border-border shadow-xl flex flex-col">
<div className="w-[90vw] max-w-6xl h-[80vh] bg-background rounded-xl border border-border shadow-xl flex flex-col">

{/* Header */}
<div className="flex items-start justify-between px-6 py-4 border-b border-border">
<div className="flex items-start justify-between gap-4 px-6 py-4 border-b border-border">
<div className="flex flex-col gap-0.5">
<Dialog.Title asChild>
<Typography variant="subheading">Node Heatmap</Typography>
<Typography variant="subheading">{TABS[tab].title}</Typography>
</Dialog.Title>
<Dialog.Description asChild>
<Typography variant="bodyXs">
Hot key concentration across cluster nodes
</Typography>
<Typography variant="bodyXs">{TABS[tab].description}</Typography>
</Dialog.Description>
</div>
<Dialog.Close asChild>
<Button className="hover:text-primary p-1 shrink-0 -mt-1 -mr-1" size="sm" variant="ghost">
<X size={16} />
</Button>
</Dialog.Close>
</div>

{/* Body */}
<div className="flex-1 flex flex-col gap-5 px-6 py-5 overflow-y-auto min-h-0">

{/* Summary chips */}
<div className="flex items-center gap-2">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 border border-primary/20">
<Server className="text-primary" size={12} />
<Typography variant="bodyXs">
{sorted.length} node{sorted.length !== 1 ? "s" : ""}
</Typography>
</div>
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 border border-primary/20">
<Flame className="text-primary" size={12} />
<Typography variant="bodyXs">
{data.length} hot key{data.length !== 1 ? "s" : ""}
</Typography>
</div>
<div className="flex items-start gap-4">
{showSlots && (
<TabGroup activeTab={tab} onChange={setActiveTab} tabs={tabs} />
)}
<Dialog.Close asChild>
<Button className="hover:text-primary p-1 shrink-0 -mt-1 -mr-1" size="sm" variant="ghost">
<X size={16} />
</Button>
</Dialog.Close>
</div>
</div>

{/* Legend with filter */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<Typography variant="bodyXs">
Select one or multiple legends to filter nodes by hot key concentration
</Typography>
<div className="flex items-center gap-2">
<div className="flex gap-0.5">
{LEGEND_STEPS.map((step) => {
const isSelected = selectedBuckets.has(step)
return (
<button
className={`w-5 h-5 rounded-lg transition-all focus:outline-none
${isSelected
? "ring-2 ring-offset-1 ring-foreground scale-110"
: hasBucketFilter && !isSelected
? "opacity-30 hover:opacity-70"
: "hover:scale-110 hover:ring-1 hover:ring-border"
}`}
key={step}
onClick={() => toggleBucket(step)}
style={{ backgroundColor: getColor(step) }}
type="button"
/>
)
})}
</div>

</div>
</div>

{/* Tile grid */}
<div className="rounded-lg border border-border bg-muted/40 p-4 min-h-16 max-h-40 overflow-y-auto">
<div className="flex flex-wrap gap-1.5">
{sorted.map((stat) => {
const ratio = max === min ? 0 : (stat.count - min) / (max - min)
return (
<div
className={`w-5 h-5 rounded transition-all relative cursor-default
${isActive(ratio) ? "hover:scale-125 hover:z-10 hover:shadow-sm" : "opacity-20"}`}
key={stat.nodeId}
onMouseEnter={(e) => handleMouseEnter(stat, e)}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
style={{ backgroundColor: getColor(ratio) }}
/>
)
})}
</div>
</div>
</div>
<div className="flex-1 flex flex-col px-6 py-5 min-h-0">
{tab === "slots" ? (
<SlotHeatmap
failedNodeCount={failedNodeCount}
hotKeys={data}
onKeyClick={handleKeyClick}
/>
) : (
<NodeHeatmap data={data} />
)}
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>

{/* Hover tooltip */}
{hovered && (
<div
className="fixed z-50 pointer-events-none px-3 py-2.5 rounded-lg border border-border bg-popover shadow-lg"
style={{ left: hovered.x + 14, top: hovered.y - 12 }}
>
<Typography variant="code">{truncateText(hovered.nodeId)}</Typography>
<div className="flex flex-col gap-0.5 mt-1">
<Typography variant="bodyXs">
{hovered.count} hot key{hovered.count !== 1 ? "s" : ""}
</Typography>
<Typography variant="bodyXs">
{hovered.totalAccess.toLocaleString()} total accesses
</Typography>
</div>
</div>
)}
</Dialog.Root>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function HotKeysToolbar({
variant="outline"
>
<ChartPie className="text-primary" />
Node Heatmap
Heatmap
</Button>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -96,8 +96,11 @@ export function HotKeys({
{banners}
<HotKeysHeatmapModal
data={sorted}
failedNodeCount={nodeErrors?.length ?? 0}
onClose={() => setIsHeatmapOpen(false)}
onKeyClick={onKeyClick}
open={isHeatmapOpen}
showSlots={!!isHotSlots && sorted.some(([, , , , , slotId]) => slotId !== undefined)}
/>
<HotKeysToolbar
countMax={countMax}
Expand Down
Loading
Loading