@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.
@@ -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
+ }