@djangocfg/devtools 2.1.459

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ 'use client'
2
+
3
+ import React, { useCallback, useEffect, useRef } from 'react'
4
+ import { useStore } from 'zustand'
5
+
6
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core/components'
7
+ import { useHotkey } from '@djangocfg/ui-core/hooks'
8
+ import { cn } from '@djangocfg/ui-core/lib'
9
+ import { Bug } from 'lucide-react'
10
+
11
+ import { isDevelopment } from '../internal'
12
+ import { devtoolsStore } from '../store'
13
+ import { useDebugMode } from '../useDebugMode'
14
+ import { DevtoolsPanel } from './DevtoolsPanel'
15
+ import type { DevtoolsPanelProps } from './DevtoolsPanel'
16
+
17
+ export interface DevtoolsButtonProps {
18
+ className?: string
19
+ /** Props forwarded to the panel */
20
+ panel?: DevtoolsPanelProps
21
+ /**
22
+ * Explicitly disable. Default: undefined (auto — visible in dev, hidden in
23
+ * prod, `?debug=1` always unlocks).
24
+ */
25
+ enabled?: boolean
26
+ }
27
+
28
+ /**
29
+ * Floating debug button.
30
+ *
31
+ * Visibility:
32
+ * 1. `enabled === false` and no `?debug=1` → renders nothing
33
+ * 2. dev or `?debug=1` → visible button + ⌘D shortcut
34
+ * 3. prod without `?debug=1` → invisible click trap (5 clicks in 2s unlock)
35
+ */
36
+ export function DevtoolsButton({ className, panel = {}, enabled }: DevtoolsButtonProps) {
37
+ const isOpen = useStore(devtoolsStore, (s) => s.isOpen)
38
+ const togglePanel = useStore(devtoolsStore, (s) => s.togglePanel)
39
+ const errorCount = useStore(devtoolsStore, (s) =>
40
+ s.entries.reduce((n, e) => (e.level === 'error' ? n + 1 : n), 0),
41
+ )
42
+ const isDebugMode = useDebugMode()
43
+ const unlocked = isDevelopment || isDebugMode
44
+
45
+ useHotkey(
46
+ 'meta+d',
47
+ (e) => {
48
+ e.preventDefault()
49
+ if (unlocked) togglePanel()
50
+ },
51
+ { preventDefault: true },
52
+ )
53
+
54
+ // Hide the Next.js dev indicator while the panel is open — it overlaps.
55
+ useEffect(() => {
56
+ if (typeof document === 'undefined') return
57
+ const apply = () => {
58
+ document.querySelectorAll<HTMLElement>('nextjs-portal').forEach((el) => {
59
+ el.style.display = isOpen ? 'none' : ''
60
+ })
61
+ }
62
+ apply()
63
+ const observer = new MutationObserver(apply)
64
+ observer.observe(document.body, { childList: true })
65
+ return () => observer.disconnect()
66
+ }, [isOpen])
67
+
68
+ // Easter egg: 5 clicks in 2s on the invisible trap opens the panel.
69
+ const clicks = useRef<number[]>([])
70
+ const handleTrapClick = useCallback(() => {
71
+ const now = Date.now()
72
+ clicks.current = [...clicks.current.filter((t) => now - t < 2000), now]
73
+ if (clicks.current.length >= 5) {
74
+ clicks.current = []
75
+ devtoolsStore.getState().openPanel()
76
+ }
77
+ }, [])
78
+
79
+ if (enabled === false && !isDebugMode) return null
80
+
81
+ if (!unlocked) {
82
+ return (
83
+ <div
84
+ className="fixed bottom-5 left-5 z-[99999] h-9 w-9 cursor-default opacity-0"
85
+ onClick={handleTrapClick}
86
+ />
87
+ )
88
+ }
89
+
90
+ return (
91
+ <>
92
+ {!isOpen && (
93
+ // Inline position, not Tailwind: this package's utility classes aren't
94
+ // always in the consumer app's content scan. `left: 64px` in dev
95
+ // clears the Next.js dev indicator.
96
+ <div className="fixed z-[99999]" style={{ bottom: 20, left: isDevelopment ? 64 : 20 }}>
97
+ <Tooltip>
98
+ <TooltipTrigger asChild>
99
+ <button
100
+ type="button"
101
+ className={cn(
102
+ 'relative flex h-9 w-9 items-center justify-center rounded-full',
103
+ 'bg-black/80 backdrop-blur-xl',
104
+ 'shadow-[0_0_0_1px_#171717,inset_0_0_0_1px_hsla(0,0%,100%,0.14),0px_16px_32px_-8px_rgba(0,0,0,0.24)]',
105
+ 'transition-all duration-150 hover:scale-105',
106
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-white/50',
107
+ errorCount > 0 && 'bg-red-600/90',
108
+ className,
109
+ )}
110
+ onClick={togglePanel}
111
+ >
112
+ <Bug className={cn('h-4 w-4', errorCount > 0 ? 'text-white' : 'text-white/80')} />
113
+ {errorCount > 0 && (
114
+ <span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-white text-[10px] font-semibold text-red-600 shadow-sm">
115
+ {errorCount}
116
+ </span>
117
+ )}
118
+ </button>
119
+ </TooltipTrigger>
120
+ <TooltipContent side="right">
121
+ Devtools <kbd className="ml-1 text-[10px] opacity-60">⌘D</kbd>
122
+ </TooltipContent>
123
+ </Tooltip>
124
+ </div>
125
+ )}
126
+ <DevtoolsPanel {...panel} />
127
+ </>
128
+ )
129
+ }
@@ -0,0 +1,142 @@
1
+ 'use client'
2
+
3
+ import React, { useMemo, useState } from 'react'
4
+ import { useStore } from 'zustand'
5
+
6
+ import { Badge, Button, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core/components'
7
+ import { cn } from '@djangocfg/ui-core/lib'
8
+ import { Bug, Maximize2, Minimize2, ScrollText, X } from 'lucide-react'
9
+
10
+ import { devtoolsStore } from '../store'
11
+ import type { CustomDebugTab } from '../types'
12
+ import { LogsTab } from './LogsTab'
13
+
14
+ export interface DevtoolsPanelProps {
15
+ /** Additional app-specific tabs */
16
+ tabs?: CustomDebugTab[]
17
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
18
+ defaultHeight?: number
19
+ defaultWidth?: number
20
+ }
21
+
22
+ const POSITION_STYLES: Record<NonNullable<DevtoolsPanelProps['position']>, React.CSSProperties> = {
23
+ 'bottom-right': { bottom: 16, right: 16 },
24
+ 'bottom-left': { bottom: 16, left: 16 },
25
+ 'top-right': { top: 16, right: 16 },
26
+ 'top-left': { top: 16, left: 16 },
27
+ }
28
+
29
+ export function DevtoolsPanel({
30
+ tabs: customTabs = [],
31
+ position = 'bottom-left',
32
+ defaultHeight = 480,
33
+ defaultWidth = 560,
34
+ }: DevtoolsPanelProps) {
35
+ const isOpen = useStore(devtoolsStore, (s) => s.isOpen)
36
+ const activeTab = useStore(devtoolsStore, (s) => s.tab)
37
+ const setTab = useStore(devtoolsStore, (s) => s.setTab)
38
+ const closePanel = useStore(devtoolsStore, (s) => s.closePanel)
39
+ const errorCount = useStore(devtoolsStore, (s) =>
40
+ s.entries.reduce((n, e) => (e.level === 'error' ? n + 1 : n), 0),
41
+ )
42
+
43
+ const [isMinimized, setIsMinimized] = useState(false)
44
+
45
+ const allTabs = useMemo(
46
+ () => [
47
+ { id: 'logs', label: 'Logs', icon: ScrollText as React.ElementType },
48
+ ...customTabs.map((t) => ({ id: t.id, label: t.label, icon: t.icon })),
49
+ ],
50
+ [customTabs],
51
+ )
52
+
53
+ if (!isOpen) return null
54
+
55
+ return (
56
+ <div
57
+ className="fixed z-[99999] flex flex-col overflow-hidden rounded-lg border border-border bg-background shadow-xl"
58
+ style={{
59
+ ...POSITION_STYLES[position],
60
+ width: defaultWidth,
61
+ maxWidth: 'calc(100vw - 32px)',
62
+ height: isMinimized ? 40 : defaultHeight,
63
+ maxHeight: 'calc(100vh - 32px)',
64
+ }}
65
+ >
66
+ {/* Header */}
67
+ <div className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/50 px-3 py-1.5">
68
+ <Bug className="h-4 w-4 text-primary" />
69
+ <span className="text-sm font-medium">Devtools</span>
70
+ {errorCount > 0 && (
71
+ <Badge variant="destructive" className="text-[10px]">
72
+ {errorCount} {errorCount === 1 ? 'error' : 'errors'}
73
+ </Badge>
74
+ )}
75
+
76
+ {!isMinimized && (
77
+ <div className="ml-3 flex items-center gap-0.5">
78
+ {allTabs.map((tab) => {
79
+ const Icon = tab.icon
80
+ const isActive = activeTab === tab.id
81
+ return (
82
+ <button
83
+ key={tab.id}
84
+ type="button"
85
+ onClick={() => setTab(tab.id)}
86
+ className={cn(
87
+ 'flex items-center gap-1.5 rounded px-2 py-0.5 text-xs transition-colors',
88
+ isActive
89
+ ? 'bg-background text-foreground shadow-sm'
90
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
91
+ )}
92
+ >
93
+ <Icon className="h-3 w-3" />
94
+ {tab.label}
95
+ </button>
96
+ )
97
+ })}
98
+ </div>
99
+ )}
100
+
101
+ <div className="ml-auto flex items-center gap-1">
102
+ <Tooltip>
103
+ <TooltipTrigger asChild>
104
+ <Button
105
+ variant="ghost"
106
+ size="icon"
107
+ className="h-6 w-6"
108
+ onClick={() => setIsMinimized((v) => !v)}
109
+ >
110
+ {isMinimized ? <Maximize2 className="h-3.5 w-3.5" /> : <Minimize2 className="h-3.5 w-3.5" />}
111
+ </Button>
112
+ </TooltipTrigger>
113
+ <TooltipContent>{isMinimized ? 'Expand' : 'Minimize'}</TooltipContent>
114
+ </Tooltip>
115
+ <Tooltip>
116
+ <TooltipTrigger asChild>
117
+ <Button variant="ghost" size="icon" className="h-6 w-6" onClick={closePanel}>
118
+ <X className="h-3.5 w-3.5" />
119
+ </Button>
120
+ </TooltipTrigger>
121
+ <TooltipContent>Close</TooltipContent>
122
+ </Tooltip>
123
+ </div>
124
+ </div>
125
+
126
+ {/* Content */}
127
+ {!isMinimized && (
128
+ <div className="min-h-0 flex-1">
129
+ {activeTab === 'logs' && <LogsTab isActive />}
130
+ {customTabs.map((tab) => {
131
+ const Panel = tab.panel
132
+ return (
133
+ <div key={tab.id} className={cn('h-full', activeTab !== tab.id && 'hidden')}>
134
+ <Panel isActive={activeTab === tab.id} />
135
+ </div>
136
+ )
137
+ })}
138
+ </div>
139
+ )}
140
+ </div>
141
+ )
142
+ }
@@ -0,0 +1,165 @@
1
+ 'use client'
2
+
3
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
4
+ import { useStore } from 'zustand'
5
+
6
+ import { Badge, Button, Input } from '@djangocfg/ui-core/components'
7
+ import { cn } from '@djangocfg/ui-core/lib'
8
+ import { Copy, Trash2 } from 'lucide-react'
9
+
10
+ import { devtoolsStore } from '../store'
11
+ import type { LogEntry, LogLevel } from '../types'
12
+
13
+ const LEVELS: LogLevel[] = ['debug', 'info', 'success', 'warn', 'error']
14
+
15
+ const LEVEL_COLORS: Record<LogLevel, string> = {
16
+ debug: 'text-muted-foreground',
17
+ info: 'text-blue-500',
18
+ success: 'text-emerald-500',
19
+ warn: 'text-amber-500',
20
+ error: 'text-red-500',
21
+ }
22
+
23
+ function formatTime(d: Date): string {
24
+ return d.toTimeString().slice(0, 8) + '.' + String(d.getMilliseconds()).padStart(3, '0')
25
+ }
26
+
27
+ function Row({ entry }: { entry: LogEntry }) {
28
+ const [expanded, setExpanded] = useState(false)
29
+ const hasDetails = Boolean(entry.stack || (entry.data && Object.keys(entry.data).length > 0))
30
+
31
+ return (
32
+ <div
33
+ className={cn(
34
+ 'border-b border-border/50 px-3 py-1.5 font-mono text-xs leading-relaxed',
35
+ hasDetails && 'cursor-pointer hover:bg-muted/50',
36
+ )}
37
+ onClick={hasDetails ? () => setExpanded((v) => !v) : undefined}
38
+ >
39
+ <div className="flex items-baseline gap-2">
40
+ <span className="shrink-0 tabular-nums text-muted-foreground/70">
41
+ {formatTime(entry.timestamp)}
42
+ </span>
43
+ <span className={cn('shrink-0 font-semibold uppercase', LEVEL_COLORS[entry.level])}>
44
+ {entry.level}
45
+ </span>
46
+ <span className="shrink-0 text-muted-foreground">{entry.source}</span>
47
+ <span className="min-w-0 break-words">{entry.message}</span>
48
+ </div>
49
+ {expanded && (
50
+ <div className="mt-1 space-y-1 pl-2">
51
+ {entry.data && Object.keys(entry.data).length > 0 && (
52
+ <pre className="overflow-x-auto rounded bg-muted/60 p-2 text-[11px]">
53
+ {JSON.stringify(entry.data, null, 2)}
54
+ </pre>
55
+ )}
56
+ {entry.stack && (
57
+ <pre className="overflow-x-auto rounded bg-muted/60 p-2 text-[11px] text-red-400/90">
58
+ {entry.stack}
59
+ </pre>
60
+ )}
61
+ </div>
62
+ )}
63
+ </div>
64
+ )
65
+ }
66
+
67
+ export function LogsTab({ isActive }: { isActive: boolean }) {
68
+ const entries = useStore(devtoolsStore, (s) => s.entries)
69
+ const clearEntries = useStore(devtoolsStore, (s) => s.clearEntries)
70
+
71
+ const [levels, setLevels] = useState<Set<LogLevel>>(new Set(LEVELS))
72
+ const [search, setSearch] = useState('')
73
+ const scrollRef = useRef<HTMLDivElement>(null)
74
+
75
+ const filtered = useMemo(() => {
76
+ const q = search.trim().toLowerCase()
77
+ return entries.filter((e) => {
78
+ if (!levels.has(e.level)) return false
79
+ if (!q) return true
80
+ return (
81
+ e.message.toLowerCase().includes(q) ||
82
+ e.source.toLowerCase().includes(q) ||
83
+ (e.data ? JSON.stringify(e.data).toLowerCase().includes(q) : false)
84
+ )
85
+ })
86
+ }, [entries, levels, search])
87
+
88
+ // Follow the tail while the tab is visible
89
+ useEffect(() => {
90
+ if (!isActive) return
91
+ const el = scrollRef.current
92
+ if (el) el.scrollTop = el.scrollHeight
93
+ }, [filtered.length, isActive])
94
+
95
+ const toggleLevel = (level: LogLevel) =>
96
+ setLevels((prev) => {
97
+ const next = new Set(prev)
98
+ if (next.has(level)) next.delete(level)
99
+ else next.add(level)
100
+ return next
101
+ })
102
+
103
+ return (
104
+ <div className="flex h-full flex-col">
105
+ {/* Toolbar */}
106
+ <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
107
+ <div className="flex items-center gap-1">
108
+ {LEVELS.map((level) => (
109
+ <button
110
+ key={level}
111
+ type="button"
112
+ onClick={() => toggleLevel(level)}
113
+ className={cn(
114
+ 'rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase transition-colors',
115
+ levels.has(level)
116
+ ? cn('bg-muted', LEVEL_COLORS[level])
117
+ : 'text-muted-foreground/40 hover:text-muted-foreground',
118
+ )}
119
+ >
120
+ {level}
121
+ </button>
122
+ ))}
123
+ </div>
124
+ <Input
125
+ value={search}
126
+ onChange={(e) => setSearch(e.target.value)}
127
+ placeholder="Filter…"
128
+ className="h-6 flex-1 text-xs"
129
+ />
130
+ <Badge variant="secondary" className="text-[10px] tabular-nums">
131
+ {filtered.length}
132
+ </Badge>
133
+ <Button
134
+ variant="ghost"
135
+ size="icon"
136
+ className="h-6 w-6"
137
+ title="Copy visible entries as JSON"
138
+ onClick={() => navigator.clipboard?.writeText(JSON.stringify(filtered, null, 2))}
139
+ >
140
+ <Copy className="h-3 w-3" />
141
+ </Button>
142
+ <Button
143
+ variant="ghost"
144
+ size="icon"
145
+ className="h-6 w-6"
146
+ title="Clear"
147
+ onClick={clearEntries}
148
+ >
149
+ <Trash2 className="h-3 w-3" />
150
+ </Button>
151
+ </div>
152
+
153
+ {/* Entries */}
154
+ <div ref={scrollRef} className="flex-1 overflow-y-auto">
155
+ {filtered.length === 0 ? (
156
+ <div className="flex h-full items-center justify-center text-xs text-muted-foreground">
157
+ No log entries
158
+ </div>
159
+ ) : (
160
+ filtered.map((entry) => <Row key={entry.id} entry={entry} />)
161
+ )}
162
+ </div>
163
+ </div>
164
+ )
165
+ }
package/src/server.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @djangocfg/devtools/server
3
+ *
4
+ * Server-side error reporting for Next.js route handlers, Server Components,
5
+ * and middleware. No browser APIs — safe in Node.js / Edge Runtime.
6
+ *
7
+ * @example
8
+ * import { serverDevtools } from '@djangocfg/devtools/server'
9
+ * serverDevtools.configure({ project: 'my-app', baseUrl: 'https://api.myapp.com' })
10
+ *
11
+ * export async function POST(req: Request) {
12
+ * try { ... } catch (err) {
13
+ * await serverDevtools.captureError(err, { url: req.url })
14
+ * return new Response('Internal Server Error', { status: 500 })
15
+ * }
16
+ * }
17
+ */
18
+
19
+ import { sendBatch } from './ingest'
20
+ import { EventLevel, EventType } from './types'
21
+ import type { DevtoolsEvent, ServerDevtoolsConfig } from './types'
22
+
23
+ export { EventLevel, EventType } from './types'
24
+ export type { DevtoolsEvent, ServerDevtoolsConfig } from './types'
25
+
26
+ let _config: ServerDevtoolsConfig = {}
27
+
28
+ async function send(events: DevtoolsEvent[]): Promise<void> {
29
+ try {
30
+ await sendBatch(_config.baseUrl ?? '', events.map((e) => ({
31
+ project_name: _config.project,
32
+ environment: _config.environment,
33
+ ...e,
34
+ })))
35
+ } catch {
36
+ // Never throw from error reporting
37
+ }
38
+ }
39
+
40
+ export const serverDevtools = {
41
+ configure(config: ServerDevtoolsConfig): void {
42
+ _config = config
43
+ },
44
+
45
+ async captureError(
46
+ err: unknown,
47
+ ctx?: { url?: string; extra?: Record<string, unknown> },
48
+ ): Promise<void> {
49
+ await send([
50
+ {
51
+ event_type: EventType.JS_ERROR,
52
+ level: EventLevel.ERROR,
53
+ message: err instanceof Error ? err.message : String(err),
54
+ stack_trace: err instanceof Error ? (err.stack ?? '') : '',
55
+ url: ctx?.url ?? '',
56
+ extra: ctx?.extra,
57
+ },
58
+ ])
59
+ },
60
+
61
+ async captureNetworkError(
62
+ status: number,
63
+ method: string,
64
+ apiUrl: string,
65
+ ctx?: { pageUrl?: string; extra?: Record<string, unknown> },
66
+ ): Promise<void> {
67
+ await send([
68
+ {
69
+ event_type: EventType.NETWORK_ERROR,
70
+ level: status >= 500 ? EventLevel.ERROR : EventLevel.WARNING,
71
+ message: `HTTP ${status} — ${method} ${apiUrl}`,
72
+ url: ctx?.pageUrl ?? '',
73
+ http_status: status,
74
+ http_method: method,
75
+ http_url: apiUrl,
76
+ extra: ctx?.extra,
77
+ },
78
+ ])
79
+ },
80
+
81
+ async capture(event: DevtoolsEvent): Promise<void> {
82
+ await send([event])
83
+ },
84
+ }
85
+
86
+ export type ServerDevtools = typeof serverDevtools
package/src/store.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The ONE devtools store.
3
+ *
4
+ * Everything flows through here: captured events land in `entries` (what the
5
+ * panel renders) and — when ingest-worthy — in `outbox` (what gets POSTed to
6
+ * the backend). The old monitor/debuger split kept two stores synced through
7
+ * a bridge; a single store makes that whole layer unnecessary.
8
+ *
9
+ * Vanilla store so it works outside React (capture hooks, console API);
10
+ * React components subscribe via `useStore` from zustand.
11
+ */
12
+
13
+ import { createStore } from 'zustand/vanilla'
14
+
15
+ import { sendBatch } from './ingest'
16
+ import {
17
+ DEFAULT_DEDUPE_TTL,
18
+ DEFAULT_MAX_BUFFER,
19
+ computeFingerprint,
20
+ makeDeduper,
21
+ truncate,
22
+ } from './internal'
23
+ import type { DevtoolsConfig, DevtoolsEvent, LogEntry, LogLevel } from './types'
24
+
25
+ const MAX_ENTRIES = 1000
26
+ const MAX_BATCH = 25
27
+
28
+ // Circuit breaker: pause ingest after N consecutive transport failures
29
+ const BREAKER_THRESHOLD = 3
30
+ const BREAKER_COOLDOWN_MS = 60_000
31
+
32
+ const isRecent = makeDeduper()
33
+
34
+ let _counter = 0
35
+ const nextId = () => `dt-${Date.now()}-${++_counter}`
36
+
37
+ const eventToLogLevel: Record<string, LogLevel> = {
38
+ error: 'error',
39
+ warning: 'warn',
40
+ info: 'info',
41
+ debug: 'debug',
42
+ }
43
+
44
+ export interface DevtoolsState {
45
+ config: DevtoolsConfig
46
+ /** Panel feed — every captured event and local log line (ring buffer). */
47
+ entries: LogEntry[]
48
+ /** Events waiting to be POSTed to the ingest endpoint. */
49
+ outbox: DevtoolsEvent[]
50
+ // Panel UI
51
+ isOpen: boolean
52
+ tab: string
53
+ // Circuit breaker
54
+ _failures: number
55
+ _pausedUntil: number
56
+
57
+ setConfig: (config: DevtoolsConfig) => void
58
+ /** Add a local panel entry (loggers, custom instrumentation). Never ingested. */
59
+ addEntry: (entry: Omit<LogEntry, 'id' | 'timestamp'>) => void
60
+ /** Capture an event: panel entry + (if enabled) ingest outbox. */
61
+ capture: (event: DevtoolsEvent) => void
62
+ flush: (useBeacon?: boolean) => void
63
+ clearEntries: () => void
64
+ // Panel UI
65
+ openPanel: () => void
66
+ closePanel: () => void
67
+ togglePanel: () => void
68
+ setTab: (tab: string) => void
69
+ }
70
+
71
+ export const devtoolsStore = createStore<DevtoolsState>((set, get) => ({
72
+ config: {},
73
+ entries: [],
74
+ outbox: [],
75
+ isOpen: false,
76
+ tab: 'logs',
77
+ _failures: 0,
78
+ _pausedUntil: 0,
79
+
80
+ setConfig(config) {
81
+ set({ config })
82
+ },
83
+
84
+ addEntry(entry) {
85
+ const next = [...get().entries, { ...entry, id: nextId(), timestamp: new Date() }]
86
+ set({ entries: next.length > MAX_ENTRIES ? next.slice(-MAX_ENTRIES) : next })
87
+ },
88
+
89
+ capture(event) {
90
+ const { config } = get()
91
+
92
+ const message = truncate(event.message ?? '', 4997)
93
+ const stack = event.stack_trace ? truncate(event.stack_trace, 9997) : event.stack_trace
94
+ const fingerprint =
95
+ event.fingerprint ?? computeFingerprint(message, stack ?? '', event.http_url ?? event.url ?? '')
96
+
97
+ // One dedupe for everything: same fingerprint within TTL → drop entirely.
98
+ if (isRecent(fingerprint, config.dedupeTtl ?? DEFAULT_DEDUPE_TTL)) return
99
+
100
+ get().addEntry({
101
+ level: eventToLogLevel[event.level ?? 'error'] ?? 'error',
102
+ source: `capture:${event.event_type.toLowerCase()}`,
103
+ message,
104
+ data: {
105
+ ...(event.http_status != null && {
106
+ http_status: event.http_status,
107
+ http_method: event.http_method,
108
+ http_url: event.http_url,
109
+ }),
110
+ ...(event.extra ? { extra: event.extra } : {}),
111
+ },
112
+ stack: stack || undefined,
113
+ })
114
+
115
+ if (config.ingest === false) return
116
+
117
+ const sanitized: DevtoolsEvent = {
118
+ ...event,
119
+ message,
120
+ stack_trace: stack,
121
+ fingerprint,
122
+ build_id: event.build_id ?? config.buildId ?? '',
123
+ project_name: event.project_name ?? config.project,
124
+ environment: event.environment ?? config.environment,
125
+ // Drop oversized extras instead of failing serializer-side.
126
+ extra: (() => {
127
+ if (!event.extra) return event.extra
128
+ try {
129
+ return JSON.stringify(event.extra).length > 32_768 ? {} : event.extra
130
+ } catch {
131
+ return {}
132
+ }
133
+ })(),
134
+ }
135
+
136
+ const outbox = [...get().outbox, sanitized]
137
+ set({ outbox })
138
+
139
+ if (outbox.length >= (config.maxBufferSize ?? DEFAULT_MAX_BUFFER) || event.level === 'error') {
140
+ get().flush()
141
+ }
142
+ },
143
+
144
+ flush(useBeacon = false) {
145
+ const { outbox, config, _pausedUntil } = get()
146
+ if (outbox.length === 0 || Date.now() < _pausedUntil) return
147
+
148
+ const batch = outbox.slice(0, MAX_BATCH)
149
+ set({ outbox: outbox.slice(MAX_BATCH) })
150
+
151
+ sendBatch(config.baseUrl ?? '', batch, useBeacon).then(
152
+ () => set({ _failures: 0, _pausedUntil: 0 }),
153
+ () => {
154
+ const failures = get()._failures + 1
155
+ set({
156
+ _failures: failures,
157
+ _pausedUntil:
158
+ failures >= BREAKER_THRESHOLD ? Date.now() + BREAKER_COOLDOWN_MS : get()._pausedUntil,
159
+ })
160
+ },
161
+ )
162
+ },
163
+
164
+ clearEntries: () => set({ entries: [] }),
165
+
166
+ openPanel: () => set({ isOpen: true }),
167
+ closePanel: () => set({ isOpen: false }),
168
+ togglePanel: () => set((s) => ({ isOpen: !s.isOpen })),
169
+ setTab: (tab) => set({ tab }),
170
+ }))
package/src/styles.css ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Tailwind v4 source detection for @djangocfg/devtools.
3
+ * Add `@import "@djangocfg/devtools/styles";` to your app's globals.css
4
+ * (after ui-core styles) so the panel's utility classes get compiled.
5
+ */
6
+ @source "../**/*.{ts,tsx}";