@agentcairn/mcp-server 0.0.0-stage → 0.1.1

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.
Files changed (58) hide show
  1. package/README.md +23 -2
  2. package/dist/authn.d.ts +38 -0
  3. package/dist/authn.js +86 -0
  4. package/dist/authn.js.map +1 -0
  5. package/dist/bin.d.ts +2 -0
  6. package/dist/bin.js +25 -0
  7. package/dist/bin.js.map +1 -0
  8. package/dist/context.d.ts +49 -0
  9. package/dist/context.js +79 -0
  10. package/dist/context.js.map +1 -0
  11. package/dist/dashboard.d.ts +29 -0
  12. package/dist/dashboard.js +365 -0
  13. package/dist/dashboard.js.map +1 -0
  14. package/dist/events.d.ts +120 -0
  15. package/dist/events.js +161 -0
  16. package/dist/events.js.map +1 -0
  17. package/dist/http.d.ts +35 -0
  18. package/dist/http.js +348 -0
  19. package/dist/http.js.map +1 -0
  20. package/dist/index.d.ts +7 -0
  21. package/dist/index.js +8 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/security.d.ts +49 -0
  24. package/dist/security.js +143 -0
  25. package/dist/security.js.map +1 -0
  26. package/dist/serve.d.ts +2 -0
  27. package/dist/serve.js +60 -0
  28. package/dist/serve.js.map +1 -0
  29. package/dist/server.d.ts +10 -0
  30. package/dist/server.js +14 -0
  31. package/dist/server.js.map +1 -0
  32. package/dist/tools.d.ts +8 -0
  33. package/dist/tools.js +199 -0
  34. package/dist/tools.js.map +1 -0
  35. package/dist/util.d.ts +13 -0
  36. package/dist/util.js +49 -0
  37. package/dist/util.js.map +1 -0
  38. package/package.json +55 -4
  39. package/ui/css/app.css +361 -0
  40. package/ui/favicon.svg +1 -0
  41. package/ui/index.html +17 -0
  42. package/ui/js/api.js +37 -0
  43. package/ui/js/app.js +190 -0
  44. package/ui/js/charts.js +133 -0
  45. package/ui/js/dom.js +247 -0
  46. package/ui/js/drawer.js +39 -0
  47. package/ui/js/json.js +37 -0
  48. package/ui/js/store.js +77 -0
  49. package/ui/js/views/access.js +129 -0
  50. package/ui/js/views/auth.js +102 -0
  51. package/ui/js/views/eventDrawer.js +106 -0
  52. package/ui/js/views/graph.js +344 -0
  53. package/ui/js/views/live.js +203 -0
  54. package/ui/js/views/memoryDrawer.js +37 -0
  55. package/ui/js/views/project.js +94 -0
  56. package/ui/js/views/projects.js +77 -0
  57. package/ui/js/views/query.js +107 -0
  58. package/ui/js/views/system.js +73 -0
