diff options
| author | Adam <[email protected]> | 2026-03-09 15:57:19 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-03-09 15:57:24 -0500 |
| commit | ee18c9976e00ebf0162fe1dcc47b209e0507b3e5 (patch) | |
| tree | 7c11e23925ffa12d3a14bed69b8f9988820cf5a8 /packages/app/src/components/debug-bar.tsx | |
| parent | 794532928f76c197181bc80944e8be1f6e5eda9a (diff) | |
| download | opencode-ee18c9976e00ebf0162fe1dcc47b209e0507b3e5.tar.gz opencode-ee18c9976e00ebf0162fe1dcc47b209e0507b3e5.zip | |
chore(app): dev stats
Diffstat (limited to 'packages/app/src/components/debug-bar.tsx')
| -rw-r--r-- | packages/app/src/components/debug-bar.tsx | 432 |
1 files changed, 432 insertions, 0 deletions
diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx new file mode 100644 index 000000000..93a4654f8 --- /dev/null +++ b/packages/app/src/components/debug-bar.tsx @@ -0,0 +1,432 @@ +import { useIsRouting, useLocation } from "@solidjs/router" +import { batch, createEffect, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { Tooltip } from "@opencode-ai/ui/tooltip" + +type Mem = Performance & { + memory?: { + usedJSHeapSize: number + jsHeapSizeLimit: number + } +} + +type Evt = PerformanceEntry & { + interactionId?: number + processingStart?: number +} + +type Shift = PerformanceEntry & { + hadRecentInput: boolean + value: number +} + +type Obs = PerformanceObserverInit & { + durationThreshold?: number +} + +const span = 5000 + +const ms = (n?: number, d = 0) => { + if (n === undefined || Number.isNaN(n)) return "n/a" + return `${n.toFixed(d)}ms` +} + +const time = (n?: number) => { + if (n === undefined || Number.isNaN(n)) return "n/a" + return `${Math.round(n)}` +} + +const mb = (n?: number) => { + if (n === undefined || Number.isNaN(n)) return "n/a" + const v = n / 1024 / 1024 + return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB` +} + +const bad = (n: number | undefined, limit: number, low = false) => { + if (n === undefined || Number.isNaN(n)) return false + return low ? n < limit : n > limit +} + +const session = (path: string) => path.includes("/session") + +function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string }) { + return ( + <Tooltip value={props.tip} placement="left"> + <div class="flex w-full flex-col items-center px-0.5 py-1 text-center"> + <div class="text-[7px] font-black uppercase tracking-[0.04em] opacity-70 leading-none">{props.label}</div> + <div + classList={{ + "text-[9px] font-semibold leading-none tabular-nums": true, + "text-text-on-critical-base": !!props.bad, + "opacity-70": !!props.dim, + }} + > + {props.value} + </div> + </div> + </Tooltip> + ) +} + +export function DebugBar() { + const location = useLocation() + const routing = useIsRouting() + const [state, setState] = createStore({ + cls: undefined as number | undefined, + delay: undefined as number | undefined, + fps: undefined as number | undefined, + gap: undefined as number | undefined, + heap: { + limit: undefined as number | undefined, + used: undefined as number | undefined, + }, + inp: undefined as number | undefined, + jank: undefined as number | undefined, + long: { + block: undefined as number | undefined, + count: undefined as number | undefined, + max: undefined as number | undefined, + }, + nav: { + dur: undefined as number | undefined, + pending: false, + }, + }) + + const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) + const heapv = () => { + const value = heap() + if (value === undefined) return "n/a" + return `${Math.round(value * 100)}%` + } + const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`) + const navv = () => (state.nav.pending ? "..." : time(state.nav.dur)) + + let prev = "" + let start = 0 + let init = false + let one = 0 + let two = 0 + + createEffect(() => { + const busy = routing() + const next = `${location.pathname}${location.search}` + + if (!init) { + init = true + prev = next + return + } + + if (busy) { + if (one !== 0) cancelAnimationFrame(one) + if (two !== 0) cancelAnimationFrame(two) + one = 0 + two = 0 + if (start !== 0) return + start = performance.now() + if (session(prev)) setState("nav", { dur: undefined, pending: true }) + return + } + + if (start === 0) { + prev = next + return + } + + const at = start + const from = prev + start = 0 + prev = next + + if (!(session(from) || session(next))) return + + if (one !== 0) cancelAnimationFrame(one) + if (two !== 0) cancelAnimationFrame(two) + one = requestAnimationFrame(() => { + one = 0 + two = requestAnimationFrame(() => { + two = 0 + setState("nav", { dur: performance.now() - at, pending: false }) + }) + }) + }) + + onMount(() => { + const obs: PerformanceObserver[] = [] + const fps: Array<{ at: number; dur: number }> = [] + const long: Array<{ at: number; dur: number }> = [] + const seen = new Map<number | string, { at: number; delay: number; dur: number }>() + let hasLong = false + let poll: number | undefined + let raf = 0 + let last = 0 + let snap = 0 + + const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => { + while (list[0] && at - list[0].at > span) list.shift() + } + + const syncFrame = (at: number) => { + trim(fps, span, at) + const total = fps.reduce((sum, entry) => sum + entry.dur, 0) + const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0) + const jank = fps.filter((entry) => entry.dur > 32).length + batch(() => { + setState("fps", total > 0 ? (fps.length * 1000) / total : undefined) + setState("gap", gap > 0 ? gap : undefined) + setState("jank", jank) + }) + } + + const syncLong = (at = performance.now()) => { + if (!hasLong) return + trim(long, span, at) + const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0) + const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0) + setState("long", { block, count: long.length, max }) + } + + const syncInp = (at = performance.now()) => { + for (const [key, entry] of seen) { + if (at - entry.at > span) seen.delete(key) + } + let delay = 0 + let inp = 0 + for (const entry of seen.values()) { + delay = Math.max(delay, entry.delay) + inp = Math.max(inp, entry.dur) + } + batch(() => { + setState("delay", delay > 0 ? delay : undefined) + setState("inp", inp > 0 ? inp : undefined) + }) + } + + const syncHeap = () => { + const mem = (performance as Mem).memory + if (!mem) return + setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize }) + } + + const reset = () => { + fps.length = 0 + long.length = 0 + seen.clear() + last = 0 + snap = 0 + batch(() => { + setState("fps", undefined) + setState("gap", undefined) + setState("jank", undefined) + setState("delay", undefined) + setState("inp", undefined) + if (hasLong) setState("long", { block: 0, count: 0, max: 0 }) + }) + } + + const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => { + if (typeof PerformanceObserver === "undefined") return false + if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false + const ob = new PerformanceObserver((list) => fn(list.getEntries())) + try { + ob.observe(init) + obs.push(ob) + return true + } catch { + ob.disconnect() + return false + } + } + + if ( + watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => { + const add = entries.reduce((sum, entry) => { + const item = entry as Shift + if (item.hadRecentInput) return sum + return sum + item.value + }, 0) + if (add === 0) return + setState("cls", (value) => (value ?? 0) + add) + }) + ) { + setState("cls", 0) + } + + if ( + watch("longtask", { buffered: true, type: "longtask" }, (entries) => { + const at = performance.now() + long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration }))) + syncLong(at) + }) + ) { + hasLong = true + setState("long", { block: 0, count: 0, max: 0 }) + } + + watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => { + for (const raw of entries) { + const entry = raw as Evt + if (entry.duration < 16) continue + const key = + entry.interactionId && entry.interactionId > 0 + ? entry.interactionId + : `${entry.name}:${Math.round(entry.startTime)}` + const prev = seen.get(key) + const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime) + seen.set(key, { + at: entry.startTime, + delay: Math.max(prev?.delay ?? 0, delay), + dur: Math.max(prev?.dur ?? 0, entry.duration), + }) + if (seen.size <= 200) continue + const first = seen.keys().next().value + if (first !== undefined) seen.delete(first) + } + syncInp() + }) + + const loop = (at: number) => { + if (document.visibilityState !== "visible") { + raf = 0 + return + } + + if (last === 0) { + last = at + raf = requestAnimationFrame(loop) + return + } + + fps.push({ at, dur: at - last }) + last = at + + if (at - snap >= 250) { + snap = at + syncFrame(at) + } + + raf = requestAnimationFrame(loop) + } + + const stop = () => { + if (raf !== 0) cancelAnimationFrame(raf) + raf = 0 + if (poll === undefined) return + clearInterval(poll) + poll = undefined + } + + const start = () => { + if (document.visibilityState !== "visible") return + if (poll === undefined) { + poll = window.setInterval(() => { + syncLong() + syncInp() + syncHeap() + }, 1000) + } + if (raf !== 0) return + raf = requestAnimationFrame(loop) + } + + const vis = () => { + if (document.visibilityState !== "visible") { + stop() + return + } + reset() + start() + } + + syncHeap() + start() + document.addEventListener("visibilitychange", vis) + + onCleanup(() => { + if (one !== 0) cancelAnimationFrame(one) + if (two !== 0) cancelAnimationFrame(two) + stop() + document.removeEventListener("visibilitychange", vis) + for (const ob of obs) ob.disconnect() + }) + }) + + return ( + <aside + aria-label="Development performance diagnostics" + class="pointer-events-auto h-full min-h-0 w-[36px] shrink-0 overflow-y-auto text-text-on-interactive-base no-scrollbar sm:w-[38px]" + style={{ "background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)" }} + > + <div class="flex min-h-full flex-col gap-0.5 py-2 font-mono"> + <Cell + label="NAV" + tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles." + value={navv()} + bad={bad(state.nav.dur, 400)} + dim={state.nav.dur === undefined && !state.nav.pending} + /> + <Cell + label="FPS" + tip="Rolling frames per second over the last 5 seconds." + value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`} + bad={bad(state.fps, 50, true)} + dim={state.fps === undefined} + /> + <Cell + label="FRM" + tip="Worst frame time over the last 5 seconds." + value={time(state.gap)} + bad={bad(state.gap, 50)} + dim={state.gap === undefined} + /> + <Cell + label="JNK" + tip="Frames over 32ms in the last 5 seconds." + value={state.jank === undefined ? "n/a" : `${state.jank}`} + bad={bad(state.jank, 8)} + dim={state.jank === undefined} + /> + <Cell + label="LNG" + tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`} + value={longv()} + bad={bad(state.long.block, 200)} + dim={state.long.count === undefined} + /> + <Cell + label="DLY" + tip="Worst observed input delay in the last 5 seconds." + value={time(state.delay)} + bad={bad(state.delay, 100)} + dim={state.delay === undefined} + /> + <Cell + label="INP" + tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP." + value={time(state.inp)} + bad={bad(state.inp, 200)} + dim={state.inp === undefined} + /> + <Cell + label="CLS" + tip="Cumulative layout shift for the current app lifetime." + value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)} + bad={bad(state.cls, 0.1)} + dim={state.cls === undefined} + /> + <Cell + label="MEM" + tip={ + state.heap.used === undefined + ? "Used JS heap vs heap limit. Chromium only." + : `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.` + } + value={heapv()} + bad={bad(heap(), 0.8)} + dim={state.heap.used === undefined} + /> + </div> + </aside> + ) +} |
