@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.
- package/README.md +41 -0
- package/package.json +66 -0
- package/src/Devtools.tsx +48 -0
- package/src/capture.ts +175 -0
- package/src/index.ts +44 -0
- package/src/ingest.ts +27 -0
- package/src/instance.ts +185 -0
- package/src/internal.ts +85 -0
- package/src/panel/DevtoolsButton.tsx +129 -0
- package/src/panel/DevtoolsPanel.tsx +142 -0
- package/src/panel/LogsTab.tsx +165 -0
- package/src/server.ts +86 -0
- package/src/store.ts +170 -0
- package/src/styles.css +6 -0
- package/src/types.ts +113 -0
- package/src/useDebugMode.ts +58 -0
|
@@ -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