@agentcairn/mcp-server 0.0.0-stage → 0.1.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/README.md +23 -2
- package/dist/authn.d.ts +38 -0
- package/dist/authn.js +86 -0
- package/dist/authn.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +25 -0
- package/dist/bin.js.map +1 -0
- package/dist/context.d.ts +49 -0
- package/dist/context.js +79 -0
- package/dist/context.js.map +1 -0
- package/dist/dashboard.d.ts +29 -0
- package/dist/dashboard.js +365 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/events.d.ts +120 -0
- package/dist/events.js +161 -0
- package/dist/events.js.map +1 -0
- package/dist/http.d.ts +35 -0
- package/dist/http.js +348 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/security.d.ts +49 -0
- package/dist/security.js +143 -0
- package/dist/security.js.map +1 -0
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +60 -0
- package/dist/serve.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +14 -0
- package/dist/server.js.map +1 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +199 -0
- package/dist/tools.js.map +1 -0
- package/dist/util.d.ts +13 -0
- package/dist/util.js +49 -0
- package/dist/util.js.map +1 -0
- package/package.json +55 -4
- package/ui/css/app.css +361 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +17 -0
- package/ui/js/api.js +37 -0
- package/ui/js/app.js +190 -0
- package/ui/js/charts.js +133 -0
- package/ui/js/dom.js +247 -0
- package/ui/js/drawer.js +39 -0
- package/ui/js/json.js +37 -0
- package/ui/js/store.js +77 -0
- package/ui/js/views/access.js +129 -0
- package/ui/js/views/auth.js +102 -0
- package/ui/js/views/eventDrawer.js +106 -0
- package/ui/js/views/graph.js +344 -0
- package/ui/js/views/live.js +203 -0
- package/ui/js/views/memoryDrawer.js +37 -0
- package/ui/js/views/project.js +94 -0
- package/ui/js/views/projects.js +77 -0
- package/ui/js/views/query.js +107 -0
- 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()
|
package/ui/js/charts.js
ADDED
|
@@ -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
|
+
}
|
package/ui/js/drawer.js
ADDED
|
@@ -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
|
+
}
|