@kontsedal/olas-devtools 0.0.1-rc.0
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/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/index.cjs +1922 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +204 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +204 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1914 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +45 -0
- package/src/DevtoolsLauncher.tsx +258 -0
- package/src/DevtoolsPanel.tsx +749 -0
- package/src/JsonView.tsx +151 -0
- package/src/format.ts +37 -0
- package/src/index.ts +13 -0
- package/src/store.ts +352 -0
- package/src/styles.ts +594 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
import type { DebugCacheEntry, Root } from '@kontsedal/olas-core'
|
|
2
|
+
import { use } from '@kontsedal/olas-react'
|
|
3
|
+
import { type ReactElement, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import { formatPath, formatTime } from './format'
|
|
5
|
+
import { JsonView } from './JsonView'
|
|
6
|
+
import {
|
|
7
|
+
type CacheEntry,
|
|
8
|
+
type ControllerNode,
|
|
9
|
+
DevtoolsStore,
|
|
10
|
+
type FieldEntry,
|
|
11
|
+
type MutationEntry,
|
|
12
|
+
} from './store'
|
|
13
|
+
import { DEVTOOLS_CSS } from './styles'
|
|
14
|
+
|
|
15
|
+
export type DevtoolsTab = 'tree' | 'cache' | 'inspector' | 'mutations' | 'fields'
|
|
16
|
+
|
|
17
|
+
export type DevtoolsPanelProps = {
|
|
18
|
+
/** The root to inspect. The panel subscribes to `root.__debug` on mount. */
|
|
19
|
+
root: Pick<Root<unknown>, '__debug'>
|
|
20
|
+
/** Initial tab. Default: `'tree'`. */
|
|
21
|
+
defaultTab?: DevtoolsTab
|
|
22
|
+
/** Cap on each event log. Default: 100. */
|
|
23
|
+
maxEntries?: number
|
|
24
|
+
/**
|
|
25
|
+
* Persist filter state to the URL hash under this key. When set,
|
|
26
|
+
* reloading the page restores filter + tab. Default: no persistence.
|
|
27
|
+
*/
|
|
28
|
+
urlHashKey?: string
|
|
29
|
+
/** How often (ms) to refresh the live cache inspector snapshot. Default 800. */
|
|
30
|
+
inspectorPollMs?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Drop-in devtools panel for an Olas root.
|
|
35
|
+
*
|
|
36
|
+
* Features:
|
|
37
|
+
* - **Tree** populated from the snapshot replay on mount (no lost events).
|
|
38
|
+
* - **Cache / Mutations / Fields** event logs in reverse chronological order.
|
|
39
|
+
* - **Filter** field per tab — text-matches kind, path, name, payload.
|
|
40
|
+
* - **Pause** toggle freezes the log without stopping ingestion.
|
|
41
|
+
* - **Click a row** to expand its payload from a truncated preview to the full
|
|
42
|
+
* JSON.
|
|
43
|
+
* - **Mutation durations** — `run → success/error` pairing surfaces elapsed ms.
|
|
44
|
+
*
|
|
45
|
+
* Styled inline (no CSS import needed) and scoped to the `.olas-devtools-*`
|
|
46
|
+
* class prefix. Hosts override the palette via `--olas-*` custom properties.
|
|
47
|
+
* Spec §13.
|
|
48
|
+
*/
|
|
49
|
+
export function DevtoolsPanel(props: DevtoolsPanelProps): ReactElement {
|
|
50
|
+
const { root, defaultTab = 'tree', maxEntries, urlHashKey, inspectorPollMs = 800 } = props
|
|
51
|
+
const store = useMemo(
|
|
52
|
+
() => new DevtoolsStore(maxEntries !== undefined ? { maxEntries } : undefined),
|
|
53
|
+
[maxEntries],
|
|
54
|
+
)
|
|
55
|
+
useEffect(() => store.attach(root), [root, store])
|
|
56
|
+
|
|
57
|
+
// Initial state read from URL hash if `urlHashKey` is set.
|
|
58
|
+
const initial = useMemo(() => readUrlHash(urlHashKey, defaultTab), [urlHashKey, defaultTab])
|
|
59
|
+
const [tab, setTab] = useState<DevtoolsTab>(initial.tab)
|
|
60
|
+
const [paused, setPaused] = useState(false)
|
|
61
|
+
// Filters are kept per-tab so switching back doesn't lose the query.
|
|
62
|
+
const [filters, setFilters] = useState<Record<DevtoolsTab, string>>(initial.filters)
|
|
63
|
+
const filter = filters[tab]
|
|
64
|
+
const setFilter = (q: string) => setFilters((prev) => ({ ...prev, [tab]: q }))
|
|
65
|
+
|
|
66
|
+
// Persist tab + filters back to the URL hash on every change.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (urlHashKey === undefined) return
|
|
69
|
+
writeUrlHash(urlHashKey, { tab, filters })
|
|
70
|
+
}, [urlHashKey, tab, filters])
|
|
71
|
+
|
|
72
|
+
// Live cache inspector — polls `root.__debug.queryEntries()` periodically.
|
|
73
|
+
// Polling is cheap (a single peek per entry) and bounded by inspectorPollMs;
|
|
74
|
+
// only the Cache Inspector view reads this.
|
|
75
|
+
const [cacheEntries, setCacheEntries] = useState<DebugCacheEntry[]>([])
|
|
76
|
+
const rootRef = useRef(root)
|
|
77
|
+
rootRef.current = root
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (tab !== 'inspector') return
|
|
80
|
+
const tick = () => setCacheEntries(rootRef.current.__debug.queryEntries())
|
|
81
|
+
tick()
|
|
82
|
+
const id = window.setInterval(tick, inspectorPollMs)
|
|
83
|
+
return () => window.clearInterval(id)
|
|
84
|
+
}, [tab, inspectorPollMs])
|
|
85
|
+
|
|
86
|
+
const liveTree = use(store.tree$)
|
|
87
|
+
const liveCache = use(store.cache$)
|
|
88
|
+
const liveMutations = use(store.mutations$)
|
|
89
|
+
const liveFields = use(store.fields$)
|
|
90
|
+
|
|
91
|
+
// When paused, snapshot once and keep showing that frozen state.
|
|
92
|
+
const [frozen, setFrozen] = useState<{
|
|
93
|
+
tree: ControllerNode
|
|
94
|
+
cache: CacheEntry[]
|
|
95
|
+
mutations: MutationEntry[]
|
|
96
|
+
fields: FieldEntry[]
|
|
97
|
+
} | null>(null)
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (paused) {
|
|
100
|
+
setFrozen({
|
|
101
|
+
tree: liveTree,
|
|
102
|
+
cache: liveCache,
|
|
103
|
+
mutations: liveMutations,
|
|
104
|
+
fields: liveFields,
|
|
105
|
+
})
|
|
106
|
+
} else {
|
|
107
|
+
setFrozen(null)
|
|
108
|
+
}
|
|
109
|
+
// We only re-snapshot when the toggle flips, not on every event.
|
|
110
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
+
}, [paused])
|
|
112
|
+
|
|
113
|
+
const tree = frozen?.tree ?? liveTree
|
|
114
|
+
const cache = frozen?.cache ?? liveCache
|
|
115
|
+
const mutations = frozen?.mutations ?? liveMutations
|
|
116
|
+
const fields = frozen?.fields ?? liveFields
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="olas-devtools" data-testid="olas-devtools">
|
|
120
|
+
<style>{DEVTOOLS_CSS}</style>
|
|
121
|
+
<div className="olas-devtools-tabs" role="tablist">
|
|
122
|
+
<Tab
|
|
123
|
+
name="tree"
|
|
124
|
+
current={tab}
|
|
125
|
+
setTab={setTab}
|
|
126
|
+
label="Tree"
|
|
127
|
+
short="Tree"
|
|
128
|
+
count={countLiveControllers(liveTree)}
|
|
129
|
+
/>
|
|
130
|
+
<Tab
|
|
131
|
+
name="cache"
|
|
132
|
+
current={tab}
|
|
133
|
+
setTab={setTab}
|
|
134
|
+
label="Cache"
|
|
135
|
+
short="Cache"
|
|
136
|
+
count={liveCache.length}
|
|
137
|
+
/>
|
|
138
|
+
<Tab
|
|
139
|
+
name="inspector"
|
|
140
|
+
current={tab}
|
|
141
|
+
setTab={setTab}
|
|
142
|
+
label="Inspector"
|
|
143
|
+
short="Insp"
|
|
144
|
+
count={cacheEntries.length}
|
|
145
|
+
/>
|
|
146
|
+
<Tab
|
|
147
|
+
name="mutations"
|
|
148
|
+
current={tab}
|
|
149
|
+
setTab={setTab}
|
|
150
|
+
label="Mutations"
|
|
151
|
+
short="Mut"
|
|
152
|
+
count={liveMutations.length}
|
|
153
|
+
/>
|
|
154
|
+
<Tab
|
|
155
|
+
name="fields"
|
|
156
|
+
current={tab}
|
|
157
|
+
setTab={setTab}
|
|
158
|
+
label="Fields"
|
|
159
|
+
short="Fld"
|
|
160
|
+
count={liveFields.length}
|
|
161
|
+
/>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
aria-pressed={paused}
|
|
165
|
+
className={paused ? 'olas-devtools-pause olas-devtools-pause-on' : 'olas-devtools-pause'}
|
|
166
|
+
onClick={() => setPaused(!paused)}
|
|
167
|
+
title={paused ? 'Resume live updates' : 'Pause live updates'}
|
|
168
|
+
>
|
|
169
|
+
<span aria-hidden="true">{paused ? '▶' : '⏸'}</span>
|
|
170
|
+
<span className="olas-devtools-pause-text">{paused ? ' Resume' : ' Pause'}</span>
|
|
171
|
+
</button>
|
|
172
|
+
<button
|
|
173
|
+
className="olas-devtools-clear"
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={() => store.clearLogs()}
|
|
176
|
+
title="Clear logs"
|
|
177
|
+
>
|
|
178
|
+
<span className="olas-devtools-clear-text">Clear</span>
|
|
179
|
+
<span className="olas-devtools-clear-icon" aria-hidden="true">
|
|
180
|
+
✕
|
|
181
|
+
</span>
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{(tab === 'cache' || tab === 'inspector' || tab === 'mutations' || tab === 'fields') && (
|
|
186
|
+
<div className="olas-devtools-filter">
|
|
187
|
+
<input
|
|
188
|
+
type="search"
|
|
189
|
+
value={filter}
|
|
190
|
+
placeholder={`Filter ${tab}…`}
|
|
191
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
192
|
+
/>
|
|
193
|
+
{filter !== '' && (
|
|
194
|
+
<button type="button" onClick={() => setFilter('')} aria-label="Clear filter">
|
|
195
|
+
✕
|
|
196
|
+
</button>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
<div className="olas-devtools-body" role="tabpanel">
|
|
202
|
+
{tab === 'tree' && <TreeView tree={tree} mutations={liveMutations} />}
|
|
203
|
+
{tab === 'cache' && <CacheView entries={cache} filter={filter} />}
|
|
204
|
+
{tab === 'inspector' && <InspectorView entries={cacheEntries} filter={filter} />}
|
|
205
|
+
{tab === 'mutations' && <MutationsView entries={mutations} filter={filter} />}
|
|
206
|
+
{tab === 'fields' && <FieldsView entries={fields} filter={filter} />}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function Tab(props: {
|
|
213
|
+
name: DevtoolsTab
|
|
214
|
+
current: DevtoolsTab
|
|
215
|
+
setTab: (t: DevtoolsTab) => void
|
|
216
|
+
label: string
|
|
217
|
+
short: string
|
|
218
|
+
count: number
|
|
219
|
+
}): ReactElement {
|
|
220
|
+
const selected = props.current === props.name
|
|
221
|
+
return (
|
|
222
|
+
<button
|
|
223
|
+
role="tab"
|
|
224
|
+
type="button"
|
|
225
|
+
aria-selected={selected}
|
|
226
|
+
title={props.label}
|
|
227
|
+
className="olas-devtools-tab"
|
|
228
|
+
onClick={() => props.setTab(props.name)}
|
|
229
|
+
>
|
|
230
|
+
<span className="olas-devtools-tab-label-full">{props.label}</span>
|
|
231
|
+
<span className="olas-devtools-tab-label-short" aria-hidden="true">
|
|
232
|
+
{props.short}
|
|
233
|
+
</span>
|
|
234
|
+
{props.count > 0 && (
|
|
235
|
+
<span className="olas-devtools-tab-count" aria-hidden="true">
|
|
236
|
+
{props.count}
|
|
237
|
+
</span>
|
|
238
|
+
)}
|
|
239
|
+
</button>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function countLiveControllers(node: ControllerNode): number {
|
|
244
|
+
let total = node.state !== 'disposed' ? 1 : 0
|
|
245
|
+
for (const c of node.children) total += countLiveControllers(c)
|
|
246
|
+
return Math.max(total - 1, 0) // exclude the placeholder root wrapper
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ===========================================================================
|
|
250
|
+
// Tree
|
|
251
|
+
// ===========================================================================
|
|
252
|
+
|
|
253
|
+
function TreeView({
|
|
254
|
+
tree,
|
|
255
|
+
mutations,
|
|
256
|
+
}: {
|
|
257
|
+
tree: ControllerNode
|
|
258
|
+
mutations: MutationEntry[]
|
|
259
|
+
}): ReactElement {
|
|
260
|
+
if (tree.children.length === 0) {
|
|
261
|
+
return <Empty title="No controllers yet" hint="The root hasn't constructed any controllers." />
|
|
262
|
+
}
|
|
263
|
+
// Roll up pending-mutation counts per controller path. A "pending" mutation
|
|
264
|
+
// is one whose last entry is `run` with no matching success/error for the
|
|
265
|
+
// same (path, name).
|
|
266
|
+
const pending = useMemo(() => rollupPending(mutations), [mutations])
|
|
267
|
+
return (
|
|
268
|
+
<div className="olas-devtools-tree">
|
|
269
|
+
{tree.children.map((child) => (
|
|
270
|
+
<TreeNode key={child.path.join('/')} node={child} pending={pending} />
|
|
271
|
+
))}
|
|
272
|
+
</div>
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function rollupPending(entries: readonly MutationEntry[]): Map<string, number> {
|
|
277
|
+
const inFlight = new Map<string, number>() // (path|name) → count
|
|
278
|
+
const out = new Map<string, number>() // path → pending count
|
|
279
|
+
for (const e of entries) {
|
|
280
|
+
const key = `${e.path.join('>')}#${e.name ?? ''}`
|
|
281
|
+
const pathKey = e.path.join('>')
|
|
282
|
+
if (e.kind === 'run') {
|
|
283
|
+
inFlight.set(key, (inFlight.get(key) ?? 0) + 1)
|
|
284
|
+
out.set(pathKey, (out.get(pathKey) ?? 0) + 1)
|
|
285
|
+
} else if (e.kind === 'success' || e.kind === 'error') {
|
|
286
|
+
const n = inFlight.get(key) ?? 0
|
|
287
|
+
if (n > 0) inFlight.set(key, n - 1)
|
|
288
|
+
const p = out.get(pathKey) ?? 0
|
|
289
|
+
if (p > 0) out.set(pathKey, p - 1)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return out
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function TreeNode({
|
|
296
|
+
node,
|
|
297
|
+
pending,
|
|
298
|
+
}: {
|
|
299
|
+
node: ControllerNode
|
|
300
|
+
pending: Map<string, number>
|
|
301
|
+
}): ReactElement {
|
|
302
|
+
const name = node.path[node.path.length - 1] ?? '?'
|
|
303
|
+
const stateClass =
|
|
304
|
+
node.state === 'suspended'
|
|
305
|
+
? 'olas-devtools-tree-state-suspended'
|
|
306
|
+
: node.state === 'disposed'
|
|
307
|
+
? 'olas-devtools-tree-state-disposed'
|
|
308
|
+
: 'olas-devtools-tree-state-active'
|
|
309
|
+
const pendingCount = pending.get(node.path.join('>')) ?? 0
|
|
310
|
+
const propsPreview = useMemo(() => summarizeProps(node.props), [node.props])
|
|
311
|
+
const [propsOpen, setPropsOpen] = useState(false)
|
|
312
|
+
const canExpandProps = node.props !== undefined && node.props !== null
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<div className="olas-devtools-tree-node">
|
|
316
|
+
<span className="olas-devtools-tree-row">
|
|
317
|
+
<span className="olas-devtools-tree-name">{name}</span>
|
|
318
|
+
<span className={stateClass}>{node.state}</span>
|
|
319
|
+
{pendingCount > 0 && (
|
|
320
|
+
<span className="olas-devtools-tree-pending" title="pending mutations on this controller">
|
|
321
|
+
{pendingCount} pending
|
|
322
|
+
</span>
|
|
323
|
+
)}
|
|
324
|
+
{canExpandProps && (
|
|
325
|
+
<button
|
|
326
|
+
type="button"
|
|
327
|
+
className="olas-devtools-tree-props-toggle"
|
|
328
|
+
aria-expanded={propsOpen}
|
|
329
|
+
onClick={() => setPropsOpen((v) => !v)}
|
|
330
|
+
title={propsOpen ? 'Hide props' : 'Show full props'}
|
|
331
|
+
>
|
|
332
|
+
{propsPreview}
|
|
333
|
+
</button>
|
|
334
|
+
)}
|
|
335
|
+
</span>
|
|
336
|
+
{propsOpen && canExpandProps && (
|
|
337
|
+
<div className="olas-devtools-tree-props">
|
|
338
|
+
<JsonView value={node.props} />
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
{node.children.length > 0 && (
|
|
342
|
+
<div className="olas-devtools-tree-children">
|
|
343
|
+
{node.children.map((child) => (
|
|
344
|
+
<TreeNode key={child.path.join('/')} node={child} pending={pending} />
|
|
345
|
+
))}
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Build a one-line props summary for the tree row. */
|
|
353
|
+
function summarizeProps(props: unknown): string {
|
|
354
|
+
if (props === null || props === undefined) return ''
|
|
355
|
+
if (typeof props === 'string') return `"${truncate(props, 24)}"`
|
|
356
|
+
if (typeof props === 'number' || typeof props === 'boolean') return String(props)
|
|
357
|
+
if (Array.isArray(props)) return `[${props.length}]`
|
|
358
|
+
if (typeof props === 'object') {
|
|
359
|
+
const keys = Object.keys(props as Record<string, unknown>)
|
|
360
|
+
if (keys.length === 0) return '{}'
|
|
361
|
+
const parts = keys.slice(0, 2).map((k) => {
|
|
362
|
+
const v = (props as Record<string, unknown>)[k]
|
|
363
|
+
return `${k}: ${shortValue(v)}`
|
|
364
|
+
})
|
|
365
|
+
return `{ ${parts.join(', ')}${keys.length > 2 ? `, +${keys.length - 2}` : ''} }`
|
|
366
|
+
}
|
|
367
|
+
return String(props)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function shortValue(v: unknown): string {
|
|
371
|
+
if (v === null) return 'null'
|
|
372
|
+
if (v === undefined) return 'undefined'
|
|
373
|
+
if (typeof v === 'string') return `"${truncate(v, 16)}"`
|
|
374
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
375
|
+
if (Array.isArray(v)) return `[${v.length}]`
|
|
376
|
+
if (typeof v === 'object') return `{${Object.keys(v as object).length}}`
|
|
377
|
+
return String(v)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function truncate(s: string, max: number): string {
|
|
381
|
+
return s.length <= max ? s : `${s.slice(0, max - 1)}…`
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ===========================================================================
|
|
385
|
+
// Cache Inspector — live state, not history
|
|
386
|
+
// ===========================================================================
|
|
387
|
+
|
|
388
|
+
function InspectorView({
|
|
389
|
+
entries,
|
|
390
|
+
filter,
|
|
391
|
+
}: {
|
|
392
|
+
entries: DebugCacheEntry[]
|
|
393
|
+
filter: string
|
|
394
|
+
}): ReactElement {
|
|
395
|
+
const filtered = useFiltered(entries, filter, inspectorHaystack)
|
|
396
|
+
if (entries.length === 0) {
|
|
397
|
+
return (
|
|
398
|
+
<Empty
|
|
399
|
+
title="No cache entries"
|
|
400
|
+
hint="Subscribe to a query somewhere in the tree to see its data."
|
|
401
|
+
/>
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
if (filtered.length === 0) {
|
|
405
|
+
return <Empty title="No matches" hint={`Nothing matches “${filter}”.`} />
|
|
406
|
+
}
|
|
407
|
+
return (
|
|
408
|
+
<ul className="olas-devtools-list">
|
|
409
|
+
{filtered.map((entry) => (
|
|
410
|
+
<InspectorRow key={entry.key.join('|')} entry={entry} />
|
|
411
|
+
))}
|
|
412
|
+
</ul>
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function inspectorHaystack(e: DebugCacheEntry): string {
|
|
417
|
+
return [...e.key.map(String), e.status, safeStringify(e.data)].join(' ')
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function InspectorRow({ entry }: { entry: DebugCacheEntry }): ReactElement {
|
|
421
|
+
const kindClass =
|
|
422
|
+
entry.status === 'error'
|
|
423
|
+
? 'olas-devtools-kind-error'
|
|
424
|
+
: entry.status === 'success'
|
|
425
|
+
? 'olas-devtools-kind-success'
|
|
426
|
+
: entry.status === 'pending'
|
|
427
|
+
? 'olas-devtools-kind-warn'
|
|
428
|
+
: ''
|
|
429
|
+
const ageMs = entry.lastUpdatedAt != null ? Date.now() - entry.lastUpdatedAt : null
|
|
430
|
+
const tags: string[] = []
|
|
431
|
+
if (entry.isStale) tags.push('stale')
|
|
432
|
+
if (entry.isFetching) tags.push('fetching')
|
|
433
|
+
if (entry.hasPendingMutations) tags.push('optimistic')
|
|
434
|
+
return (
|
|
435
|
+
<Row
|
|
436
|
+
kind={entry.status}
|
|
437
|
+
kindClass={kindClass}
|
|
438
|
+
target={formatPath(entry.key)}
|
|
439
|
+
t={entry.lastUpdatedAt ?? Date.now()}
|
|
440
|
+
payload={entry.error ?? entry.data}
|
|
441
|
+
suffix={[ageMs != null ? `${formatAge(ageMs)} ago` : '—', ...tags].join(' · ')}
|
|
442
|
+
/>
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function safeStringify(v: unknown): string {
|
|
447
|
+
try {
|
|
448
|
+
return JSON.stringify(v) ?? ''
|
|
449
|
+
} catch {
|
|
450
|
+
return String(v)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function formatAge(ms: number): string {
|
|
455
|
+
if (ms < 1000) return `${ms}ms`
|
|
456
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
|
457
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`
|
|
458
|
+
return `${Math.round(ms / 3_600_000)}h`
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ===========================================================================
|
|
462
|
+
// URL-hash persistence
|
|
463
|
+
// ===========================================================================
|
|
464
|
+
|
|
465
|
+
function readUrlHash(
|
|
466
|
+
key: string | undefined,
|
|
467
|
+
defaultTab: DevtoolsTab,
|
|
468
|
+
): { tab: DevtoolsTab; filters: Record<DevtoolsTab, string> } {
|
|
469
|
+
const empty = { tree: '', cache: '', inspector: '', mutations: '', fields: '' }
|
|
470
|
+
if (key === undefined) return { tab: defaultTab, filters: empty }
|
|
471
|
+
if (typeof window === 'undefined') return { tab: defaultTab, filters: empty }
|
|
472
|
+
try {
|
|
473
|
+
const params = new URLSearchParams(window.location.hash.replace(/^#/, ''))
|
|
474
|
+
const raw = params.get(key)
|
|
475
|
+
if (raw === null) return { tab: defaultTab, filters: empty }
|
|
476
|
+
const parsed = JSON.parse(decodeURIComponent(raw)) as {
|
|
477
|
+
tab?: DevtoolsTab
|
|
478
|
+
filters?: Partial<Record<DevtoolsTab, string>>
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
tab: parsed.tab ?? defaultTab,
|
|
482
|
+
filters: { ...empty, ...(parsed.filters ?? {}) },
|
|
483
|
+
}
|
|
484
|
+
} catch {
|
|
485
|
+
return { tab: defaultTab, filters: empty }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function writeUrlHash(
|
|
490
|
+
key: string,
|
|
491
|
+
state: { tab: DevtoolsTab; filters: Record<DevtoolsTab, string> },
|
|
492
|
+
): void {
|
|
493
|
+
if (typeof window === 'undefined') return
|
|
494
|
+
const params = new URLSearchParams(window.location.hash.replace(/^#/, ''))
|
|
495
|
+
params.set(key, encodeURIComponent(JSON.stringify(state)))
|
|
496
|
+
const next = `#${params.toString()}`
|
|
497
|
+
if (next !== window.location.hash) {
|
|
498
|
+
window.history.replaceState(null, '', next)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ===========================================================================
|
|
503
|
+
// Cache
|
|
504
|
+
// ===========================================================================
|
|
505
|
+
|
|
506
|
+
function CacheView({ entries, filter }: { entries: CacheEntry[]; filter: string }): ReactElement {
|
|
507
|
+
const filtered = useFiltered(entries, filter, cacheHaystack)
|
|
508
|
+
if (entries.length === 0) {
|
|
509
|
+
return (
|
|
510
|
+
<Empty title="No cache events yet" hint="Trigger a query subscription to see fetches here." />
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
if (filtered.length === 0) {
|
|
514
|
+
return <Empty title="No matches" hint={`Nothing matches “${filter}”.`} />
|
|
515
|
+
}
|
|
516
|
+
return (
|
|
517
|
+
<ul className="olas-devtools-list">
|
|
518
|
+
{[...filtered].reverse().map((entry) => (
|
|
519
|
+
<CacheRow key={entry.id} entry={entry} />
|
|
520
|
+
))}
|
|
521
|
+
</ul>
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function cacheHaystack(e: CacheEntry): string {
|
|
526
|
+
const parts: string[] = [e.kind, ...e.queryKey.map((p) => String(p))]
|
|
527
|
+
if (e.kind === 'fetch-error') parts.push(safeStringify(e.error))
|
|
528
|
+
if (e.kind === 'subscribed') parts.push(...e.subscriberPath)
|
|
529
|
+
return parts.join(' ')
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function CacheRow({ entry }: { entry: CacheEntry }): ReactElement {
|
|
533
|
+
const kindClass =
|
|
534
|
+
entry.kind === 'fetch-error'
|
|
535
|
+
? 'olas-devtools-kind-error'
|
|
536
|
+
: entry.kind === 'fetch-success'
|
|
537
|
+
? 'olas-devtools-kind-success'
|
|
538
|
+
: entry.kind === 'invalidated' || entry.kind === 'gc'
|
|
539
|
+
? 'olas-devtools-kind-warn'
|
|
540
|
+
: ''
|
|
541
|
+
|
|
542
|
+
let inline: string | null = null
|
|
543
|
+
let payload: unknown | undefined
|
|
544
|
+
let suffix: string | null = null
|
|
545
|
+
if (entry.kind === 'fetch-success') {
|
|
546
|
+
suffix = `${entry.durationMs}ms`
|
|
547
|
+
} else if (entry.kind === 'fetch-error') {
|
|
548
|
+
suffix = `${entry.durationMs}ms`
|
|
549
|
+
payload = entry.error
|
|
550
|
+
} else if (entry.kind === 'subscribed') {
|
|
551
|
+
inline = `from ${formatPath(entry.subscriberPath)}`
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return (
|
|
555
|
+
<Row
|
|
556
|
+
kind={entry.kind}
|
|
557
|
+
kindClass={kindClass}
|
|
558
|
+
target={formatPath(entry.queryKey)}
|
|
559
|
+
t={entry.t}
|
|
560
|
+
inline={inline}
|
|
561
|
+
payload={payload}
|
|
562
|
+
suffix={suffix}
|
|
563
|
+
/>
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ===========================================================================
|
|
568
|
+
// Mutations
|
|
569
|
+
// ===========================================================================
|
|
570
|
+
|
|
571
|
+
function MutationsView({
|
|
572
|
+
entries,
|
|
573
|
+
filter,
|
|
574
|
+
}: {
|
|
575
|
+
entries: MutationEntry[]
|
|
576
|
+
filter: string
|
|
577
|
+
}): ReactElement {
|
|
578
|
+
const filtered = useFiltered(entries, filter, mutationHaystack)
|
|
579
|
+
if (entries.length === 0) {
|
|
580
|
+
return <Empty title="No mutations yet" hint="Trigger a mutation to see the lifecycle here." />
|
|
581
|
+
}
|
|
582
|
+
if (filtered.length === 0) {
|
|
583
|
+
return <Empty title="No matches" hint={`Nothing matches “${filter}”.`} />
|
|
584
|
+
}
|
|
585
|
+
return (
|
|
586
|
+
<ul className="olas-devtools-list">
|
|
587
|
+
{[...filtered].reverse().map((entry) => (
|
|
588
|
+
<MutationRow key={entry.id} entry={entry} />
|
|
589
|
+
))}
|
|
590
|
+
</ul>
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function mutationHaystack(e: MutationEntry): string {
|
|
595
|
+
const parts: string[] = [e.kind, ...e.path, e.name ?? '']
|
|
596
|
+
if (e.kind === 'run') parts.push(safeStringify(e.vars))
|
|
597
|
+
if (e.kind === 'success') parts.push(safeStringify(e.result))
|
|
598
|
+
if (e.kind === 'error') parts.push(safeStringify(e.error))
|
|
599
|
+
return parts.join(' ')
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function MutationRow({ entry }: { entry: MutationEntry }): ReactElement {
|
|
603
|
+
const kindClass =
|
|
604
|
+
entry.kind === 'error'
|
|
605
|
+
? 'olas-devtools-kind-error'
|
|
606
|
+
: entry.kind === 'rollback'
|
|
607
|
+
? 'olas-devtools-kind-rollback'
|
|
608
|
+
: entry.kind === 'success'
|
|
609
|
+
? 'olas-devtools-kind-success'
|
|
610
|
+
: ''
|
|
611
|
+
|
|
612
|
+
const target = entry.name ? `${entry.name} · ${formatPath(entry.path)}` : formatPath(entry.path)
|
|
613
|
+
|
|
614
|
+
let payload: unknown | undefined
|
|
615
|
+
let suffix: string | null = null
|
|
616
|
+
if (entry.kind === 'run') payload = entry.vars
|
|
617
|
+
else if (entry.kind === 'success') {
|
|
618
|
+
payload = entry.result
|
|
619
|
+
if (entry.durationMs !== undefined) suffix = `${entry.durationMs}ms`
|
|
620
|
+
} else if (entry.kind === 'error') {
|
|
621
|
+
payload = entry.error
|
|
622
|
+
if (entry.durationMs !== undefined) suffix = `${entry.durationMs}ms`
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return (
|
|
626
|
+
<Row
|
|
627
|
+
kind={entry.kind}
|
|
628
|
+
kindClass={kindClass}
|
|
629
|
+
target={target}
|
|
630
|
+
t={entry.t}
|
|
631
|
+
payload={payload}
|
|
632
|
+
suffix={suffix}
|
|
633
|
+
/>
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ===========================================================================
|
|
638
|
+
// Fields
|
|
639
|
+
// ===========================================================================
|
|
640
|
+
|
|
641
|
+
function FieldsView({ entries, filter }: { entries: FieldEntry[]; filter: string }): ReactElement {
|
|
642
|
+
const filtered = useFiltered(entries, filter, fieldHaystack)
|
|
643
|
+
if (entries.length === 0) {
|
|
644
|
+
return (
|
|
645
|
+
<Empty
|
|
646
|
+
title="No field validations yet"
|
|
647
|
+
hint="Type into a form bound via ctx.form(...) or ctx.field(...) — each pass lands here."
|
|
648
|
+
/>
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
if (filtered.length === 0) {
|
|
652
|
+
return <Empty title="No matches" hint={`Nothing matches “${filter}”.`} />
|
|
653
|
+
}
|
|
654
|
+
return (
|
|
655
|
+
<ul className="olas-devtools-list">
|
|
656
|
+
{[...filtered].reverse().map((entry) => (
|
|
657
|
+
<FieldRow key={entry.id} entry={entry} />
|
|
658
|
+
))}
|
|
659
|
+
</ul>
|
|
660
|
+
)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function fieldHaystack(e: FieldEntry): string {
|
|
664
|
+
return [e.field, ...e.path, e.valid ? 'valid' : 'invalid', ...e.errors].join(' ')
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function FieldRow({ entry }: { entry: FieldEntry }): ReactElement {
|
|
668
|
+
const kindClass = entry.valid ? 'olas-devtools-kind-success' : 'olas-devtools-kind-error'
|
|
669
|
+
return (
|
|
670
|
+
<Row
|
|
671
|
+
kind={entry.valid ? 'valid' : 'invalid'}
|
|
672
|
+
kindClass={kindClass}
|
|
673
|
+
target={`${formatPath(entry.path)} · ${entry.field}`}
|
|
674
|
+
t={entry.t}
|
|
675
|
+
inline={entry.errors.length > 0 ? entry.errors.join(' · ') : null}
|
|
676
|
+
/>
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ===========================================================================
|
|
681
|
+
// Shared row + helpers
|
|
682
|
+
// ===========================================================================
|
|
683
|
+
|
|
684
|
+
type RowProps = {
|
|
685
|
+
kind: string
|
|
686
|
+
kindClass: string
|
|
687
|
+
target: string
|
|
688
|
+
t: number
|
|
689
|
+
/** Either a tiny inline string (durations, urls) OR a structured payload. */
|
|
690
|
+
inline?: string | null
|
|
691
|
+
payload?: unknown
|
|
692
|
+
suffix?: string | null
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function Row(props: RowProps): ReactElement {
|
|
696
|
+
const { kind, kindClass, target, t, inline, payload, suffix } = props
|
|
697
|
+
const hasPayload = payload !== undefined
|
|
698
|
+
const [expanded, setExpanded] = useState(false)
|
|
699
|
+
const togglable = hasPayload
|
|
700
|
+
|
|
701
|
+
return (
|
|
702
|
+
<li className={togglable ? 'olas-devtools-row-clickable' : ''}>
|
|
703
|
+
<div
|
|
704
|
+
className="olas-devtools-row-top"
|
|
705
|
+
onClick={togglable ? () => setExpanded((v) => !v) : undefined}
|
|
706
|
+
>
|
|
707
|
+
<span className={`olas-devtools-kind ${kindClass}`}>{kind}</span>
|
|
708
|
+
<span className="olas-devtools-target">{target}</span>
|
|
709
|
+
{suffix !== undefined && suffix !== null && (
|
|
710
|
+
<span className="olas-devtools-duration">{suffix}</span>
|
|
711
|
+
)}
|
|
712
|
+
<span className="olas-devtools-time">{formatTime(t)}</span>
|
|
713
|
+
{togglable && (
|
|
714
|
+
<span
|
|
715
|
+
aria-hidden="true"
|
|
716
|
+
className={`olas-devtools-chevron ${expanded ? 'olas-devtools-chevron-open' : ''}`}
|
|
717
|
+
>
|
|
718
|
+
›
|
|
719
|
+
</span>
|
|
720
|
+
)}
|
|
721
|
+
</div>
|
|
722
|
+
{inline != null && (
|
|
723
|
+
<div className="olas-devtools-payload olas-devtools-payload-inline">{inline}</div>
|
|
724
|
+
)}
|
|
725
|
+
{hasPayload && expanded && (
|
|
726
|
+
<div className="olas-devtools-payload olas-devtools-payload-json">
|
|
727
|
+
<JsonView value={payload} />
|
|
728
|
+
</div>
|
|
729
|
+
)}
|
|
730
|
+
</li>
|
|
731
|
+
)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function useFiltered<T>(items: readonly T[], filter: string, haystack: (item: T) => string): T[] {
|
|
735
|
+
return useMemo(() => {
|
|
736
|
+
if (filter.trim() === '') return [...items]
|
|
737
|
+
const q = filter.toLowerCase()
|
|
738
|
+
return items.filter((item) => haystack(item).toLowerCase().includes(q))
|
|
739
|
+
}, [items, filter, haystack])
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function Empty({ title, hint }: { title: string; hint: string }): ReactElement {
|
|
743
|
+
return (
|
|
744
|
+
<div className="olas-devtools-empty">
|
|
745
|
+
<div className="olas-devtools-empty-title">{title}</div>
|
|
746
|
+
<div className="olas-devtools-empty-hint">{hint}</div>
|
|
747
|
+
</div>
|
|
748
|
+
)
|
|
749
|
+
}
|