| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579 |
- '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,
- InputGroup,
- InputGroupAddon,
- InputGroupInput,
- 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-sm 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 font-mono uppercase">
- Events ({filteredEvents.length})
- </TabsTrigger>
- <TabsTrigger value="flags" className="text-xs py-3 border-b 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 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 gap-2">
- <InputGroup className="w-full max-w-sm">
- <InputGroupInput
- size="tiny"
- placeholder="Filter events..."
- value={eventFilter}
- onChange={(e) => setEventFilter(e.target.value)}
- />
- <InputGroupAddon>
- <Search size={14} className="text-foreground-lighter" />
- </InputGroupAddon>
- </InputGroup>
- <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-sm 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>
- )
- }
|