Files
supabase/packages/dev-tools/DevToolbar.tsx
Jordi EnricandClaude Sonnet 4.6 1545df1fe6 feat(dev-tools): add resource warnings tab to dev toolbar (#44857)
## Problem

There was no way to trigger resource warning banners in the dev toolbar,
making it hard to test different warning states (warn/critical,
read-only mode) without manually mocking API responses or waiting for
real exhaustion events.

## Fix

- Adds an ExtraTab extension point to DevToolbar so Studio can inject
custom tabs without coupling the package to app-specific hooks
- Adds a ResourceWarningsTab in Studio with per-type off/warn/crit
toggles for disk IO, CPU, memory, disk space, and auth rate limit
warnings, plus a read-only mode toggle
- Auth rate limit only exposes warn (no crit) since critical is not a
valid API return value
- The tab component is dynamically imported so it stays out of
production bundles, controlled by the same NEXT_PUBLIC_ENVIRONMENT guard
used by the toolbar

## How to test

1. Run Studio locally or open a Vercel preview deployment
2. Open the browser console and run window.devTelemetry() to enable the
toolbar
3. Click the toolbar trigger to open the panel
4. Switch to the "Warnings" tab
5. Toggle any warning type to "Warn" or "Crit" and confirm the banner
appears at the top of the page
6. Toggle read-only mode on and confirm the read-only banner appears
7. Click "Reset to real data" and confirm banners return to their actual
state
8. Confirm the Auth Rate Limit row only has Off and Warn buttons (no
Crit)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* New "Warnings" tab in the developer toolbar (enabled in local and
staging) for managing resource warning banners.
* Per-resource severity overrides (Off / Warning / Critical) with
immediate preview in the toolbar.
  * Local "read-only" toggle to simulate read-only mode.
* "Reset to real data" clears overrides, disables the local toggle, and
refreshes server state.
* Tab loads lazily and shows a disabled/loading state if org/project
context is unavailable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:09:47 +02:00

580 lines
21 KiB
TypeScript

'use client'
import { useFeatureFlags } from 'common'
import { Copy, EyeOff, Search, X } from 'lucide-react'
import Image from 'next/image'
import {
useCallback,
useEffect,
useState,
type ChangeEvent,
type Dispatch,
type SetStateAction,
} from 'react'
import {
Badge,
Button,
cn,
Input,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
Switch,
Tabs_Shadcn_ as Tabs,
TabsList_Shadcn_ as TabsList,
TabsTrigger_Shadcn_ as TabsTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { useDevToolbar } from './DevToolbarContext'
import type { DevTelemetryEvent, ExtraTab } from './types'
import {
CC_ORIGINALS_KEY,
deleteCookie,
getCookie,
parseOverrideValue,
PH_ORIGINALS_KEY,
readOriginals,
safeJsonParse,
setCookie,
valuesAreEqual,
writeOriginals,
} from './utils'
// Duplicated for tree-shaking — bundler must see literal process.env reference.
// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbarTrigger.tsx, feature-flags.tsx
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
const IS_LOCAL_DEV = env === 'local'
function EventRow({ event }: { event: DevTelemetryEvent }) {
const [isExpanded, setIsExpanded] = useState(false)
const time = new Date(event.timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard
.writeText(JSON.stringify({ name: event.eventName, properties: event.properties }, null, 2))
.catch((error) => console.warn('Copy failed', error))
}
return (
<div className="group last:border-b-0">
<div className="relative flex items-center h-9 hover:bg-surface-100">
<button
type="button"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((prev) => !prev)}
className="flex items-center flex-1 min-w-0 h-full px-6 gap-5 cursor-pointer"
>
<span className="flex items-center gap-2 shrink-0 w-16">
<span
className={cn(
'w-1.5 h-1.5 rounded-[2px] shrink-0',
event.source === 'client' ? 'bg-brand' : 'bg-foreground-lighter'
)}
/>
<span
className={cn(
'font-mono text-xs uppercase',
event.source === 'client' ? 'text-brand' : 'text-foreground-light'
)}
>
{event.source}
</span>
</span>
<span className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
<span className="font-mono text-xs text-foreground-lighter shrink-0 w-20">{time}</span>
<span className="font-mono text-xs text-foreground-lighter shrink-0 w-28 truncate uppercase text-left">
{event.eventType}
</span>
<span className="font-mono text-xs text-foreground truncate shrink-0">
{event.eventName}
</span>
{event.distinctId && (
<span className="font-mono text-xs text-foreground-muted truncate">
{event.distinctId}
</span>
)}
</span>
</button>
<div className="shrink-0 pr-6">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleCopy}
aria-label="Copy JSON"
className="p-1 rounded hover:bg-surface-200 text-foreground-muted hover:text-foreground-light"
>
<Copy className="w-3 h-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Copy JSON</TooltipContent>
</Tooltip>
</div>
</div>
{isExpanded && (
<pre className="px-8 py-2 bg-surface-100 border-b text-xs font-mono overflow-x-auto max-h-[200px] overflow-y-auto text-foreground-light">
{JSON.stringify(event.properties, null, 2)}
</pre>
)}
</div>
)
}
function FlagRow({
flagName,
currentValue,
originalValue,
isOverridden,
onToggle,
}: {
flagName: string
currentValue: unknown
originalValue: unknown
isOverridden: boolean
onToggle: (value: unknown) => void
}) {
const valueType = typeof originalValue
const isNull = originalValue === null
const inputProps = {
size: 'tiny' as const,
value: String(currentValue),
onChange: (event: ChangeEvent<HTMLInputElement>) => onToggle(event.target.value),
className: 'w-32',
}
return (
<div className={cn('px-4 py-3 flex flex-col gap-0.5', isOverridden && 'bg-warning/5')}>
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col items-start gap-1">
<div className="flex gap-2 min-w-0 h-4">
<span
className={cn(
'font-mono text-xs truncate',
isOverridden ? 'text-warning' : 'text-foreground'
)}
>
{flagName}
</span>
{isOverridden && (
<Badge variant="warning" className="shrink-0">
Overridden
</Badge>
)}
{isNull && (
<Badge variant="secondary" className="shrink-0">
null
</Badge>
)}
</div>
<div className="text-xs text-foreground-muted uppercase font-mono">
Original:{' '}
<code className="text-foreground-lighter">{JSON.stringify(originalValue)}</code>
</div>
</div>
{isNull ? (
<Input size="tiny" value="null" disabled className="w-32 opacity-50" />
) : valueType === 'boolean' ? (
<Switch
checked={currentValue as boolean}
onCheckedChange={(checked) => onToggle(checked)}
/>
) : valueType === 'number' ? (
<Input type="number" {...inputProps} />
) : (
<Input {...inputProps} />
)}
</div>
</div>
)
}
export function DevToolbar({ extraTabs = [] }: { extraTabs?: ExtraTab[] }) {
const { isEnabled, isOpen, setIsOpen, events, setEvents, dismissToolbar } = useDevToolbar()
const [activeTab, setActiveTab] = useState<string>('events')
const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog')
const [eventFilter, setEventFilter] = useState<string>('')
const { posthog: posthogFlags, configcat: configcatFlags } = useFeatureFlags()
const [phFlagOverrides, setPhFlagOverrides] = useState<Record<string, unknown>>({})
const [ccFlagOverrides, setCcFlagOverrides] = useState<Record<string, unknown>>({})
const [phFlagOriginals, setPhFlagOriginals] = useState<Record<string, unknown>>({})
const [ccFlagOriginals, setCcFlagOriginals] = useState<Record<string, unknown>>({})
const loadOverrides = useCallback(
(cookieName: string, label: string, setter: (value: Record<string, unknown>) => void) => {
const parsed = safeJsonParse<Record<string, unknown>>(getCookie(cookieName), {}, label)
if (Object.keys(parsed).length > 0) {
setter(parsed)
}
},
[]
)
const saveOverrides = useCallback(
(
cookieName: string,
overrides: Record<string, unknown>,
setter: (value: Record<string, unknown>) => void
) => {
setter(overrides)
if (Object.keys(overrides).length > 0) {
setCookie(cookieName, JSON.stringify(overrides), '/')
} else {
deleteCookie(cookieName)
}
},
[]
)
const updateOriginals = useCallback(
(
storageKey: typeof PH_ORIGINALS_KEY | typeof CC_ORIGINALS_KEY,
setter: Dispatch<SetStateAction<Record<string, unknown>>>
) =>
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
setter((prev) => {
const next = updater(prev)
writeOriginals(storageKey, next)
return next
})
},
[]
)
useEffect(() => {
loadOverrides('x-ph-flag-overrides', 'PostHog flag overrides', setPhFlagOverrides)
loadOverrides('x-cc-flag-overrides', 'ConfigCat flag overrides', setCcFlagOverrides)
}, [loadOverrides])
useEffect(() => {
setPhFlagOriginals(readOriginals(PH_ORIGINALS_KEY))
setCcFlagOriginals(readOriginals(CC_ORIGINALS_KEY))
}, [])
useEffect(() => {
const STYLE_ID = 'dev-toolbar-hide-native-devtools'
const existing = document.getElementById(STYLE_ID)
if (isOpen) {
if (!existing) {
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = `
.tsqd-open-btn, .tsqd-open-btn-container { display: none !important; }
`
document.head.appendChild(style)
}
} else {
existing?.remove()
}
}, [isOpen])
const updatePhOriginals = updateOriginals(PH_ORIGINALS_KEY, setPhFlagOriginals)
const updateCcOriginals = updateOriginals(CC_ORIGINALS_KEY, setCcFlagOriginals)
const togglePhFlagOverride = (flagName: string, value: unknown) => {
const originalValue = phFlagOriginals[flagName] ?? posthogFlags[flagName]
const parsedValue = parseOverrideValue(value, originalValue)
if (valuesAreEqual(parsedValue, originalValue)) {
const newOverrides = { ...phFlagOverrides }
delete newOverrides[flagName]
saveOverrides('x-ph-flag-overrides', newOverrides, setPhFlagOverrides)
return
}
updatePhOriginals((prev) =>
flagName in prev ? prev : { ...prev, [flagName]: posthogFlags[flagName] }
)
const newOverrides = { ...phFlagOverrides, [flagName]: parsedValue }
saveOverrides('x-ph-flag-overrides', newOverrides, setPhFlagOverrides)
}
const toggleCcFlagOverride = (flagName: string, value: unknown) => {
const originalValue = ccFlagOriginals[flagName] ?? configcatFlags[flagName]
const parsedValue = parseOverrideValue(value, originalValue)
if (valuesAreEqual(parsedValue, originalValue)) {
const newOverrides = { ...ccFlagOverrides }
delete newOverrides[flagName]
saveOverrides('x-cc-flag-overrides', newOverrides, setCcFlagOverrides)
return
}
updateCcOriginals((prev) =>
flagName in prev ? prev : { ...prev, [flagName]: configcatFlags[flagName] }
)
const newOverrides = { ...ccFlagOverrides, [flagName]: parsedValue }
saveOverrides('x-cc-flag-overrides', newOverrides, setCcFlagOverrides)
}
const clearAllOverrides = () => {
setPhFlagOverrides({})
setCcFlagOverrides({})
setPhFlagOriginals({})
setCcFlagOriginals({})
deleteCookie('x-ph-flag-overrides')
deleteCookie('x-cc-flag-overrides')
writeOriginals(PH_ORIGINALS_KEY, {})
writeOriginals(CC_ORIGINALS_KEY, {})
window.location.reload()
}
const normalizedFilter = eventFilter.trim().toLowerCase()
const filteredEvents = (
normalizedFilter
? events.filter(
(e) =>
e.eventName.toLowerCase().includes(normalizedFilter) ||
e.eventType.toLowerCase().includes(normalizedFilter)
)
: events
)
.slice()
.sort((a, b) => b.timestamp - a.timestamp)
const phOverrideCount = Object.keys(phFlagOverrides).length
const ccOverrideCount = Object.keys(ccFlagOverrides).length
const totalOverrideCount = phOverrideCount + ccOverrideCount
if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetContent
side="bottom"
size="lg"
className="flex flex-col p-0 gap-0 overflow-hidden"
showClose={false}
hasOverlay={false}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
<SheetHeader className="border-b shrink-0 space-y-0 p-0">
<SheetTitle className="sr-only">Dev Toolbar</SheetTitle>
<SheetDescription className="sr-only">
View telemetry events and feature flags for local development
</SheetDescription>
<div className="flex items-center px-6">
<Image
src="/img/logo-pixel-small-light.png"
alt="Dev Toolbar"
width={16}
height={16}
style={{
filter:
'brightness(0) saturate(100%) invert(72%) sepia(57%) saturate(431%) hue-rotate(108deg) brightness(95%) contrast(91%)',
}}
aria-hidden="true"
className="shrink-0 mr-4"
/>
<TabsList className="flex gap-x-4 rounded-none !border-none h-auto">
<TabsTrigger
value="events"
className="text-xs py-3 border-b-[1px] font-mono uppercase"
>
Events ({filteredEvents.length})
</TabsTrigger>
<TabsTrigger
value="flags"
className="text-xs py-3 border-b-[1px] font-mono uppercase"
>
Flags {totalOverrideCount > 0 && `(${totalOverrideCount})`}
</TabsTrigger>
{extraTabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-xs py-3 border-b-[1px] font-mono uppercase"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<div className="ml-auto flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
icon={<EyeOff className="w-4 h-4" />}
onClick={dismissToolbar}
className="text-foreground-light hover:text-foreground p-1"
/>
</TooltipTrigger>
<TooltipContent side="top">Hide Dev Toolbar</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<SheetClose asChild>
<Button
type="text"
icon={<X className="w-4 h-4" />}
className="text-foreground-light hover:text-foreground p-1"
/>
</SheetClose>
</TooltipTrigger>
<TooltipContent side="top">Close</TooltipContent>
</Tooltip>
</div>
</div>
</SheetHeader>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{activeTab === 'events' && (
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex items-center justify-between border-b shrink-0 px-6 py-2">
<Input
size="tiny"
placeholder="Filter events..."
value={eventFilter}
onChange={(e) => setEventFilter(e.target.value)}
icon={<Search size={14} className="text-foreground-lighter" />}
className="flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 max-w-96"
/>
<Button
type="default"
onClick={() => setEvents([])}
className="text-foreground-lighter hover:text-foreground"
>
Clear all
</Button>
</div>
{!IS_LOCAL_DEV && (
<div className="px-6 py-2 text-xs text-foreground-muted border-b bg-surface-100">
Server-side events are only visible when using the toolbar in local development
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto pb-4">
{filteredEvents.length === 0 ? (
<div className="text-center text-foreground-lighter py-8 text-sm">
No events yet. Interact with the app to see telemetry events.
</div>
) : (
<div className="overflow-hidden">
{filteredEvents.map((event) => (
<EventRow key={`${event.source}-${event.id}`} event={event} />
))}
</div>
)}
</div>
</div>
)}
{activeTab === 'flags' && (
<div className="flex-1 min-h-0 overflow-hidden flex">
{/* Sidebar */}
<div className="w-44 border-r shrink-0 flex flex-col">
<nav className="flex flex-col p-3 gap-0.5">
{(
[
{ id: 'posthog', label: 'PostHog', count: phOverrideCount },
{ id: 'configcat', label: 'ConfigCat', count: ccOverrideCount },
] as const
).map(({ id, label, count }) => (
<button
key={id}
type="button"
onClick={() => setFlagsSubTab(id)}
className={cn(
'flex items-center justify-between px-3 py-1.5 rounded text-sm text-left uppercase font-mono tracking-wide',
flagsSubTab === id
? 'bg-surface-300 text-foreground'
: 'text-foreground-light hover:bg-surface-200'
)}
>
<span className="font-mono text-xs">{label}</span>
{count > 0 && (
<span className="text-xs text-foreground-lighter">{count}</span>
)}
</button>
))}
</nav>
{totalOverrideCount > 0 && (
<div className="mt-auto p-2 border-t">
<Button type="outline" size="tiny" block onClick={clearAllOverrides}>
Reset & Reload
</Button>
</div>
)}
</div>
{/* Flag list */}
<div className="flex-1 min-h-0 overflow-y-auto pb-6">
<div className="divide-y">
{flagsSubTab === 'posthog' &&
(Object.keys(posthogFlags).length === 0 ? (
<div className="text-center text-foreground-lighter py-8 text-sm">
No PostHog feature flags loaded yet.
</div>
) : (
Object.entries(posthogFlags).map(([flagName, flagValue]) => (
<FlagRow
key={flagName}
flagName={flagName}
currentValue={
phFlagOverrides[flagName] ?? phFlagOriginals[flagName] ?? flagValue
}
originalValue={phFlagOriginals[flagName] ?? flagValue}
isOverridden={flagName in phFlagOverrides}
onToggle={(value) => togglePhFlagOverride(flagName, value)}
/>
))
))}
{flagsSubTab === 'configcat' &&
(Object.keys(configcatFlags).length === 0 ? (
<div className="text-center text-foreground-lighter py-8 text-sm">
No ConfigCat feature flags loaded yet.
</div>
) : (
Object.entries(configcatFlags).map(([flagName, flagValue]) => (
<FlagRow
key={flagName}
flagName={flagName}
currentValue={
ccFlagOverrides[flagName] ?? ccFlagOriginals[flagName] ?? flagValue
}
originalValue={ccFlagOriginals[flagName] ?? flagValue}
isOverridden={flagName in ccFlagOverrides}
onToggle={(value) => toggleCcFlagOverride(flagName, value)}
/>
))
))}
</div>
</div>
</div>
)}
{extraTabs.map((tab) => (
<div
key={tab.id}
className={cn('flex-1 min-h-0 overflow-y-auto', activeTab !== tab.id && 'hidden')}
>
{tab.content}
</div>
))}
</div>
</Tabs>
</SheetContent>
</Sheet>
)
}