feat(ui): resource + inbox views
Resource: status header (status + runtime_type + host + clickable URL), three-column row of Dependencies (with add-by-UUID form) / Source docs / Runbook pages (filters /api/links/to/resource/:id for from_type=page and relation=runbook, then fetches each page title). Change history card pulls /api/resources/:id/changes. Inbox: groups pending changes by agent. Each row shows entity type badge + action + reason, with a collapsible payload disclosure. Approve calls /api/pending-changes/:id/approve and, if the response carries entity_id, navigates to the resulting detail view. Reject just re-fetches. Both update the shared pending-count emitted to state.js so the sidebar and topbar badges drop immediately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user