DevToolbar.tsx579 lines · main
1'use client'
2
3import { useFeatureFlags } from 'common'
4import { Copy, EyeOff, Search, X } from 'lucide-react'
5import Image from 'next/image'
6import {
7 useCallback,
8 useEffect,
9 useState,
10 type ChangeEvent,
11 type Dispatch,
12 type SetStateAction,
13} from 'react'
14import {
15 Badge,
16 Button,
17 cn,
18 Input,
19 InputGroup,
20 InputGroupAddon,
21 InputGroupInput,
22 Sheet,
23 SheetClose,
24 SheetContent,
25 SheetDescription,
26 SheetHeader,
27 SheetTitle,
28 Switch,
29 Tabs_Shadcn_ as Tabs,
30 TabsList_Shadcn_ as TabsList,
31 TabsTrigger_Shadcn_ as TabsTrigger,
32 Tooltip,
33 TooltipContent,
34 TooltipTrigger,
35} from 'ui'
36
37import { useDevToolbar } from './DevToolbarContext'
38import type { DevTelemetryEvent, ExtraTab } from './types'
39import {
40 CC_ORIGINALS_KEY,
41 deleteCookie,
42 getCookie,
43 parseOverrideValue,
44 PH_ORIGINALS_KEY,
45 readOriginals,
46 safeJsonParse,
47 setCookie,
48 valuesAreEqual,
49 writeOriginals,
50} from './utils'
51
52// Duplicated for tree-shaking — bundler must see literal process.env reference.
53// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbarTrigger.tsx, feature-flags.tsx
54const env = process.env.NEXT_PUBLIC_ENVIRONMENT
55const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
56const IS_LOCAL_DEV = env === 'local'
57
58function EventRow({ event }: { event: DevTelemetryEvent }) {
59 const [isExpanded, setIsExpanded] = useState(false)
60
61 const time = new Date(event.timestamp).toLocaleTimeString('en-US', {
62 hour12: false,
63 hour: '2-digit',
64 minute: '2-digit',
65 second: '2-digit',
66 })
67
68 const handleCopy = (e: React.MouseEvent) => {
69 e.stopPropagation()
70 navigator.clipboard
71 .writeText(JSON.stringify({ name: event.eventName, properties: event.properties }, null, 2))
72 .catch((error) => console.warn('Copy failed', error))
73 }
74
75 return (
76 <div className="group last:border-b-0">
77 <div className="relative flex items-center h-9 hover:bg-surface-100">
78 <button
79 type="button"
80 aria-expanded={isExpanded}
81 onClick={() => setIsExpanded((prev) => !prev)}
82 className="flex items-center flex-1 min-w-0 h-full px-6 gap-5 cursor-pointer"
83 >
84 <span className="flex items-center gap-2 shrink-0 w-16">
85 <span
86 className={cn(
87 'w-1.5 h-1.5 rounded-[2px] shrink-0',
88 event.source === 'client' ? 'bg-brand' : 'bg-foreground-lighter'
89 )}
90 />
91 <span
92 className={cn(
93 'font-mono text-xs uppercase',
94 event.source === 'client' ? 'text-brand' : 'text-foreground-light'
95 )}
96 >
97 {event.source}
98 </span>
99 </span>
100
101 <span className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
102 <span className="font-mono text-xs text-foreground-lighter shrink-0 w-20">{time}</span>
103 <span className="font-mono text-xs text-foreground-lighter shrink-0 w-28 truncate uppercase text-left">
104 {event.eventType}
105 </span>
106 <span className="font-mono text-xs text-foreground truncate shrink-0">
107 {event.eventName}
108 </span>
109 {event.distinctId && (
110 <span className="font-mono text-xs text-foreground-muted truncate">
111 {event.distinctId}
112 </span>
113 )}
114 </span>
115 </button>
116 <div className="shrink-0 pr-6">
117 <Tooltip>
118 <TooltipTrigger asChild>
119 <button
120 type="button"
121 onClick={handleCopy}
122 aria-label="Copy JSON"
123 className="p-1 rounded-sm hover:bg-surface-200 text-foreground-muted hover:text-foreground-light"
124 >
125 <Copy className="w-3 h-3" />
126 </button>
127 </TooltipTrigger>
128 <TooltipContent side="left">Copy JSON</TooltipContent>
129 </Tooltip>
130 </div>
131 </div>
132 {isExpanded && (
133 <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">
134 {JSON.stringify(event.properties, null, 2)}
135 </pre>
136 )}
137 </div>
138 )
139}
140
141function FlagRow({
142 flagName,
143 currentValue,
144 originalValue,
145 isOverridden,
146 onToggle,
147}: {
148 flagName: string
149 currentValue: unknown
150 originalValue: unknown
151 isOverridden: boolean
152 onToggle: (value: unknown) => void
153}) {
154 const valueType = typeof originalValue
155 const isNull = originalValue === null
156 const inputProps = {
157 size: 'tiny' as const,
158 value: String(currentValue),
159 onChange: (event: ChangeEvent<HTMLInputElement>) => onToggle(event.target.value),
160 className: 'w-32',
161 }
162
163 return (
164 <div className={cn('px-4 py-3 flex flex-col gap-0.5', isOverridden && 'bg-warning/5')}>
165 <div className="flex items-center justify-between gap-4">
166 <div className="flex flex-col items-start gap-1">
167 <div className="flex gap-2 min-w-0 h-4">
168 <span
169 className={cn(
170 'font-mono text-xs truncate',
171 isOverridden ? 'text-warning' : 'text-foreground'
172 )}
173 >
174 {flagName}
175 </span>
176 {isOverridden && (
177 <Badge variant="warning" className="shrink-0">
178 Overridden
179 </Badge>
180 )}
181 {isNull && (
182 <Badge variant="secondary" className="shrink-0">
183 null
184 </Badge>
185 )}
186 </div>
187 <div className="text-xs text-foreground-muted uppercase font-mono">
188 Original:{' '}
189 <code className="text-foreground-lighter">{JSON.stringify(originalValue)}</code>
190 </div>
191 </div>
192
193 {isNull ? (
194 <Input size="tiny" value="null" disabled className="w-32 opacity-50" />
195 ) : valueType === 'boolean' ? (
196 <Switch
197 checked={currentValue as boolean}
198 onCheckedChange={(checked) => onToggle(checked)}
199 />
200 ) : valueType === 'number' ? (
201 <Input type="number" {...inputProps} />
202 ) : (
203 <Input {...inputProps} />
204 )}
205 </div>
206 </div>
207 )
208}
209
210export function DevToolbar({ extraTabs = [] }: { extraTabs?: ExtraTab[] }) {
211 const { isEnabled, isOpen, setIsOpen, events, setEvents, dismissToolbar } = useDevToolbar()
212 const [activeTab, setActiveTab] = useState<string>('events')
213 const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog')
214 const [eventFilter, setEventFilter] = useState<string>('')
215 const { posthog: posthogFlags, configcat: configcatFlags } = useFeatureFlags()
216 const [phFlagOverrides, setPhFlagOverrides] = useState<Record<string, unknown>>({})
217 const [ccFlagOverrides, setCcFlagOverrides] = useState<Record<string, unknown>>({})
218 const [phFlagOriginals, setPhFlagOriginals] = useState<Record<string, unknown>>({})
219 const [ccFlagOriginals, setCcFlagOriginals] = useState<Record<string, unknown>>({})
220
221 const loadOverrides = useCallback(
222 (cookieName: string, label: string, setter: (value: Record<string, unknown>) => void) => {
223 const parsed = safeJsonParse<Record<string, unknown>>(getCookie(cookieName), {}, label)
224 if (Object.keys(parsed).length > 0) {
225 setter(parsed)
226 }
227 },
228 []
229 )
230
231 const saveOverrides = useCallback(
232 (
233 cookieName: string,
234 overrides: Record<string, unknown>,
235 setter: (value: Record<string, unknown>) => void
236 ) => {
237 setter(overrides)
238 if (Object.keys(overrides).length > 0) {
239 setCookie(cookieName, JSON.stringify(overrides), '/')
240 } else {
241 deleteCookie(cookieName)
242 }
243 },
244 []
245 )
246
247 const updateOriginals = useCallback(
248 (
249 storageKey: typeof PH_ORIGINALS_KEY | typeof CC_ORIGINALS_KEY,
250 setter: Dispatch<SetStateAction<Record<string, unknown>>>
251 ) =>
252 (updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
253 setter((prev) => {
254 const next = updater(prev)
255 writeOriginals(storageKey, next)
256 return next
257 })
258 },
259 []
260 )
261
262 useEffect(() => {
263 loadOverrides('x-ph-flag-overrides', 'PostHog flag overrides', setPhFlagOverrides)
264 loadOverrides('x-cc-flag-overrides', 'ConfigCat flag overrides', setCcFlagOverrides)
265 }, [loadOverrides])
266
267 useEffect(() => {
268 setPhFlagOriginals(readOriginals(PH_ORIGINALS_KEY))
269 setCcFlagOriginals(readOriginals(CC_ORIGINALS_KEY))
270 }, [])
271
272 useEffect(() => {
273 const STYLE_ID = 'dev-toolbar-hide-native-devtools'
274 const existing = document.getElementById(STYLE_ID)
275
276 if (isOpen) {
277 if (!existing) {
278 const style = document.createElement('style')
279 style.id = STYLE_ID
280 style.textContent = `
281 .tsqd-open-btn, .tsqd-open-btn-container { display: none !important; }
282 `
283 document.head.appendChild(style)
284 }
285 } else {
286 existing?.remove()
287 }
288 }, [isOpen])
289
290 const updatePhOriginals = updateOriginals(PH_ORIGINALS_KEY, setPhFlagOriginals)
291 const updateCcOriginals = updateOriginals(CC_ORIGINALS_KEY, setCcFlagOriginals)
292
293 const togglePhFlagOverride = (flagName: string, value: unknown) => {
294 const originalValue = phFlagOriginals[flagName] ?? posthogFlags[flagName]
295 const parsedValue = parseOverrideValue(value, originalValue)
296 if (valuesAreEqual(parsedValue, originalValue)) {
297 const newOverrides = { ...phFlagOverrides }
298 delete newOverrides[flagName]
299 saveOverrides('x-ph-flag-overrides', newOverrides, setPhFlagOverrides)
300 return
301 }
302
303 updatePhOriginals((prev) =>
304 flagName in prev ? prev : { ...prev, [flagName]: posthogFlags[flagName] }
305 )
306 const newOverrides = { ...phFlagOverrides, [flagName]: parsedValue }
307 saveOverrides('x-ph-flag-overrides', newOverrides, setPhFlagOverrides)
308 }
309
310 const toggleCcFlagOverride = (flagName: string, value: unknown) => {
311 const originalValue = ccFlagOriginals[flagName] ?? configcatFlags[flagName]
312 const parsedValue = parseOverrideValue(value, originalValue)
313 if (valuesAreEqual(parsedValue, originalValue)) {
314 const newOverrides = { ...ccFlagOverrides }
315 delete newOverrides[flagName]
316 saveOverrides('x-cc-flag-overrides', newOverrides, setCcFlagOverrides)
317 return
318 }
319
320 updateCcOriginals((prev) =>
321 flagName in prev ? prev : { ...prev, [flagName]: configcatFlags[flagName] }
322 )
323 const newOverrides = { ...ccFlagOverrides, [flagName]: parsedValue }
324 saveOverrides('x-cc-flag-overrides', newOverrides, setCcFlagOverrides)
325 }
326
327 const clearAllOverrides = () => {
328 setPhFlagOverrides({})
329 setCcFlagOverrides({})
330 setPhFlagOriginals({})
331 setCcFlagOriginals({})
332 deleteCookie('x-ph-flag-overrides')
333 deleteCookie('x-cc-flag-overrides')
334 writeOriginals(PH_ORIGINALS_KEY, {})
335 writeOriginals(CC_ORIGINALS_KEY, {})
336 window.location.reload()
337 }
338
339 const normalizedFilter = eventFilter.trim().toLowerCase()
340 const filteredEvents = (
341 normalizedFilter
342 ? events.filter(
343 (e) =>
344 e.eventName.toLowerCase().includes(normalizedFilter) ||
345 e.eventType.toLowerCase().includes(normalizedFilter)
346 )
347 : events
348 )
349 .slice()
350 .sort((a, b) => b.timestamp - a.timestamp)
351
352 const phOverrideCount = Object.keys(phFlagOverrides).length
353 const ccOverrideCount = Object.keys(ccFlagOverrides).length
354 const totalOverrideCount = phOverrideCount + ccOverrideCount
355
356 if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
357
358 return (
359 <Sheet open={isOpen} onOpenChange={setIsOpen}>
360 <SheetContent
361 side="bottom"
362 size="lg"
363 className="flex flex-col p-0 gap-0 overflow-hidden"
364 showClose={false}
365 hasOverlay={false}
366 onOpenAutoFocus={(event) => event.preventDefault()}
367 >
368 <Tabs
369 value={activeTab}
370 onValueChange={setActiveTab}
371 className="flex flex-col flex-1 min-h-0 overflow-hidden"
372 >
373 <SheetHeader className="border-b shrink-0 space-y-0 p-0">
374 <SheetTitle className="sr-only">Dev Toolbar</SheetTitle>
375 <SheetDescription className="sr-only">
376 View telemetry events and feature flags for local development
377 </SheetDescription>
378 <div className="flex items-center px-6">
379 <Image
380 src="/img/logo-pixel-small-light.png"
381 alt="Dev Toolbar"
382 width={16}
383 height={16}
384 style={{
385 filter:
386 'brightness(0) saturate(100%) invert(72%) sepia(57%) saturate(431%) hue-rotate(108deg) brightness(95%) contrast(91%)',
387 }}
388 aria-hidden="true"
389 className="shrink-0 mr-4"
390 />
391 <TabsList className="flex gap-x-4 rounded-none border-none! h-auto">
392 <TabsTrigger value="events" className="text-xs py-3 border-b font-mono uppercase">
393 Events ({filteredEvents.length})
394 </TabsTrigger>
395 <TabsTrigger value="flags" className="text-xs py-3 border-b font-mono uppercase">
396 Flags {totalOverrideCount > 0 && `(${totalOverrideCount})`}
397 </TabsTrigger>
398 {extraTabs.map((tab) => (
399 <TabsTrigger
400 key={tab.id}
401 value={tab.id}
402 className="text-xs py-3 border-b font-mono uppercase"
403 >
404 {tab.label}
405 </TabsTrigger>
406 ))}
407 </TabsList>
408 <div className="ml-auto flex items-center gap-2">
409 <Tooltip>
410 <TooltipTrigger asChild>
411 <Button
412 type="text"
413 icon={<EyeOff className="w-4 h-4" />}
414 onClick={dismissToolbar}
415 className="text-foreground-light hover:text-foreground p-1"
416 />
417 </TooltipTrigger>
418 <TooltipContent side="top">Hide Dev Toolbar</TooltipContent>
419 </Tooltip>
420 <Tooltip>
421 <TooltipTrigger asChild>
422 <SheetClose asChild>
423 <Button
424 type="text"
425 icon={<X className="w-4 h-4" />}
426 className="text-foreground-light hover:text-foreground p-1"
427 />
428 </SheetClose>
429 </TooltipTrigger>
430 <TooltipContent side="top">Close</TooltipContent>
431 </Tooltip>
432 </div>
433 </div>
434 </SheetHeader>
435
436 <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
437 {activeTab === 'events' && (
438 <div className="flex-1 overflow-hidden flex flex-col">
439 <div className="flex items-center justify-between border-b shrink-0 px-6 py-2 gap-2">
440 <InputGroup className="w-full max-w-sm">
441 <InputGroupInput
442 size="tiny"
443 placeholder="Filter events..."
444 value={eventFilter}
445 onChange={(e) => setEventFilter(e.target.value)}
446 />
447 <InputGroupAddon>
448 <Search size={14} className="text-foreground-lighter" />
449 </InputGroupAddon>
450 </InputGroup>
451 <Button
452 type="default"
453 onClick={() => setEvents([])}
454 className="text-foreground-lighter hover:text-foreground"
455 >
456 Clear all
457 </Button>
458 </div>
459
460 {!IS_LOCAL_DEV && (
461 <div className="px-6 py-2 text-xs text-foreground-muted border-b bg-surface-100">
462 Server-side events are only visible when using the toolbar in local development
463 </div>
464 )}
465
466 <div className="flex-1 min-h-0 overflow-y-auto pb-4">
467 {filteredEvents.length === 0 ? (
468 <div className="text-center text-foreground-lighter py-8 text-sm">
469 No events yet. Interact with the app to see telemetry events.
470 </div>
471 ) : (
472 <div className="overflow-hidden">
473 {filteredEvents.map((event) => (
474 <EventRow key={`${event.source}-${event.id}`} event={event} />
475 ))}
476 </div>
477 )}
478 </div>
479 </div>
480 )}
481
482 {activeTab === 'flags' && (
483 <div className="flex-1 min-h-0 overflow-hidden flex">
484 {/* Sidebar */}
485 <div className="w-44 border-r shrink-0 flex flex-col">
486 <nav className="flex flex-col p-3 gap-0.5">
487 {(
488 [
489 { id: 'posthog', label: 'PostHog', count: phOverrideCount },
490 { id: 'configcat', label: 'ConfigCat', count: ccOverrideCount },
491 ] as const
492 ).map(({ id, label, count }) => (
493 <button
494 key={id}
495 type="button"
496 onClick={() => setFlagsSubTab(id)}
497 className={cn(
498 'flex items-center justify-between px-3 py-1.5 rounded-sm text-sm text-left uppercase font-mono tracking-wide',
499 flagsSubTab === id
500 ? 'bg-surface-300 text-foreground'
501 : 'text-foreground-light hover:bg-surface-200'
502 )}
503 >
504 <span className="font-mono text-xs">{label}</span>
505 {count > 0 && (
506 <span className="text-xs text-foreground-lighter">{count}</span>
507 )}
508 </button>
509 ))}
510 </nav>
511 {totalOverrideCount > 0 && (
512 <div className="mt-auto p-2 border-t">
513 <Button type="outline" size="tiny" block onClick={clearAllOverrides}>
514 Reset & Reload
515 </Button>
516 </div>
517 )}
518 </div>
519
520 {/* Flag list */}
521 <div className="flex-1 min-h-0 overflow-y-auto pb-6">
522 <div className="divide-y">
523 {flagsSubTab === 'posthog' &&
524 (Object.keys(posthogFlags).length === 0 ? (
525 <div className="text-center text-foreground-lighter py-8 text-sm">
526 No PostHog feature flags loaded yet.
527 </div>
528 ) : (
529 Object.entries(posthogFlags).map(([flagName, flagValue]) => (
530 <FlagRow
531 key={flagName}
532 flagName={flagName}
533 currentValue={
534 phFlagOverrides[flagName] ?? phFlagOriginals[flagName] ?? flagValue
535 }
536 originalValue={phFlagOriginals[flagName] ?? flagValue}
537 isOverridden={flagName in phFlagOverrides}
538 onToggle={(value) => togglePhFlagOverride(flagName, value)}
539 />
540 ))
541 ))}
542 {flagsSubTab === 'configcat' &&
543 (Object.keys(configcatFlags).length === 0 ? (
544 <div className="text-center text-foreground-lighter py-8 text-sm">
545 No ConfigCat feature flags loaded yet.
546 </div>
547 ) : (
548 Object.entries(configcatFlags).map(([flagName, flagValue]) => (
549 <FlagRow
550 key={flagName}
551 flagName={flagName}
552 currentValue={
553 ccFlagOverrides[flagName] ?? ccFlagOriginals[flagName] ?? flagValue
554 }
555 originalValue={ccFlagOriginals[flagName] ?? flagValue}
556 isOverridden={flagName in ccFlagOverrides}
557 onToggle={(value) => toggleCcFlagOverride(flagName, value)}
558 />
559 ))
560 ))}
561 </div>
562 </div>
563 </div>
564 )}
565
566 {extraTabs.map((tab) => (
567 <div
568 key={tab.id}
569 className={cn('flex-1 min-h-0 overflow-y-auto', activeTab !== tab.id && 'hidden')}
570 >
571 {tab.content}
572 </div>
573 ))}
574 </div>
575 </Tabs>
576 </SheetContent>
577 </Sheet>
578 )
579}