From 0d21a000dce60f472fa4cc157282616dfff06a18 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 30 Sep 2026 23:40:22 +0530 Subject: [PATCH 1/3] fix(inspector): stabilize queue panel hint to prevent layout shifts The queue hint used different messages for empty, queued, and shedding states. When the queue crossed those states, the rendered content changed height and caused the panel content below it to jump. Replace the state-specific messages with one stable message that describes all three queue states without changing the panel layout. --- src/components/Inspector.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index ade46b3..70430eb 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -2572,7 +2572,6 @@ function QueuePanel({ stats }: { stats: NodeStats }) { const limit = stats.queueLimit > 0 ? stats.queueLimit : 1; const depth = stats.queued; const fill = Math.min(1, depth / limit); - const shedding = stats.shedRate > 0; return (
@@ -2586,17 +2585,10 @@ function QueuePanel({ stats }: { stats: NodeStats }) {
- {shedding ? ( -

- Full. It is turning away {formatRate(stats.shedRate)}. Work arriving now is - being destroyed, not delayed. The consumers cannot keep up. -

- ) : depth > 0 ? ( -

- Work is waiting here because the consumers are slower than the producers. It - drains once they catch up. -

- ) : null} +

+ Work is processed when consumers keep up, waits when they fall behind and is + turned away when the queue is full. +

); } From a268ad5eade0403de2c5e6b42c36eadcc0cd375f Mon Sep 17 00:00:00 2001 From: Kartik Date: Sun, 18 Oct 2026 12:44:22 +0530 Subject: [PATCH 2/3] fix(inspector): [Update] keep state hints without shifting the panel - Restored the queued and shedding messages, including the live shed rate - Stack each panel's hint variants in one grid cell sized to the tallest one - Show only the active hint; hidden ones keep their space via visibility - Hold a calmer hint for 1.5s before it replaces a worse one to stop intense flickering (at a specific load) - Keep the replica "Reads served stale" row visible, showing 0% when none - Let the replica bottleneck warning take the stale-read hint's slot when both apply - Move the replica set into ReplicaSetSection so it can use hooks --- src/components/Inspector.css | 16 +++ src/components/Inspector.tsx | 201 +++++++++++++++++++++++++---------- 2 files changed, 163 insertions(+), 54 deletions(-) diff --git a/src/components/Inspector.css b/src/components/Inspector.css index 9387c3f..ab162ba 100644 --- a/src/components/Inspector.css +++ b/src/components/Inspector.css @@ -269,6 +269,22 @@ text-wrap: pretty; } +/* Stacks state-dependent hints in one cell so the reserved height follows + the tallest message at whatever width the panel has, instead of a fixed + min-height that only fits one width. `visibility` (not `display`) keeps the + hidden ones in layout and drops them from the accessibility tree. */ +.ins-hint-stack { + display: grid; +} + +.ins-hint-stack > .ins-hint { + grid-area: 1 / 1; +} + +.ins-hint-stack > [data-active='false'] { + visibility: hidden; +} + .ins-field-row .row-k { min-width: 0; font-size: var(--fs-sm); diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 70430eb..ea55843 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -1,4 +1,4 @@ -import { useCallback, useId, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; /* * Transport glyphs. The top-level entry, not the per-icon deep paths the @@ -2456,6 +2456,66 @@ function AutoscalerPanel({ stats }: { stats: NodeStats }) { * shedding. Printing hottest and coldest side by side makes the gap * unmissable. */ +/** + * The replica set's split between the primary and the read pool. + * + * Both of its signals are noisy: stale reads come and go with every lagging + * read, and the primary hovers around the bottleneck threshold. So nothing + * here is added or removed as they change. The stale-read row always shows + * (0% is a real reading), and the two hints share one slot sized to the + * taller of them, like the queue hint. When both apply, the bottleneck + * warning wins: the stale share is still on the row above it. + */ +function ReplicaSetSection({ per, stats }: { per: number[]; stats: NodeStats }) { + // Index 0 is the primary and reports the WRITE pool; the rest share the + // read pool. Averaging them together is what hides a write bottleneck. + const primary = per[0] ?? 0; + const reads = per.slice(1); + const readAvg = reads.length ? reads.reduce((a, b) => a + b, 0) / reads.length : 0; + // Lingering keeps the slot from flicking between messages, not just from + // moving: a hint swaps only after its signal has settled. + const writeBound = useLingering(primary - readAvg > 0.4); + const stale = useLingering(stats.staleReadRate > 0); + const hint = writeBound ? 'write-bound' : stale ? 'stale' : null; + return ( +
+
+ + + + +
+
+

+ The primary is the bottleneck, not the replicas. Adding more read replicas + will not help; every write still goes through the one primary. +

+

+ Some reads are answered by a replica that has not caught up with the latest + write yet. Lower the replication lag to reduce this. +

+
+
+ ); +} + function UnitsPanel({ node, stats }: { node: SimNode; stats: NodeStats }) { const per = stats.perInstance; if (!per || per.length === 0) return null; @@ -2488,55 +2548,7 @@ function UnitsPanel({ node, stats }: { node: SimNode; stats: NodeStats }) { ); } - if (node.kind === 'replica') { - // Index 0 is the primary and reports the WRITE pool; the rest share the - // read pool. Averaging them together is what hides a write bottleneck. - const primary = per[0] ?? 0; - const reads = per.slice(1); - const readAvg = reads.length ? reads.reduce((a, b) => a + b, 0) / reads.length : 0; - const writeBound = primary - readAvg > 0.4; - return ( -
-
- - - - {stats.staleReadRate > 0 && ( - - )} -
- {writeBound && ( -

- The primary is the bottleneck, not the replicas. Adding more read replicas - will not help; every write still goes through the one primary. -

- )} - {stats.staleReadRate > 0 && ( -

- Some reads are answered by a replica that has not caught up with the latest - write yet. Lower the replication lag to reduce this. -

- )} -
- ); - } + if (node.kind === 'replica') return ; // service / worker / db / cache / lb / cdn: interchangeable machines. const booting = stats.instancesPending ?? 0; @@ -2568,10 +2580,82 @@ function UnitsPanel({ node, stats }: { node: SimNode; stats: NodeStats }) { * 2478 with utilisation at 0.00 throughout. Depth against limit is the only * reading that moves. */ +/** + * How long a calmer state must hold before a hint reflecting a worse one + * changes. Long enough to ride out a signal that flips on every 10Hz + * snapshot, short enough that a hint does not outstay what it describes. + */ +const HINT_HOLD_MS = 1500; + +/** + * `on`, but turning off only after it has stayed off for HINT_HOLD_MS. + * + * For a hint that appears when a noisy signal crosses a line: it shows the + * moment the signal does, and does not blink out each time the signal dips + * back for a single snapshot. + */ +function useLingering(on: boolean): boolean { + const [held, setHeld] = useState(on); + // Turning on takes effect during render, so it shows this pass. + if (on && !held) setHeld(true); + useEffect(() => { + if (on) return; + const t = setTimeout(() => setHeld(false), HINT_HOLD_MS); + return () => clearTimeout(t); + }, [on]); + return on || held; +} + +type QueueHintState = 'idle' | 'queued' | 'shedding'; + +const QUEUE_HINT_RANK: Record = { + idle: 0, + queued: 1, + shedding: 2, +}; + +/** + * The queue hint state, escalating at once but calming only reluctantly. + * + * A queue whose consumers roughly match its producers flips between 0 and 1 + * waiting item on every 10Hz snapshot, and a full one flips in and out of + * shedding the same way. Following that directly makes the hint flicker. A + * worse state shows immediately; a better one waits until it has held for a + * continuous HINT_HOLD_MS. The shed rate is held with the state so a + * lingering "turning away" message keeps the last real rate instead of 0/s. + */ +function useQueueHint( + state: QueueHintState, + shedRate: number, +): { state: QueueHintState; shedRate: number } { + const [held, setHeld] = useState({ state, shedRate }); + const escalating = QUEUE_HINT_RANK[state] >= QUEUE_HINT_RANK[held.state]; + + // A same-or-worse state takes over during render, so it shows this pass + // and the rate stays live; React re-runs the render before committing. + if (escalating && (held.state !== state || held.shedRate !== shedRate)) { + setHeld({ state, shedRate }); + } + + // A calmer state only lands after holding for HINT_HOLD_MS. The + // timer restarts only when the target state changes, not on a new rate. + useEffect(() => { + if (escalating) return; + const t = setTimeout(() => setHeld({ state, shedRate: 0 }), HINT_HOLD_MS); + return () => clearTimeout(t); + }, [escalating, state]); + + return escalating ? { state, shedRate } : held; +} + function QueuePanel({ stats }: { stats: NodeStats }) { const limit = stats.queueLimit > 0 ? stats.queueLimit : 1; const depth = stats.queued; const fill = Math.min(1, depth / limit); + const hint = useQueueHint( + stats.shedRate > 0 ? 'shedding' : depth > 0 ? 'queued' : 'idle', + stats.shedRate, + ); return (
@@ -2585,10 +2669,19 @@ function QueuePanel({ stats }: { stats: NodeStats }) {
-

- Work is processed when consumers keep up, waits when they fall behind and is - turned away when the queue is full. -

+ {/* Every message is rendered in one grid cell so the stack always holds + the tallest one's height at the current panel width. Only the active + message is visible; switching states never moves the panel below. */} +
+

+ Full. It is turning away {formatRate(hint.shedRate)}. Work arriving now is + being destroyed, not delayed. The consumers cannot keep up. +

+

+ Work is waiting here because the consumers are slower than the producers. It + drains once they catch up. +

+
); } From 0eda3c0962dbf3d07c109e6a72a998e5c3d7ddfe Mon Sep 17 00:00:00 2001 From: Kartik Date: Sun, 18 Oct 2026 13:03:04 +0530 Subject: [PATCH 3/3] fix: correct misplaced function definition --- src/components/Inspector.tsx | 150 +++++++++++++++++------------------ 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index ea55843..71d4168 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -2447,15 +2447,73 @@ function AutoscalerPanel({ stats }: { stats: NodeStats }) { } /** - * Per-unit truth for the kinds that are genuinely several things. + * How long a calmer state must hold before a hint reflecting a worse one + * changes. Long enough to ride out a signal that flips on every 10Hz + * snapshot, short enough that a hint does not outstay what it describes. + */ +const HINT_HOLD_MS = 1500; + +/** + * `on`, but turning off only after it has stayed off for HINT_HOLD_MS. * - * The headline is the SPREAD, not the mean, and for a shard that is the whole - * lesson: measured at hotKeyFraction 0.85 across 8 partitions, the hottest - * sat at 100% while the node mean read 17%. A student reading only the mean - * concludes there is plenty of headroom at the exact moment one partition is - * shedding. Printing hottest and coldest side by side makes the gap - * unmissable. + * For a hint that appears when a noisy signal crosses a line: it shows the + * moment the signal does, and does not blink out each time the signal dips + * back for a single snapshot. */ +function useLingering(on: boolean): boolean { + const [held, setHeld] = useState(on); + // Turning on takes effect during render, so it shows this pass. + if (on && !held) setHeld(true); + useEffect(() => { + if (on) return; + const t = setTimeout(() => setHeld(false), HINT_HOLD_MS); + return () => clearTimeout(t); + }, [on]); + return on || held; +} + +type QueueHintState = 'idle' | 'queued' | 'shedding'; + +const QUEUE_HINT_RANK: Record = { + idle: 0, + queued: 1, + shedding: 2, +}; + +/** + * The queue hint state, escalating at once but calming only reluctantly. + * + * A queue whose consumers roughly match its producers flips between 0 and 1 + * waiting item on every 10Hz snapshot, and a full one flips in and out of + * shedding the same way. Following that directly makes the hint flicker. A + * worse state shows immediately; a better one waits until it has held for a + * continuous HINT_HOLD_MS. The shed rate is held with the state so a + * lingering "turning away" message keeps the last real rate instead of 0/s. + */ +function useQueueHint( + state: QueueHintState, + shedRate: number, +): { state: QueueHintState; shedRate: number } { + const [held, setHeld] = useState({ state, shedRate }); + const escalating = QUEUE_HINT_RANK[state] >= QUEUE_HINT_RANK[held.state]; + + // A same-or-worse state takes over during render, so it shows this pass + // and the rate stays live; React re-runs the render before committing. + if (escalating && (held.state !== state || held.shedRate !== shedRate)) { + setHeld({ state, shedRate }); + } + + // A calmer state only lands after holding for HINT_HOLD_MS. The + // timer restarts only when the target state changes, not on a new rate. + useEffect(() => { + if (escalating) return; + const t = setTimeout(() => setHeld({ state, shedRate: 0 }), HINT_HOLD_MS); + return () => clearTimeout(t); + }, [escalating, state]); + + return escalating ? { state, shedRate } : held; +} + /** * The replica set's split between the primary and the read pool. * @@ -2516,6 +2574,16 @@ function ReplicaSetSection({ per, stats }: { per: number[]; stats: NodeStats }) ); } +/** + * Per-unit truth for the kinds that are genuinely several things. + * + * The headline is the SPREAD, not the mean, and for a shard that is the whole + * lesson: measured at hotKeyFraction 0.85 across 8 partitions, the hottest + * sat at 100% while the node mean read 17%. A student reading only the mean + * concludes there is plenty of headroom at the exact moment one partition is + * shedding. Printing hottest and coldest side by side makes the gap + * unmissable. + */ function UnitsPanel({ node, stats }: { node: SimNode; stats: NodeStats }) { const per = stats.perInstance; if (!per || per.length === 0) return null; @@ -2580,74 +2648,6 @@ function UnitsPanel({ node, stats }: { node: SimNode; stats: NodeStats }) { * 2478 with utilisation at 0.00 throughout. Depth against limit is the only * reading that moves. */ -/** - * How long a calmer state must hold before a hint reflecting a worse one - * changes. Long enough to ride out a signal that flips on every 10Hz - * snapshot, short enough that a hint does not outstay what it describes. - */ -const HINT_HOLD_MS = 1500; - -/** - * `on`, but turning off only after it has stayed off for HINT_HOLD_MS. - * - * For a hint that appears when a noisy signal crosses a line: it shows the - * moment the signal does, and does not blink out each time the signal dips - * back for a single snapshot. - */ -function useLingering(on: boolean): boolean { - const [held, setHeld] = useState(on); - // Turning on takes effect during render, so it shows this pass. - if (on && !held) setHeld(true); - useEffect(() => { - if (on) return; - const t = setTimeout(() => setHeld(false), HINT_HOLD_MS); - return () => clearTimeout(t); - }, [on]); - return on || held; -} - -type QueueHintState = 'idle' | 'queued' | 'shedding'; - -const QUEUE_HINT_RANK: Record = { - idle: 0, - queued: 1, - shedding: 2, -}; - -/** - * The queue hint state, escalating at once but calming only reluctantly. - * - * A queue whose consumers roughly match its producers flips between 0 and 1 - * waiting item on every 10Hz snapshot, and a full one flips in and out of - * shedding the same way. Following that directly makes the hint flicker. A - * worse state shows immediately; a better one waits until it has held for a - * continuous HINT_HOLD_MS. The shed rate is held with the state so a - * lingering "turning away" message keeps the last real rate instead of 0/s. - */ -function useQueueHint( - state: QueueHintState, - shedRate: number, -): { state: QueueHintState; shedRate: number } { - const [held, setHeld] = useState({ state, shedRate }); - const escalating = QUEUE_HINT_RANK[state] >= QUEUE_HINT_RANK[held.state]; - - // A same-or-worse state takes over during render, so it shows this pass - // and the rate stays live; React re-runs the render before committing. - if (escalating && (held.state !== state || held.shedRate !== shedRate)) { - setHeld({ state, shedRate }); - } - - // A calmer state only lands after holding for HINT_HOLD_MS. The - // timer restarts only when the target state changes, not on a new rate. - useEffect(() => { - if (escalating) return; - const t = setTimeout(() => setHeld({ state, shedRate: 0 }), HINT_HOLD_MS); - return () => clearTimeout(t); - }, [escalating, state]); - - return escalating ? { state, shedRate } : held; -} - function QueuePanel({ stats }: { stats: NodeStats }) { const limit = stats.queueLimit > 0 ? stats.queueLimit : 1; const depth = stats.queued;