package/ui/js/app.js ADDED
@@ -0,0 +1,190 @@
1
+ import { api, onUnauthorized } from './api.js'
2
+ import { clear, h, icon, initial, startClock, svg } from './dom.js'
3
+ import { closeDrawer } from './drawer.js'
4
+ import { live } from './store.js'
5
+ import { mountLogin, mountSetup } from './views/auth.js'
6
+
7
+ const root = document.getElementById('root')
8
+
9
+ // ---- theme -----------------------------------------------------------------
10
+
11
+ const THEME_KEY = 'aib-theme'
12
+ const savedTheme = () => {
13
+ try {
14
+ return localStorage.getItem(THEME_KEY)
15
+ } catch {
16
+ return null
17
+ }
18
+ }
19
+ const applyTheme = theme => {
20
+ if (theme) document.documentElement.dataset.theme = theme
21
+ else delete document.documentElement.dataset.theme
22
+ }
23
+ applyTheme(savedTheme())
24
+ const isDark = () =>
25
+ document.documentElement.dataset.theme
26
+ ? document.documentElement.dataset.theme === 'dark'
27
+ : matchMedia('(prefers-color-scheme: dark)').matches
28
+
29
+ // ---- routes ----------------------------------------------------------------
30
+
31
+ const ROUTES = [
32
+ { pattern: /^\/live$/, nav: 'live', load: () => import('./views/live.js') },
33
+ { pattern: /^\/projects$/, nav: 'projects', load: () => import('./views/projects.js') },
34
+ { pattern: /^\/projects\/([^/]+)$/, nav: 'projects', params: ['id'], load: () => import('./views/project.js') },
35
+ { pattern: /^\/graph$/, nav: 'graph', load: () => import('./views/graph.js') },
36
+ { pattern: /^\/query$/, nav: 'query', load: () => import('./views/query.js') },
37
+ { pattern: /^\/access$/, nav: 'access', load: () => import('./views/access.js') },
38
+ { pattern: /^\/system$/, nav: 'system', load: () => import('./views/system.js') },
39
+ ]
40
+
41
+ const NAV = [
42
+ ['Monitor', [['live', 'Live', 'live']]],
43
+ ['Explore', [['projects', 'Projects', 'folder'], ['graph', 'Graph', 'graph'], ['query', 'Query', 'terminal']]],
44
+ ['Manage', [['access', 'Access', 'key'], ['system', 'System', 'server']]],
45
+ ]
46
+ const TITLES = Object.fromEntries(NAV.flatMap(([, items]) => items.map(([key, label]) => [key, label])))
47
+
48
+ export function navigate(hash) {
49
+ location.hash = hash
50
+ }
51
+
52
+ function parseHash() {
53
+ const raw = location.hash.replace(/^#/, '') || '/live'
54
+ const [path, search = ''] = raw.split('?')
55
+ return { path, query: Object.fromEntries(new URLSearchParams(search)) }
56
+ }
57
+
58
+ function logo() {
59
+ return svg(
60
+ 'svg',
61
+ { viewBox: '0 0 32 32', 'aria-hidden': 'true' },
62
+ svg('ellipse', { cx: 16, cy: 23.5, rx: 9, ry: 3.8, fill: 'var(--text)' }),
63
+ svg('ellipse', { cx: 16, cy: 16.2, rx: 6.4, ry: 3.2, fill: 'var(--text)' }),
64
+ svg('ellipse', { class: 'pulse', cx: 16, cy: 9.6, rx: 3.8, ry: 2.8, fill: 'var(--s1)' }),
65
+ )
66
+ }
67
+
68
+ // ---- app shell -------------------------------------------------------------
69
+
70
+ let cleanup = null
71
+ let shell = null
72
+
73
+ function mountShell(username, version) {
74
+ clear(root)
75
+ const brand = h('div', { class: 'brand' }, logo(), 'Agent Cairn', version && h('small', null, `v${version}`))
76
+ const links = NAV.flatMap(([group, items]) => [
77
+ h('div', { class: 'label' }, group),
78
+ ...items.map(([key, label, ic]) => {
79
+ const count = h('span', { class: 'count', dataset: { count: key } })
80
+ return h('a', { class: 'item', href: `#/${key}`, dataset: { nav: key }, title: label }, icon(ic), h('span', { class: 't' }, label), count)
81
+ }),
82
+ ])
83
+ const dot = h('span', { class: 'dot' })
84
+ const connText = h('span', { class: 'conn-text' }, 'connecting')
85
+ const themeBtn = h('button', { class: 'btn sm ghost', type: 'button', title: 'Toggle light and dark', 'aria-label': 'Toggle theme' })
86
+ const syncThemeBtn = () => themeBtn.replaceChildren(icon(isDark() ? 'sun' : 'moon'))
87
+ themeBtn.addEventListener('click', () => {
88
+ const next = isDark() ? 'light' : 'dark'
89
+ applyTheme(next)
90
+ try {
91
+ localStorage.setItem(THEME_KEY, next)
92
+ } catch {
93
+ /* private mode */
94
+ }
95
+ syncThemeBtn()
96
+ })
97
+ syncThemeBtn()
98
+
99
+ const logout = h('button', { class: 'btn sm ghost', type: 'button', title: 'Sign out', 'aria-label': 'Sign out' }, icon('logout'))
100
+ logout.addEventListener('click', async () => {
101
+ await api('logout', { body: {} }).catch(() => undefined)
102
+ boot()
103
+ })
104
+
105
+ const outlet = h('main', { class: 'main', id: 'outlet' })
106
+ const nav = h(
107
+ 'nav',
108
+ { class: 'nav', 'aria-label': 'Main' },
109
+ brand,
110
+ ...links,
111
+ h('div', { class: 'spacer' }),
112
+ h('div', { class: 'foot' }, h('div', { class: 'conn' }, dot, connText), h('div', { class: 'row tight' }, h('div', { class: 'me grow' }, h('span', { class: 'avatar' }, initial(username)), h('span', { class: 'name dim' }, username)), themeBtn, logout)),
113
+ )
114
+ root.append(h('div', { class: 'app' }, nav, outlet))
115
+
116
+ const sync = kind => {
117
+ if (kind === 'status') {
118
+ dot.className = `dot ${live.status === 'live' ? 'live' : live.status === 'down' ? 'down' : ''}`
119
+ connText.textContent = live.status === 'live' ? 'live stream' : live.status === 'down' ? 'disconnected' : 'connecting'
120
+ }
121
+ if (kind === 'stats' && live.stats) {
122
+ const active = live.stats.clients.filter(c => c.active).length
123
+ const el = nav.querySelector('[data-count="live"]')
124
+ if (el) el.textContent = active ? `${active} online` : ''
125
+ }
126
+ if (kind === 'event') {
127
+ brand.classList.remove('beat')
128
+ void brand.offsetWidth
129
+ brand.classList.add('beat')
130
+ }
131
+ }
132
+ const unsubscribe = live.subscribe(sync)
133
+ sync('status')
134
+ shell = { outlet, nav, unsubscribe }
135
+ }
136
+
137
+ async function route() {
138
+ if (!shell) return
139
+ cleanup?.()
140
+ cleanup = null
141
+ closeDrawer()
142
+ const { path, query } = parseHash()
143
+ const match = ROUTES.map(r => [r, r.pattern.exec(path)]).find(([, m]) => m)
144
+ const [def, m] = match ?? [ROUTES[0], null]
145
+ const params = Object.fromEntries((def.params ?? []).map((name, i) => [name, decodeURIComponent(m?.[i + 1] ?? '')]))
146
+
147
+ document.title = `${TITLES[def.nav] ?? 'Dashboard'} · Agent Cairn`
148
+ for (const a of shell.nav.querySelectorAll('a.item')) {
149
+ if (a.dataset.nav === def.nav) a.setAttribute('aria-current', 'page')
150
+ else a.removeAttribute('aria-current')
151
+ }
152
+ clear(shell.outlet)
153
+ window.scrollTo(0, 0)
154
+ try {
155
+ const view = await def.load()
156
+ const result = await view.mount(shell.outlet, { params, query, navigate })
157
+ if (typeof result === 'function') cleanup = result
158
+ } catch (error) {
159
+ console.error(error)
160
+ shell.outlet.append(h('div', { class: 'card' }, h('h2', null, 'Something went wrong'), h('p', { class: 'error' }, String(error?.message ?? error))))
161
+ }
162
+ }
163
+
164
+ window.addEventListener('hashchange', route)
165
+
166
+ // ---- boot ------------------------------------------------------------------
167
+
168
+ async function boot() {
169
+ cleanup?.()
170
+ cleanup = null
171
+ shell?.unsubscribe()
172
+ shell = null
173
+ live.stop()
174
+ let state
175
+ try {
176
+ state = await api('state')
177
+ } catch (error) {
178
+ clear(root).append(h('div', { class: 'auth-page' }, h('div', { class: 'card panel' }, h('h1', null, 'Cannot reach the server'), h('p', { class: 'lead' }, error.message), h('button', { class: 'btn primary', onclick: boot }, 'Retry'))))
179
+ return
180
+ }
181
+ if (state.setupRequired) return mountSetup(root, boot)
182
+ if (!state.authenticated) return mountLogin(root, boot)
183
+ mountShell(state.username, state.version)
184
+ live.start()
185
+ route()
186
+ }
187
+
188
+ onUnauthorized(() => boot())
189
+ startClock()
190
+ boot()
@@ -0,0 +1,133 @@
1
+ import { fmt, h, svg } from './dom.js'
2
+
3
+ // ---- tooltip (one shared element) -----------------------------------------
4
+
5
+ let tip
6
+ export function showTip(event, title, rows) {
7
+ tip ??= document.body.appendChild(h('div', { class: 'tooltip', role: 'tooltip' }))
8
+ tip.replaceChildren(
9
+ h('b', null, title),
10
+ ...rows.map(([label, value]) => h('div', { class: 'r' }, h('span', null, label), h('strong', null, value))),
11
+ )
12
+ tip.style.display = 'block'
13
+ const pad = 14
14
+ const w = tip.offsetWidth
15
+ const x = event.clientX + pad + w > window.innerWidth ? event.clientX - w - pad : event.clientX + pad
16
+ tip.style.left = `${Math.max(4, x)}px`
17
+ tip.style.top = `${Math.min(window.innerHeight - tip.offsetHeight - 4, event.clientY + pad)}px`
18
+ }
19
+ export function hideTip() {
20
+ if (tip) tip.style.display = 'none'
21
+ }
22
+
23
+ function niceMax(v) {
24
+ if (v <= 4) return 4
25
+ const pow = 10 ** Math.floor(Math.log10(v))
26
+ for (const m of [1, 2, 5, 10]) if (m * pow >= v) return m * pow
27
+ return v
28
+ }
29
+
30
+ /** Column whose data end (top) is rounded and whose baseline end is square. */
31
+ function columnPath(x, y, w, hgt, r) {
32
+ const rr = Math.min(r, w / 2, hgt)
33
+ return `M${x},${y + hgt}V${y + rr}Q${x},${y} ${x + rr},${y}H${x + w - rr}Q${x + w},${y} ${x + w},${y + rr}V${y + hgt}Z`
34
+ }
35
+
36
+ // ---- requests per minute ---------------------------------------------------
37
+
38
+ /**
39
+ * Stacked columns, one per minute for the last hour: successful requests in the
40
+ * accent, failures stacked above in the status color. Hover any column for the numbers.
41
+ */
42
+ export function createThroughput() {
43
+ const holder = h('div', { class: 'chart' })
44
+ let data = []
45
+ const M = { l: 36, r: 6, t: 8, b: 22 }
46
+ const H = 168
47
+
48
+ function draw() {
49
+ const W = Math.max(320, holder.clientWidth || 640)
50
+ const iw = W - M.l - M.r
51
+ const ih = H - M.t - M.b
52
+ const max = niceMax(Math.max(1, ...data.map(d => d.requests)))
53
+ const step = iw / Math.max(1, data.length)
54
+ const barW = Math.max(2, Math.min(24, step - 2))
55
+ const y = v => M.t + ih - (v / max) * ih
56
+
57
+ const root = svg('svg', { viewBox: `0 0 ${W} ${H}`, height: H, role: 'img', 'aria-label': 'Requests per minute, last hour' })
58
+ for (const f of [0, 0.5, 1]) {
59
+ const yy = y(max * f)
60
+ root.append(svg('line', { class: f === 0 ? 'axis' : 'grid', x1: M.l, x2: W - M.r, y1: yy, y2: yy }))
61
+ root.append(svg('text', { x: M.l - 6, y: yy + 4, 'text-anchor': 'end' }, fmt.num(max * f)))
62
+ }
63
+ data.forEach((d, i) => {
64
+ const x = M.l + i * step + (step - barW) / 2
65
+ const ok = d.requests - d.errors
66
+ const okH = (ok / max) * ih
67
+ const errH = (d.errors / max) * ih
68
+ if (ok > 0) root.append(svg('path', { d: columnPath(x, y(ok), barW, Math.max(okH, 2), d.errors ? 0.01 : 3), fill: 'var(--s1)' }))
69
+ if (d.errors > 0) root.append(svg('path', { d: columnPath(x, y(ok) - errH - (ok ? 2 : 0), barW, Math.max(errH, 2), 3), fill: 'var(--crit)' }))
70
+ const hit = svg('rect', { class: 'hit', x: M.l + i * step, y: M.t, width: step, height: ih, tabindex: '-1' })
71
+ hit.addEventListener('pointermove', e =>
72
+ showTip(e, fmt.clock(d.t), [
73
+ ['Requests', fmt.int(d.requests)],
74
+ ['Errors', fmt.int(d.errors)],
75
+ ['Tokens out', fmt.int(d.tokensOut)],
76
+ ]),
77
+ )
78
+ hit.addEventListener('pointerleave', hideTip)
79
+ root.append(hit)
80
+ })
81
+ for (const i of [0, 15, 30, 45]) {
82
+ root.append(svg('text', { x: M.l + i * step + step / 2, y: H - 6, 'text-anchor': 'middle' }, i === 0 ? '60m ago' : `${60 - i}m`))
83
+ }
84
+ root.append(svg('text', { x: W - M.r - step / 2, y: H - 6, 'text-anchor': 'end' }, 'now'))
85
+ holder.replaceChildren(root)
86
+ }
87
+
88
+ new ResizeObserver(() => data.length && draw()).observe(holder)
89
+ return {
90
+ el: holder,
91
+ update(perMinute) {
92
+ data = perMinute
93
+ draw()
94
+ },
95
+ }
96
+ }
97
+
98
+ /** The same data as a table, for anyone who prefers numbers (and for assistive tech). */
99
+ export function throughputTable(perMinute) {
100
+ const rows = perMinute.filter(d => d.requests > 0).reverse()
101
+ if (!rows.length) return h('div', { class: 'empty' }, 'No requests in the last hour.')
102
+ return h(
103
+ 'div',
104
+ { class: 'scroll', style: { maxHeight: '170px' } },
105
+ h(
106
+ 'table',
107
+ { class: 'tbl' },
108
+ h('thead', null, h('tr', null, ['Minute', 'Requests', 'Errors', 'Tokens out'].map((t, i) => h('th', { class: i ? 'num' : '' }, t)))),
109
+ h(
110
+ 'tbody',
111
+ null,
112
+ rows.map(d =>
113
+ h('tr', null, h('td', { class: 'mono' }, fmt.clock(d.t)), h('td', { class: 'num' }, fmt.int(d.requests)), h('td', { class: 'num' }, fmt.int(d.errors)), h('td', { class: 'num' }, fmt.int(d.tokensOut))),
114
+ ),
115
+ ),
116
+ ),
117
+ )
118
+ }
119
+
120
+ // ---- sparkline -------------------------------------------------------------
121
+
122
+ export function sparkline(values, { w = 92, h: height = 30 } = {}) {
123
+ const max = Math.max(1, ...values)
124
+ const step = values.length > 1 ? (w - 8) / (values.length - 1) : 0
125
+ const pts = values.map((v, i) => [4 + i * step, height - 5 - (v / max) * (height - 10)])
126
+ const root = svg('svg', { viewBox: `0 0 ${w} ${height}`, width: w, height, 'aria-hidden': 'true', class: 'spark' })
127
+ if (pts.length > 1) {
128
+ root.append(svg('polyline', { points: pts.map(p => p.join(',')).join(' '), fill: 'none', stroke: 'var(--s1)', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }))
129
+ }
130
+ const last = pts.at(-1)
131
+ if (last) root.append(svg('circle', { cx: last[0], cy: last[1], r: 4, fill: 'var(--s1)', stroke: 'var(--surface)', 'stroke-width': 2 }))
132
+ return root
133
+ }
package/ui/js/dom.js ADDED
@@ -0,0 +1,247 @@
1
+ // Tiny DOM helpers: no framework, no innerHTML for data (only for the static icon set below).
2
+
3
+ const SVG_NS = 'http://www.w3.org/2000/svg'
4
+
5
+ // Views write `cond && element` freely; append/prepend/replaceChildren would turn a false into the text "false".
6
+ for (const name of ['append', 'prepend', 'replaceChildren']) {
7
+ const original = Element.prototype[name]
8
+ Element.prototype[name] = function (...nodes) {
9
+ return original.apply(this, nodes.flat(Infinity).filter(n => n !== false && n !== null && n !== undefined))
10
+ }
11
+ }
12
+
13
+ function apply(el, props) {
14
+ for (const [key, value] of Object.entries(props ?? {})) {
15
+ if (value === undefined || value === null || value === false) continue
16
+ if (key === 'class') el.setAttribute('class', Array.isArray(value) ? value.filter(Boolean).join(' ') : value)
17
+ else if (key === 'style' && typeof value === 'object') Object.assign(el.style, value)
18
+ else if (key === 'dataset') Object.assign(el.dataset, value)
19
+ else if (key.startsWith('on') && typeof value === 'function') el.addEventListener(key.slice(2).toLowerCase(), value)
20
+ else if (key === 'value' || key === 'checked' || key === 'selected' || key === 'disabled') el[key] = value
21
+ else el.setAttribute(key, value === true ? '' : String(value))
22
+ }
23
+ }
24
+
25
+ function append(el, children) {
26
+ for (const child of children.flat(Infinity)) {
27
+ if (child === null || child === undefined || child === false) continue
28
+ el.append(child instanceof Node ? child : document.createTextNode(String(child)))
29
+ }
30
+ }
31
+
32
+ export function h(tag, props, ...children) {
33
+ const el = document.createElement(tag)
34
+ apply(el, props)
35
+ append(el, children)
36
+ return el
37
+ }
38
+
39
+ export function svg(tag, props, ...children) {
40
+ const el = document.createElementNS(SVG_NS, tag)
41
+ apply(el, props)
42
+ append(el, children)
43
+ return el
44
+ }
45
+
46
+ export function clear(el) {
47
+ el.replaceChildren()
48
+ return el
49
+ }
50
+
51
+ const ICONS = {
52
+ live: '<path d="M3 12h4l3-8 4 16 3-8h4"/>',
53
+ folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
54
+ graph: '<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="8" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M8 7l8 1M7 8l4 8M17 10l-4 6"/>',
55
+ terminal: '<path d="M4 5h16v14H4z"/><path d="M8 10l3 2-3 2M13 15h4"/>',
56
+ key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
57
+ server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
58
+ copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h8"/>',
59
+ check: '<path d="M5 12l5 5 9-10"/>',
60
+ x: '<path d="M6 6l12 12M18 6L6 18"/>',
61
+ pause: '<path d="M8 5v14M16 5v14"/>',
62
+ play: '<path d="M7 5l12 7-12 7z"/>',
63
+ search: '<circle cx="11" cy="11" r="6"/><path d="M20 20l-4-4"/>',
64
+ refresh: '<path d="M4 12a8 8 0 0 1 14-5l2-2v6h-6l2.2-2.2A5.5 5.5 0 0 0 6.5 12M20 12a8 8 0 0 1-14 5l-2 2v-6h6l-2.2 2.2A5.5 5.5 0 0 0 17.5 12"/>',
65
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4"/>',
66
+ moon: '<path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z"/>',
67
+ alert: '<path d="M12 4l9 16H3z"/><path d="M12 10v4M12 17h.01"/>',
68
+ lock: '<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
69
+ logout: '<path d="M10 4H5v16h5M15 8l4 4-4 4M19 12H9"/>',
70
+ plus: '<path d="M12 5v14M5 12h14"/>',
71
+ trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
72
+ link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
73
+ zap: '<path d="M13 3L5 14h6l-1 7 8-11h-6z"/>',
74
+ inbox: '<path d="M4 13l3-8h10l3 8v6H4z"/><path d="M4 13h5l1 2h4l1-2h5"/>',
75
+ zoomin: '<circle cx="11" cy="11" r="6"/><path d="M20 20l-4-4M11 8v6M8 11h6"/>',
76
+ zoomout: '<circle cx="11" cy="11" r="6"/><path d="M20 20l-4-4M8 11h6"/>',
77
+ fit: '<path d="M4 9V5h4M20 9V5h-4M4 15v4h4M20 15v4h-4"/>',
78
+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
79
+ arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
80
+ database: '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
81
+ }
82
+
83
+ export function icon(name) {
84
+ const el = document.createElementNS(SVG_NS, 'svg')
85
+ el.setAttribute('viewBox', '0 0 24 24')
86
+ el.setAttribute('width', '16')
87
+ el.setAttribute('height', '16')
88
+ el.setAttribute('fill', 'none')
89
+ el.setAttribute('stroke', 'currentColor')
90
+ el.setAttribute('stroke-width', '2')
91
+ el.setAttribute('stroke-linecap', 'round')
92
+ el.setAttribute('stroke-linejoin', 'round')
93
+ el.setAttribute('aria-hidden', 'true')
94
+ el.innerHTML = ICONS[name] ?? '' // static strings above, never data
95
+ return el
96
+ }
97
+
98
+ // ---- formatting -----------------------------------------------------------
99
+
100
+ export const fmt = {
101
+ num(n) {
102
+ if (n === null || n === undefined) return '–'
103
+ const a = Math.abs(n)
104
+ if (a >= 1e6) return `${(n / 1e6).toFixed(a >= 1e7 ? 0 : 1)}M`
105
+ if (a >= 1e4) return `${(n / 1e3).toFixed(0)}K`
106
+ if (a >= 1e3) return `${(n / 1e3).toFixed(1)}K`
107
+ return String(Math.round(n))
108
+ },
109
+ int(n) {
110
+ return n === null || n === undefined ? '–' : Math.round(n).toLocaleString('en-US')
111
+ },
112
+ bytes(n) {
113
+ if (!n) return '0 B'
114
+ const units = ['B', 'KB', 'MB', 'GB']
115
+ const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)))
116
+ return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${units[i]}`
117
+ },
118
+ ms(n) {
119
+ if (n === null || n === undefined) return '–'
120
+ return n >= 1000 ? `${(n / 1000).toFixed(2)} s` : `${Math.round(n)} ms`
121
+ },
122
+ ago(iso) {
123
+ if (!iso) return 'never'
124
+ const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
125
+ if (s < 5) return 'just now'
126
+ if (s < 60) return `${s}s ago`
127
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`
128
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`
129
+ return `${Math.floor(s / 86400)}d ago`
130
+ },
131
+ clock(iso) {
132
+ return new Date(iso).toLocaleTimeString([], { hour12: false })
133
+ },
134
+ dateTime(iso) {
135
+ return iso ? new Date(iso).toLocaleString([], { hour12: false }) : '–'
136
+ },
137
+ uptime(seconds) {
138
+ const d = Math.floor(seconds / 86400)
139
+ const hh = Math.floor((seconds % 86400) / 3600)
140
+ const mm = Math.floor((seconds % 3600) / 60)
141
+ return d ? `${d}d ${hh}h` : hh ? `${hh}h ${mm}m` : `${mm}m ${seconds % 60}s`
142
+ },
143
+ }
144
+
145
+ /** A <time> that keeps counting ("12s ago") without re-rendering its view. */
146
+ export function ago(iso) {
147
+ return h('time', { dataset: { ts: iso ?? '' }, title: iso ? fmt.dateTime(iso) : '' }, fmt.ago(iso))
148
+ }
149
+
150
+ export function startClock() {
151
+ setInterval(() => {
152
+ for (const el of document.querySelectorAll('time[data-ts]')) {
153
+ if (el.dataset.ts) el.textContent = fmt.ago(el.dataset.ts)
154
+ }
155
+ }, 1000)
156
+ }
157
+
158
+ // ---- small UI helpers ------------------------------------------------------
159
+
160
+ export function toast(message) {
161
+ const el = h('div', { class: 'toast', role: 'status' }, icon('check'), message)
162
+ document.getElementById('toasts')?.append(el)
163
+ setTimeout(() => el.remove(), 2400)
164
+ }
165
+
166
+ export async function copyText(text, label = 'Copied') {
167
+ try {
168
+ await navigator.clipboard.writeText(text)
169
+ } catch {
170
+ const area = h('textarea', { style: { position: 'fixed', opacity: '0' } })
171
+ area.value = text
172
+ document.body.append(area)
173
+ area.select()
174
+ document.execCommand('copy')
175
+ area.remove()
176
+ }
177
+ toast(label)
178
+ }
179
+
180
+ /** A code block with a copy button. */
181
+ export function codeBox(text) {
182
+ return h(
183
+ 'div',
184
+ { class: 'code-box' },
185
+ h('pre', { class: 'pre' }, text),
186
+ h('button', { class: 'btn sm copy', type: 'button', onclick: () => copyText(text) }, icon('copy'), 'Copy'),
187
+ )
188
+ }
189
+
190
+ /** A friendly "nothing here" block: an icon, a headline, a line of help and an optional action. */
191
+ export function emptyState(iconName, title, text, action) {
192
+ return h('div', { class: 'empty' }, h('div', { class: 'glyph' }, icon(iconName)), h('b', null, title), text && h('div', null, text), action)
193
+ }
194
+
195
+ /** A search field with a magnifier inside it. */
196
+ export function searchBox(props) {
197
+ const input = h('input', { class: 'input', type: 'search', ...props })
198
+ return { el: h('div', { class: 'search' }, icon('search'), input), input }
199
+ }
200
+
201
+ export const initial = name => (name?.trim()?.[0] ?? '?').toUpperCase()
202
+
203
+ export function debounce(fn, ms) {
204
+ let timer
205
+ return (...args) => {
206
+ clearTimeout(timer)
207
+ timer = setTimeout(() => fn(...args), ms)
208
+ }
209
+ }
210
+
211
+ export const KINDS = ['overview', 'decision', 'convention', 'gotcha', 'checkpoint', 'thread', 'glossary']
212
+
213
+ export function kindLabel(kind) {
214
+ return h('span', { class: `kind k-${kind}` }, kind)
215
+ }
216
+
217
+ /** One-line horizontal stacked bar of memory counts by kind. */
218
+ export function kindBar(counts) {
219
+ const total = Object.values(counts).reduce((a, b) => a + b, 0)
220
+ if (!total) return h('div', { class: 'stackbar' })
221
+ return h(
222
+ 'div',
223
+ { class: 'stackbar', role: 'img', 'aria-label': KINDS.filter(k => counts[k]).map(k => `${counts[k]} ${k}`).join(', ') },
224
+ KINDS.filter(k => counts[k]).map(k =>
225
+ h('span', { class: `k-${k}`, style: { flex: String(counts[k]) }, title: `${counts[k]} ${k}` }),
226
+ ),
227
+ )
228
+ }
229
+
230
+ export function kindLegend(kinds = KINDS) {
231
+ return h(
232
+ 'div',
233
+ { class: 'legend' },
234
+ kinds.map(k => h('span', null, h('i', { class: `sw k-${k}`, style: { background: 'var(--k)' } }), k)),
235
+ )
236
+ }
237
+
238
+ /** A compact status glyph for dense tables: shape and title carry the meaning, not just colour. */
239
+ export function statusMark(event) {
240
+ const [cls, name, label] = event.source === 'auth' ? ['auth', 'lock', 'denied'] : event.ok ? ['ok', 'check', 'ok'] : ['err', 'x', 'error']
241
+ return h('span', { class: `st ${cls}`, title: `${label} (HTTP ${event.httpStatus})`, role: 'img', 'aria-label': label }, icon(name))
242
+ }
243
+
244
+ export function statusBadge(event) {
245
+ if (event.source === 'auth') return h('span', { class: 'badge auth' }, icon('lock'), 'denied')
246
+ return event.ok ? h('span', { class: 'badge ok' }, icon('check'), String(event.httpStatus)) : h('span', { class: 'badge err' }, icon('x'), String(event.httpStatus))
247
+ }
@@ -0,0 +1,39 @@
1
+ import { h, icon } from './dom.js'
2
+
3
+ let current = null
4
+
5
+ /** Closes whatever drawer is open (the router calls this on every page change). */
6
+ export function closeDrawer() {
7
+ current?.close()
8
+ }
9
+
10
+ /** A right-hand panel over a dimmed page. Escape or a click outside closes it. */
11
+ export function openDrawer(title, body) {
12
+ current?.close()
13
+ const previous = document.activeElement
14
+ const backdrop = h('div', { class: 'backdrop' })
15
+ const closeBtn = h('button', { class: 'btn sm ghost', type: 'button', 'aria-label': 'Close' }, icon('x'))
16
+ const drawer = h(
17
+ 'aside',
18
+ { class: 'drawer', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Details' },
19
+ h('header', null, h('div', { class: 'row grow', style: { minWidth: 0 } }, title), closeBtn),
20
+ h('div', { class: 'body' }, body),
21
+ )
22
+ const onKey = e => {
23
+ if (e.key === 'Escape') close()
24
+ }
25
+ function close() {
26
+ document.removeEventListener('keydown', onKey)
27
+ backdrop.remove()
28
+ drawer.remove()
29
+ if (current?.close === close) current = null
30
+ previous?.focus?.()
31
+ }
32
+ backdrop.addEventListener('click', close)
33
+ closeBtn.addEventListener('click', close)
34
+ document.addEventListener('keydown', onKey)
35
+ document.body.append(backdrop, drawer)
36
+ closeBtn.focus()
37
+ current = { close }
38
+ return { close, body: drawer.querySelector('.body') }
39
+ }
package/ui/js/json.js ADDED
@@ -0,0 +1,37 @@
1
+ import { h } from './dom.js'
2
+
3
+ const LONG = 240
4
+
5
+ function leaf(value) {
6
+ if (value === null) return h('span', { class: 'z' }, 'null')
7
+ if (typeof value === 'number') return h('span', { class: 'n' }, String(value))
8
+ if (typeof value === 'boolean') return h('span', { class: 'b' }, String(value))
9
+ if (typeof value === 'string') {
10
+ if (value.length <= LONG) return h('span', { class: 's' }, JSON.stringify(value))
11
+ const holder = h('span', { class: 's' }, `${JSON.stringify(value.slice(0, LONG))}… `)
12
+ holder.append(
13
+ h('span', { class: 'more', role: 'button', tabindex: '0', onclick: () => holder.replaceChildren(JSON.stringify(value)) }, `(+${value.length - LONG} more characters)`),
14
+ )
15
+ return holder
16
+ }
17
+ return h('span', null, String(value))
18
+ }
19
+
20
+ function node(key, value, depth) {
21
+ const label = key === null ? [] : [h('span', { class: 'k' }, JSON.stringify(key)), ': ']
22
+ if (value === null || typeof value !== 'object') return h('div', null, label, leaf(value))
23
+
24
+ const entries = Array.isArray(value) ? value.map((v, i) => [i, v]) : Object.entries(value)
25
+ const [open, close] = Array.isArray(value) ? ['[', ']'] : ['{', '}']
26
+ if (entries.length === 0) return h('div', null, label, h('span', { class: 'z' }, `${open}${close}`))
27
+
28
+ const summary = h('summary', null, label, open, h('span', { class: 'z' }, ` ${entries.length} ${Array.isArray(value) ? 'items' : 'keys'} `), '…', close)
29
+ const body = h('div', { class: 'ind' }, entries.map(([k, v]) => node(Array.isArray(value) ? null : k, v, depth + 1)))
30
+ return h('div', null, h('details', { open: depth < 2 }, summary, body, h('div', null, close)))
31
+ }
32
+
33
+ /** A collapsible, copyable view of any JSON value. */
34
+ export function jsonView(value) {
35
+ if (value === undefined) return h('div', { class: 'muted' }, 'Not captured.')
36
+ return h('div', { class: 'json' }, node(null, value, 0))
37
+ }