Search view: read ?q from hash, call /api/search, group hits by kind with rank + space_id; sidebar filters for kinds and space_id; updates on Enter or filter change. Bumps package.json + server.js VERSION to 2.0.0-alpha.2 and pins the /health version assertion to match. CHANGELOG: full Plan 2 entry covering API surface, capability tiering, audit chain extension (approve/reject events), and the SPA shell. Security: adds safeHref() to dom.js and applies it everywhere an API-supplied URL becomes href / src (reference media block + reference source_url anchor + resource url anchor). javascript: and other non-http(s)/mailto schemes from agent-suggested content can no longer execute in the owner's browser. Plan 2 surface is feature-complete: 22/22 tasks landed, 185 tests across 43 files, SPA renders end-to-end including the suggest -> approve agent flow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
144 lines
4.7 KiB
JavaScript
144 lines
4.7 KiB
JavaScript
// Resource detail: status header + dependencies + source docs + runbook pages + change history.
|
|
import { api } from '../api.js';
|
|
import { el, mount, clear, safeHref } 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: safeHref(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);
|
|
}
|