@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.
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
@@ -0,0 +1,94 @@
1
+ import { api, qs } from '../api.js'
2
+ import { ago, debounce, emptyState, fmt, h, icon, KINDS, kindBar, searchBox } from '../dom.js'
3
+ import { importance, openMemory } from './memoryDrawer.js'
4
+
5
+ export async function mount(container, { params, navigate }) {
6
+ const id = params.id
7
+ const detail = await api(`projects/${encodeURIComponent(id)}`)
8
+ const state = { kind: '', area: '', status: 'active', q: '' }
9
+
10
+ const list = h('div', { class: 'stack', style: { gap: '10px' } })
11
+ const count = h('span', { class: 'hint' })
12
+ const search = searchBox({ placeholder: 'Search titles and bodies…', 'aria-label': 'Search memories', style: { width: '260px' } })
13
+ const areaSelect = h('select', { 'aria-label': 'Area' }, h('option', { value: '' }, 'All areas'), detail.areas.map(a => h('option', { value: a.name }, `${a.name} · ${a.count}`)))
14
+ const kindChips = KINDS.map(k => {
15
+ const chip = h('button', { class: `chip kindchip k-${k}`, type: 'button', 'aria-pressed': 'false', disabled: !detail.counts[k] }, h('i', { class: 'sw' }), `${k} ${detail.counts[k] ?? 0}`)
16
+ chip.addEventListener('click', () => {
17
+ state.kind = state.kind === k ? '' : k
18
+ kindChips.forEach(c => c.setAttribute('aria-pressed', String(c === chip && state.kind === k)))
19
+ load()
20
+ })
21
+ return chip
22
+ })
23
+ const statusSeg = h(
24
+ 'div',
25
+ { class: 'seg', role: 'group', 'aria-label': 'Status' },
26
+ ...['active', 'superseded', 'all'].map(s => {
27
+ const b = h('button', { type: 'button', 'aria-pressed': String(s === state.status) }, s)
28
+ b.addEventListener('click', () => {
29
+ state.status = s
30
+ ;[...statusSeg.children].forEach(c => c.setAttribute('aria-pressed', String(c === b)))
31
+ load()
32
+ })
33
+ return b
34
+ }),
35
+ )
36
+
37
+ function memoryRow(m) {
38
+ return h(
39
+ 'div',
40
+ { class: ['mem', `k-${m.kind}`, m.status === 'superseded' && 'dead'], tabindex: '0', role: 'button', onclick: () => openMemory(id, m.id, navigate), onkeydown: e => e.key === 'Enter' && openMemory(id, m.id, navigate) },
41
+ h('div', { class: 'stripe' }),
42
+ h(
43
+ 'div',
44
+ { class: 'inner' },
45
+ h('div', { class: 'row tight' }, h('span', { class: `kind k-${m.kind}` }, m.kind), m.area && h('span', { class: 'tag' }, m.area), m.status === 'superseded' && h('span', { class: 'badge auth' }, 'superseded'), h('span', { class: 'grow' }), importance(m.importance), h('span', { class: 'muted mono', style: { fontSize: '11.5px' } }, `#${m.id.slice(0, 8)}`)),
46
+ h('div', { class: 'title' }, m.title),
47
+ h('div', { class: 'snippet' }, m.body),
48
+ h('div', { class: 'meta' }, ago(m.createdAt), m.agent && h('span', null, `· ${m.agent}`), m.author && h('span', null, `· ${m.author}`), m.paths.length > 0 && h('span', null, `· ${m.paths.length} path${m.paths.length > 1 ? 's' : ''}`), m.commitSha && h('span', { class: 'mono' }, `· ${m.commitSha.slice(0, 7)}`)),
49
+ ),
50
+ )
51
+ }
52
+
53
+ async function load() {
54
+ list.replaceChildren(...Array.from({ length: 3 }, () => h('div', { class: 'skeleton', style: { height: '86px', borderRadius: '12px' } })))
55
+ try {
56
+ const { memories } = await api(`memories?${qs({ project: id, kind: state.kind, area: state.area, status: state.status, q: state.q, limit: 100 })}`)
57
+ count.textContent = `${memories.length}${memories.length === 100 ? '+' : ''} shown${state.q ? ' · ranked by relevance' : ''}`
58
+ if (!memories.length) return list.replaceChildren(h('div', { class: 'card' }, emptyState('search', 'No memories match', 'Try another filter or clear the search.')))
59
+ list.replaceChildren(...memories.map(memoryRow))
60
+ } catch (error) {
61
+ list.replaceChildren(h('p', { class: 'error' }, error.message))
62
+ }
63
+ }
64
+
65
+ areaSelect.addEventListener('change', () => { state.area = areaSelect.value; load() })
66
+ search.input.addEventListener('input', debounce(() => { state.q = search.input.value.trim(); load() }, 250))
67
+
68
+ container.append(
69
+ h(
70
+ 'div',
71
+ { class: 'page-head' },
72
+ h('div', null, h('div', { class: 'crumbs' }, h('a', { href: '#/projects' }, 'Projects'), ' /'), h('h1', { class: 'mono', style: { fontSize: '22px' } }, id)),
73
+ h('div', { class: 'row' }, h('button', { class: 'btn', type: 'button', onclick: () => navigate(`#/graph?project=${encodeURIComponent(id)}`) }, icon('graph'), 'Open in graph'), h('button', { class: 'btn', type: 'button', onclick: () => navigate(`#/access?project=${encodeURIComponent(id)}`) }, icon('link'), 'Connect an agent')),
74
+ ),
75
+ h(
76
+ 'div',
77
+ { class: 'card', style: { marginBottom: '14px' } },
78
+ h(
79
+ 'div',
80
+ { class: 'row', style: { gap: '30px', marginBottom: '14px' } },
81
+ h('div', { class: 'stat' }, h('b', null, fmt.int(detail.active)), h('span', null, 'active memories')),
82
+ h('div', { class: 'stat' }, h('b', null, fmt.int(detail.superseded)), h('span', null, 'superseded')),
83
+ h('div', { class: 'stat' }, h('b', null, fmt.int(detail.areas.length)), h('span', null, detail.areas.length === 1 ? 'area' : 'areas')),
84
+ h('div', { class: 'stat' }, h('b', null, fmt.int(detail.remotes)), h('span', null, detail.remotes === 1 ? 'git remote' : 'git remotes')),
85
+ h('div', { class: 'stat' }, h('b', null, fmt.ago(detail.createdAt).replace(' ago', '')), h('span', null, 'old')),
86
+ ),
87
+ kindBar(detail.counts),
88
+ ),
89
+ h('div', { class: 'toolbar' }, search.el, areaSelect, statusSeg, h('span', { class: 'grow' }), count),
90
+ h('div', { class: 'row tight', style: { marginBottom: '14px' } }, ...kindChips),
91
+ list,
92
+ )
93
+ await load()
94
+ }
@@ -0,0 +1,77 @@
1
+ import { api } from '../api.js'
2
+ import { ago, emptyState, fmt, h, icon, KINDS, kindBar, kindLegend, searchBox, toast } from '../dom.js'
3
+ import { live } from '../store.js'
4
+
5
+ export async function mount(container, { navigate }) {
6
+ const { projects } = await api('projects')
7
+ const search = searchBox({ placeholder: 'Find a project…', 'aria-label': 'Find a project', style: { width: '240px' } })
8
+ const grid = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' } })
9
+
10
+ const activeNow = id => {
11
+ const p = live.stats?.projects.find(x => x.project === id)
12
+ return p && Date.now() - Date.parse(p.lastSeen) < 5 * 60_000 ? p : null
13
+ }
14
+
15
+ function card(p) {
16
+ const now = activeNow(p.id)
17
+ const top = KINDS.filter(k => p.counts[k]).sort((a, b) => p.counts[b] - p.counts[a])
18
+ return h(
19
+ 'a',
20
+ { class: 'card lift', href: `#/projects/${encodeURIComponent(p.id)}`, style: { color: 'inherit', textDecoration: 'none', display: 'flex', flexDirection: 'column', gap: '14px' } },
21
+ h('div', { class: 'row' }, h('h2', { class: 'mono grow', style: { overflowWrap: 'anywhere', fontSize: '15px' } }, p.id), now && h('span', { class: 'badge ok', title: `${now.requests} requests this session` }, h('span', { class: 'dot live' }), 'active')),
22
+ h('div', { class: 'row', style: { alignItems: 'baseline', gap: '8px' } }, h('span', { style: { fontSize: '32px', fontWeight: 680, letterSpacing: '-0.025em' } }, fmt.int(p.active)), h('span', { class: 'muted' }, p.active === 1 ? 'memory' : 'memories')),
23
+ kindBar(p.counts),
24
+ h('div', { class: 'legend' }, top.slice(0, 4).map(k => h('span', null, h('i', { class: `sw k-${k}`, style: { background: 'var(--k)' } }), `${p.counts[k]} ${k}`)), top.length > 4 && h('span', { class: 'muted' }, `+${top.length - 4} more`)),
25
+ h(
26
+ 'div',
27
+ { class: 'row muted', style: { fontSize: '12px', marginTop: 'auto', paddingTop: '10px', borderTop: '1px solid var(--border)' } },
28
+ h('span', null, p.lastMemoryAt ? ['updated ', ago(p.lastMemoryAt)] : 'no memories yet'),
29
+ h('span', { class: 'grow' }),
30
+ p.superseded > 0 && h('span', null, `${p.superseded} superseded`),
31
+ h('span', null, `${p.remotes} remote${p.remotes === 1 ? '' : 's'}`),
32
+ icon('arrow'),
33
+ ),
34
+ )
35
+ }
36
+
37
+ function draw() {
38
+ const q = search.input.value.trim().toLowerCase()
39
+ const shown = projects.filter(p => p.id.toLowerCase().includes(q))
40
+ if (!shown.length) {
41
+ return grid.replaceChildren(h('div', { class: 'card', style: { gridColumn: '1 / -1' } }, projects.length ? emptyState('search', 'No project matches') : emptyState('folder', 'No projects yet', 'Create one here, or run `cairn init` in a repository.')))
42
+ }
43
+ grid.replaceChildren(...shown.map(card))
44
+ }
45
+ search.input.addEventListener('input', draw)
46
+
47
+ const idInput = h('input', { class: 'input mono', id: 'new-project', placeholder: 'project-id', maxlength: '64', 'aria-label': 'New project id' })
48
+ const createBtn = h('button', { class: 'btn primary', type: 'submit' }, 'Create')
49
+ const form = h('form', { class: 'toolbar', style: { display: 'none' } }, h('label', { class: 'dim', for: 'new-project' }, 'New project id'), idInput, createBtn, h('span', { class: 'hint' }, 'Letters, digits, ".", "_" or "-"; up to 64 characters.'))
50
+ const newBtn = h('button', { class: 'btn', type: 'button' }, icon('plus'), 'New project')
51
+ newBtn.addEventListener('click', () => {
52
+ form.style.display = form.style.display === 'none' ? '' : 'none'
53
+ if (form.style.display === '') idInput.focus()
54
+ })
55
+ form.addEventListener('submit', async e => {
56
+ e.preventDefault()
57
+ createBtn.disabled = true
58
+ try {
59
+ const r = await api('projects', { body: { id: idInput.value } })
60
+ if (!r.created) toast(`"${r.id}" already exists`)
61
+ navigate(`#/projects/${encodeURIComponent(r.id)}`)
62
+ } catch (error) {
63
+ toast(error.message)
64
+ } finally {
65
+ createBtn.disabled = false
66
+ }
67
+ })
68
+
69
+ container.append(
70
+ h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Projects'), h('div', { class: 'sub' }, `${projects.length} project${projects.length === 1 ? '' : 's'} in Agent Cairn`)), h('div', { class: 'row' }, search.el, newBtn)),
71
+ form,
72
+ h('div', { style: { marginBottom: '16px' } }, kindLegend()),
73
+ grid,
74
+ )
75
+ draw()
76
+ return live.subscribe(kind => kind === 'stats' && draw())
77
+ }
@@ -0,0 +1,107 @@
1
+ import { api } from '../api.js'
2
+ import { copyText, fmt, h, icon } from '../dom.js'
3
+
4
+ const HISTORY_KEY = 'aib-query-history'
5
+
6
+ const EXAMPLES = [
7
+ ['Projects and sizes', 'MATCH (p:Project)\nOPTIONAL MATCH (m:Memory {projectId: p.id, status: "active"})\nRETURN p.id AS project, count(m) AS memories\nORDER BY memories DESC'],
8
+ ['Memories by kind', 'MATCH (m:Memory {status: "active"})\nRETURN m.projectId AS project, m.kind AS kind, count(*) AS n\nORDER BY project, n DESC'],
9
+ ['Most recent memories', 'MATCH (m:Memory)\nRETURN m.projectId AS project, m.kind AS kind, m.title AS title, toString(m.createdAt) AS written\nORDER BY m.createdAt DESC\nLIMIT 25'],
10
+ ['Supersede chains', 'MATCH (new:Memory)-[:SUPERSEDES]->(old:Memory)\nRETURN new.projectId AS project, old.title AS was, new.title AS now, toString(new.createdAt) AS changed\nORDER BY new.createdAt DESC\nLIMIT 50'],
11
+ ['Who writes memories', 'MATCH (m:Memory)\nRETURN coalesce(m.agent, "unknown") AS agent, coalesce(m.author, "unknown") AS author, count(*) AS memories\nORDER BY memories DESC'],
12
+ ['Largest memories', 'MATCH (m:Memory)\nRETURN m.projectId AS project, m.title AS title, size(m.body) AS characters\nORDER BY characters DESC\nLIMIT 15'],
13
+ ['Git remotes', 'MATCH (r:Remote)-[:BELONGS_TO]->(p:Project)\nRETURN p.id AS project, substring(r.hash, 0, 8) AS remote, toString(r.createdAt) AS added'],
14
+ ['Everything, as a graph', 'MATCH (p:Project)-[r]->(x)\nRETURN p, r, x\nLIMIT 100'],
15
+ ['Schema', 'CALL db.schema.visualization()'],
16
+ ]
17
+
18
+ const loadHistory = () => {
19
+ try {
20
+ return JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]')
21
+ } catch {
22
+ return []
23
+ }
24
+ }
25
+ const saveHistory = list => {
26
+ try {
27
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(list.slice(0, 15)))
28
+ } catch {
29
+ /* storage unavailable */
30
+ }
31
+ }
32
+
33
+ function cell(value) {
34
+ if (value === null || value === undefined) return h('span', { class: 'muted' }, 'null')
35
+ if (typeof value === 'object') {
36
+ if (value._type === 'node') return h('span', { class: 'tag', title: JSON.stringify(value.properties, null, 2) }, `(${value.labels.join(':')}) ${value.properties.title ?? value.properties.id ?? value.properties.name ?? value.id}`)
37
+ if (value._type === 'relationship') return h('span', { class: 'tag' }, `[:${value.type}]`)
38
+ return h('span', { class: 'mono', title: JSON.stringify(value) }, JSON.stringify(value))
39
+ }
40
+ return String(value)
41
+ }
42
+
43
+ const toCsv = ({ columns, rows }) => {
44
+ const esc = v => {
45
+ const s = v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v)
46
+ return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
47
+ }
48
+ return [columns.map(esc).join(','), ...rows.map(r => r.map(esc).join(','))].join('\n')
49
+ }
50
+
51
+ export async function mount(container) {
52
+ const editor = h('textarea', { rows: 6, spellcheck: 'false', 'aria-label': 'Cypher query', placeholder: 'MATCH (m:Memory) RETURN m LIMIT 10' })
53
+ editor.value = EXAMPLES[1][1]
54
+ const result = h('div', { class: 'card flush' }, h('div', { class: 'empty' }, 'Run a query to see rows here.'))
55
+ const runBtn = h('button', { class: 'btn primary', type: 'button' }, icon('play'), 'Run', h('span', { class: 'muted', style: { color: 'inherit', opacity: 0.7, fontSize: '11px' } }, 'Ctrl+Enter'))
56
+ const history = h('div', { class: 'row' })
57
+ let last = null
58
+
59
+ function drawHistory() {
60
+ const items = loadHistory()
61
+ history.replaceChildren(...(items.length ? [h('span', { class: 'hint' }, 'Recent:'), ...items.slice(0, 6).map(q => h('button', { class: 'chip', type: 'button', title: q, onclick: () => { editor.value = q; editor.focus() } }, q.replace(/\s+/g, ' ').slice(0, 34)))] : []))
62
+ }
63
+
64
+ async function run() {
65
+ const query = editor.value.trim()
66
+ if (!query) return
67
+ runBtn.disabled = true
68
+ result.replaceChildren(h('div', { class: 'empty' }, 'Running…'))
69
+ try {
70
+ const r = await api('cypher', { body: { query } })
71
+ last = r
72
+ saveHistory([query, ...loadHistory().filter(q => q !== query)])
73
+ drawHistory()
74
+ result.replaceChildren(
75
+ h('div', { class: 'head', style: { padding: '12px 16px 0' } }, h('span', { class: 'muted' }, `${r.rows.length}${r.truncated ? '+' : ''} row${r.rows.length === 1 ? '' : 's'} · ${fmt.ms(r.elapsedMs)}${r.truncated ? ' · truncated' : ''}`), h('div', { class: 'tools' },
76
+ h('button', { class: 'btn sm', type: 'button', onclick: () => copyText(toCsv(r), 'CSV copied') }, icon('copy'), 'CSV'),
77
+ h('button', { class: 'btn sm', type: 'button', onclick: () => copyText(JSON.stringify(r.rows.map(row => Object.fromEntries(r.columns.map((c, i) => [c, row[i]]))), null, 2), 'JSON copied') }, icon('copy'), 'JSON'))),
78
+ r.rows.length
79
+ ? h('div', { class: 'scroll', style: { maxHeight: '520px' } }, h('table', { class: 'tbl' }, h('thead', null, h('tr', null, r.columns.map(c => h('th', null, c)))), h('tbody', null, r.rows.map(row => h('tr', null, row.map(v => h('td', { class: 'mono-cell', title: typeof v === 'string' ? v : undefined }, cell(v))))))))
80
+ : h('div', { class: 'empty' }, 'The query ran and returned no rows.'),
81
+ )
82
+ } catch (error) {
83
+ result.replaceChildren(h('div', { style: { padding: '16px' } }, h('p', { class: 'error', style: { margin: 0 } }, error.message)))
84
+ } finally {
85
+ runBtn.disabled = false
86
+ }
87
+ }
88
+
89
+ runBtn.addEventListener('click', run)
90
+ editor.addEventListener('keydown', e => {
91
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
92
+ e.preventDefault()
93
+ run()
94
+ }
95
+ })
96
+
97
+ container.append(
98
+ h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Query'), h('div', { class: 'sub' }, 'Read-only Cypher against the database. Writes are refused twice: by a keyword screen and by the database itself.'))),
99
+ h('div', { class: 'card stack', style: { marginBottom: '14px' } },
100
+ h('div', { class: 'row' }, ...EXAMPLES.map(([label, q]) => h('button', { class: 'chip', type: 'button', onclick: () => { editor.value = q; editor.focus() } }, label))),
101
+ editor,
102
+ h('div', { class: 'row' }, runBtn, h('span', { class: 'hint' }, 'Up to 200 rows, 10 second limit.'), h('span', { class: 'grow' }), history),
103
+ ),
104
+ result,
105
+ )
106
+ drawHistory()
107
+ }
@@ -0,0 +1,73 @@
1
+ import { api } from '../api.js'
2
+ import { fmt, h, icon, toast } from '../dom.js'
3
+
4
+ const kv = rows => h('dl', { class: 'kv' }, rows.flatMap(([k, v]) => [h('dt', null, k), h('dd', null, v)]))
5
+
6
+ function counts(obj) {
7
+ const entries = Object.entries(obj)
8
+ const max = Math.max(1, ...entries.map(([, n]) => n))
9
+ return h('table', { class: 'tbl' }, h('tbody', null, entries.map(([name, n]) => h('tr', null, h('td', { class: 'mono' }, name), h('td', null, h('span', { class: 'bar', style: { width: `${Math.max(2, (n / max) * 120)}px` } })), h('td', { class: 'num' }, fmt.int(n))))))
10
+ }
11
+
12
+ export async function mount(container) {
13
+ const body = h('div', { class: 'stack' })
14
+ const refreshBtn = h('button', { class: 'btn', type: 'button' }, icon('refresh'), 'Refresh')
15
+
16
+ async function load() {
17
+ const s = await api('system')
18
+ body.replaceChildren(
19
+ h('div', { class: 'grid2' },
20
+ h('div', { class: 'card' }, h('div', { class: 'head' }, h('h2', null, 'Server')), kv([
21
+ ['Version', s.server.version], ['Node', s.server.node], ['Platform', s.server.platform], ['Process id', String(s.server.pid)],
22
+ ['Uptime', fmt.uptime(s.server.uptimeSeconds)], ['Memory', `${s.server.rssMb} MB resident · ${s.server.heapMb} MB heap`],
23
+ ])),
24
+ h('div', { class: 'card' }, h('div', { class: 'head' }, h('h2', null, 'Neo4j')), kv([
25
+ ['Server', s.neo4j.agent], ['Address', h('span', { class: 'mono' }, s.neo4j.address)], ['Configured URI', h('span', { class: 'mono' }, s.neo4j.uri)], ['User', s.neo4j.user],
26
+ ['Round trip', fmt.ms(s.neo4j.pingMs)], ['Schema version', String(s.neo4j.schemaVersion ?? 'unknown')],
27
+ ['Credentials', s.neo4j.defaultCredentials ? h('span', { class: 'badge auth' }, icon('alert'), 'default password: change it') : h('span', { class: 'badge ok' }, icon('check'), 'customized')],
28
+ ])),
29
+ ),
30
+ h('div', { class: 'grid2' },
31
+ h('div', { class: 'card flush' }, h('div', { class: 'head' }, h('h2', null, 'Nodes in the graph')), counts(s.graph.labels)),
32
+ h('div', { class: 'card flush' }, h('div', { class: 'head' }, h('h2', null, 'Relationships')), counts(s.graph.relationships)),
33
+ ),
34
+ h('div', { class: 'grid2' },
35
+ h('div', { class: 'card' }, h('div', { class: 'head' }, h('h2', null, 'Security & capture')), kv([
36
+ ['Dashboard tokens', `${s.auth.managedTokens} active`],
37
+ ['Static token (CAIRN_TOKEN)', s.auth.envToken ? h('span', { class: 'badge ok' }, icon('check'), 'set') : h('span', { class: 'muted' }, 'not set')],
38
+ ['Authentication', s.auth.noAuth ? h('span', { class: 'badge err' }, icon('alert'), 'DISABLED (CAIRN_NO_AUTH)') : h('span', { class: 'badge ok' }, icon('lock'), 'required')],
39
+ ['Payload capture', s.capture.mode === 'full' ? 'full: requests and responses are kept in memory' : 'summary only'],
40
+ ['Event buffer', `last ${fmt.int(s.capture.capacity)} requests, in memory only (cleared on restart)`],
41
+ ]), h('p', { class: 'hint', style: { marginBottom: 0 } }, 'Authorization headers are never recorded. Set CAIRN_CAPTURE=meta to stop keeping payloads.')),
42
+ passwordCard(),
43
+ ),
44
+ )
45
+ }
46
+
47
+ function passwordCard() {
48
+ const current = h('input', { class: 'input', type: 'password', autocomplete: 'current-password' })
49
+ const next = h('input', { class: 'input', type: 'password', autocomplete: 'new-password', minlength: '10' })
50
+ const error = h('p', { class: 'error', role: 'alert' })
51
+ const save = h('button', { class: 'btn', type: 'submit' }, 'Change password')
52
+ const form = h('form', null, h('div', { class: 'field' }, h('label', null, 'Current password'), current), h('div', { class: 'field' }, h('label', null, 'New password (10+ characters)'), next), error, save)
53
+ form.addEventListener('submit', async e => {
54
+ e.preventDefault()
55
+ error.textContent = ''
56
+ save.disabled = true
57
+ try {
58
+ await api('password', { body: { current: current.value, next: next.value } })
59
+ current.value = next.value = ''
60
+ toast('Password changed. Other sessions were signed out.')
61
+ } catch (err) {
62
+ error.textContent = err.message
63
+ } finally {
64
+ save.disabled = false
65
+ }
66
+ })
67
+ return h('div', { class: 'card' }, h('div', { class: 'head' }, h('h2', null, 'Admin password')), form)
68
+ }
69
+
70
+ refreshBtn.addEventListener('click', () => load().catch(e => toast(e.message)))
71
+ container.append(h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'System'), h('div', { class: 'sub' }, 'The server, its database, and how it is protected.')), refreshBtn), body)
72
+ await load()
73
+ }