DevToolbar.tsx 21 KB

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