diff --git a/public/views/inbox.js b/public/views/inbox.js index a82b556..917d148 100644 --- a/public/views/inbox.js +++ b/public/views/inbox.js @@ -1,9 +1,93 @@ -// T17 stub — full implementation lands in T21. -import { el, mount } from '../dom.js'; -export async function render(main) { - mount(main, - el('h1', { class: 'view-h1' }, 'Inbox'), - el('p', { class: 'view-sub muted' }, 'Pending agent suggestions.'), - el('div', { class: 'card muted' }, 'Inbox view ships in T21.') +// Inbox: pending changes grouped by agent. Approve/reject calls the API +// then re-fetches. On approve, navigate to the resulting entity_id if +// the response carries one. + +import { api } from '../api.js'; +import { el, mount, clear } from '../dom.js'; +import { navigate } from '../router.js'; +import { emit } from '../state.js'; + +const ROUTE_BY_TYPE = { + page: id => '#/page/' + id, + project: id => '#/project/' + id, + task: () => '#/', + ref: id => '#/ref/' + id, + resource: id => '#/resource/' + id, + source_doc: () => '#/', + space: id => '#/space/' + id +}; + +function diffBlock(diff) { + if (!diff) return null; + return el('pre', {}, JSON.stringify(diff, null, 2)); +} + +function pendingRow(row, onActed) { + const meta = el('p', { style: { margin: '0 0 6px' } }, + el('span', { class: 'status idle' }, row.entity_type), + ' ', + el('span', { class: 'muted' }, row.action), + row.reason ? el('span', { class: 'muted' }, ' — ' + row.reason) : null + ); + + async function approve() { + try { + const res = await api.post(`/api/pending-changes/${row.id}/approve`); + onActed(); + const route = ROUTE_BY_TYPE[row.entity_type]; + if (res.entity_id && route) navigate(route(res.entity_id)); + } catch (e) { alert('approve failed: ' + e.message); } + } + async function reject() { + try { await api.post(`/api/pending-changes/${row.id}/reject`); onActed(); } + catch (e) { alert('reject failed: ' + e.message); } + } + + return el('div', { class: 'card', style: { marginBottom: '10px' } }, + meta, + el('details', {}, + el('summary', { class: 'muted', style: { cursor: 'pointer', fontSize: '11px' } }, 'payload'), + el('pre', {}, JSON.stringify(row.payload, null, 2)) + ), + diffBlock(null), + el('div', { style: { display: 'flex', gap: '8px', marginTop: '8px' } }, + el('button', { class: 'primary', onclick: approve }, 'Approve'), + el('button', { class: 'ghost', onclick: reject }, 'Reject') + ) ); } + +async function refresh(container) { + let rows = []; + try { rows = await api.get('/api/pending-changes?limit=200'); } + catch (e) { + clear(container); + container.appendChild(el('p', { class: 'muted' }, 'Could not load: ' + e.message)); + return; + } + emit('pending-count', rows.length); + clear(container); + if (!rows.length) { + container.appendChild(el('p', { class: 'muted' }, 'Inbox is empty.')); + return; + } + const byAgent = new Map(); + for (const r of rows) { + if (!byAgent.has(r.agent_id)) byAgent.set(r.agent_id, []); + byAgent.get(r.agent_id).push(r); + } + for (const [agentId, items] of byAgent) { + container.appendChild(el('div', { class: 'sb-title' }, 'Agent ' + agentId.slice(0, 8))); + for (const row of items) container.appendChild(pendingRow(row, () => refresh(container))); + } +} + +export async function render(main) { + const wrap = el('div'); + mount(main, + el('h1', { class: 'view-h1' }, 'Inbox'), + el('p', { class: 'view-sub' }, 'Pending agent suggestions awaiting your call.'), + wrap + ); + refresh(wrap); +} diff --git a/public/views/resource.js b/public/views/resource.js index e05cd5d..07756ef 100644 --- a/public/views/resource.js +++ b/public/views/resource.js @@ -1,9 +1,143 @@ -// T17 stub — full implementation lands in T21. -import { el, mount } from '../dom.js'; -export async function render(main, ctx) { - mount(main, - el('h1', { class: 'view-h1' }, 'Resource'), - el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')), - el('div', { class: 'card muted' }, 'Resource detail ships in T21.') - ); +// Resource detail: status header + dependencies + source docs + runbook pages + change history. +import { api } from '../api.js'; +import { el, mount, clear } from '../dom.js'; + +function statusClass(s) { + return s === 'running' ? 'ok' : s === 'stopped' ? 'warn' : s === 'down' ? 'bad' : 'idle'; +} + +async function loadDependencies(id, listEl) { + let deps = []; + try { deps = await api.get(`/api/resources/${id}/dependencies`); } + catch { /* */ } + clear(listEl); + if (!deps.length) { + listEl.appendChild(el('li', { class: 'muted' }, '(no dependencies)')); + return; + } + for (const d of deps) { + listEl.appendChild(el('li', {}, + el('span', { class: 'status idle' }, d.kind || 'depends-on'), + ' ', + el('a', { href: '#/resource/' + d.depends_on }, d.depends_on.slice(0, 8) + '…') + )); + } +} + +async function loadRunbooks(id, listEl) { + clear(listEl); + let links = []; + try { + const all = await api.get('/api/links/to/resource/' + id); + links = all.filter(l => l.from_type === 'page' && l.relation === 'runbook'); + } catch { /* */ } + if (!links.length) { + listEl.appendChild(el('li', { class: 'muted' }, 'No runbook pages linked.')); + return; + } + for (const l of links) { + let title = l.from_id.slice(0, 8) + '…'; + try { + const page = await api.get('/api/pages/' + l.from_id); + title = page.title; + } catch { /* keep id */ } + listEl.appendChild(el('li', {}, el('a', { href: '#/page/' + l.from_id }, title))); + } +} + +async function loadChangeHistory(id, listEl) { + clear(listEl); + let rows = []; + try { rows = await api.get('/api/resources/' + id + '/changes'); } + catch { /* */ } + if (!rows.length) { + listEl.appendChild(el('li', { class: 'muted' }, 'No history yet.')); + return; + } + for (const r of rows.slice(0, 20)) { + listEl.appendChild(el('li', {}, + el('span', { class: 'muted' }, new Date(r.occurred_at).toISOString().slice(0, 16).replace('T', ' ') + ' — '), + r.actor_kind, ' ', r.action + )); + } +} + +async function loadSourceDocs(id, listEl) { + clear(listEl); + let docs = []; + try { docs = await api.get('/api/resources/' + id + '/source-docs'); } + catch { /* */ } + if (!docs.length) { + listEl.appendChild(el('li', { class: 'muted' }, 'No source docs.')); + return; + } + for (const d of docs) { + listEl.appendChild(el('li', {}, d.name, ' ', el('span', { class: 'muted', style: { fontSize: '11px' } }, d.upstream_url))); + } +} + +export async function render(main, ctx) { + const id = ctx.params.id; + mount(main, el('p', { class: 'view-sub muted' }, 'Loading …')); + + let res; + try { res = await api.get('/api/resources/' + id); } + catch (e) { + mount(main, + el('h1', { class: 'view-h1' }, 'Resource not found'), + el('p', { class: 'view-sub muted' }, e.message) + ); + return; + } + + const depsList = el('ul', { class: 'plain' }); + const runbookList = el('ul', { class: 'plain' }); + const sdList = el('ul', { class: 'plain' }); + const histList = el('ul', { class: 'plain' }); + + const depIdInput = el('input', { type: 'text', placeholder: 'depends_on UUID', style: { flex: 1 } }); + const depKindInput = el('input', { type: 'text', placeholder: 'kind (optional)', style: { width: '120px' } }); + async function addDep() { + const dep = depIdInput.value.trim(); + if (!dep) return; + try { + await api.post(`/api/resources/${id}/dependencies`, { depends_on: dep, kind: depKindInput.value.trim() || null }); + depIdInput.value = ''; depKindInput.value = ''; + loadDependencies(id, depsList); + } catch (e) { alert('add failed: ' + e.message); } + } + + mount(main, + el('h1', { class: 'view-h1' }, res.name), + el('p', { class: 'view-sub' }, + el('span', { class: 'status ' + statusClass(res.status) }, res.status), + ' · ', el('span', { class: 'status idle' }, res.runtime_type), + res.host ? ' · ' + res.host : '', + res.url ? ' · ' : '', + res.url ? el('a', { href: res.url, target: '_blank', rel: 'noopener noreferrer' }, res.url) : null + ), + el('div', { class: 'row' }, + el('div', { class: 'card' }, + el('h3', {}, 'Dependencies'), + depsList, + el('div', { style: { display: 'flex', gap: '8px', marginTop: '10px' } }, + depIdInput, depKindInput, el('button', { class: 'primary', onclick: addDep }, 'Link') + ) + ), + el('div', { class: 'card' }, + el('h3', {}, 'Source docs'), + sdList + ), + el('div', { class: 'card' }, + el('h3', {}, 'Runbook pages'), + runbookList + ) + ), + el('div', { class: 'card' }, el('h3', {}, 'Change history'), histList) + ); + + loadDependencies(id, depsList); + loadSourceDocs(id, sdList); + loadRunbooks(id, runbookList); + loadChangeHistory(id, histList); }