<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ward dashboard</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0f;
--surface: #141416;
--border: #1e1e22;
--text: #e2e2e6;
--muted: #5a5a6a;
--accent: #7c6ef5;
--green: #4ade80;
--yellow: #fbbf24;
--red: #f87171;
--cyan: #22d3ee;
--blue: #60a5fa;
--magenta: #c084fc;
--radius: 6px;
--font: "SF Mono", "Fira Code", "Cascadia Code", monospace;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 12px;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.logo {
font-weight: 700;
font-size: 13px;
color: var(--text);
letter-spacing: 0.04em;
}
.logo span { color: var(--accent); }
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 6px var(--green);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.header-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 11px;
}
button, input, textarea {
font-family: var(--font);
font-size: 11px;
}
.btn, #refresh-btn {
background: none;
border: 1px solid var(--border);
color: var(--muted);
border-radius: var(--radius);
padding: 3px 10px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
text-decoration: none;
line-height: 17px;
}
.btn:hover, #refresh-btn:hover { color: var(--text); border-color: var(--muted); }
.btn.primary { color: #fff; background: var(--accent); border-color: var(--accent); }
.btn.danger:hover { color: var(--red); border-color: var(--red); }
.filters {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
flex-wrap: wrap;
}
.filter-label { color: var(--muted); font-size: 11px; margin-right: 4px; }
.chip {
padding: 2px 10px;
border-radius: 99px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
font-family: var(--font);
font-size: 11px;
transition: all 0.15s;
}
.chip:hover { border-color: var(--muted); color: var(--text); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.chip[data-kind="execution"] { --k: var(--cyan); }
.chip[data-kind="request"] { --k: var(--blue); }
.chip[data-kind="approval"] { --k: var(--green); }
.chip[data-kind="alert"] { --k: var(--yellow); }
.chip[data-kind="session"] { --k: var(--magenta); }
.chip[data-kind].active { background: var(--k); border-color: var(--k); color: #071014; }
.separator { width: 1px; height: 16px; background: var(--border); margin: 0 4px; }
input, textarea {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 5px 7px;
outline: none;
}
textarea { min-height: 54px; resize: vertical; width: 100%; }
input:focus, textarea:focus { border-color: var(--accent); }
.dropdown {
position: relative;
min-width: 130px;
}
.form-grid .dropdown { min-width: 0; width: 100%; }
.dropdown-button {
position: relative;
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 5px 24px 5px 7px;
text-align: left;
cursor: pointer;
outline: none;
transition: border-color 0.15s, background 0.15s;
}
.dropdown-button:hover,
.dropdown.open .dropdown-button { border-color: var(--accent); }
.dropdown-button::after {
content: 'v';
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
font-size: 9px;
pointer-events: none;
}
.dropdown-menu {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
right: 0;
display: none;
max-height: 220px;
overflow-y: auto;
padding: 4px;
background: #18181c;
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
}
.dropdown.open .dropdown-menu { display: block; }
.dropdown-option {
width: 100%;
border: 0;
background: transparent;
color: var(--muted);
border-radius: 4px;
padding: 5px 7px;
text-align: left;
cursor: pointer;
}
.dropdown-option:hover,
.dropdown-option.active {
color: var(--text);
background: #252531;
}
.body {
display: flex;
flex: 1;
overflow: hidden;
}
.table-pane {
flex: 0 0 58%;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
overflow: hidden;
}
.table-scroll { overflow-y: auto; flex: 1; }
.table-scroll::-webkit-scrollbar,
.detail-pane::-webkit-scrollbar { width: 4px; }
.table-scroll::-webkit-scrollbar-thumb,
.detail-pane::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
table { width: 100%; border-collapse: collapse; }
thead th {
position: sticky; top: 0;
background: var(--surface);
color: var(--muted);
font-weight: 500;
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid #1a1a1e;
cursor: pointer;
transition: background 0.1s;
}
tbody tr:hover { background: #18181c; }
tbody tr.selected { background: #1c1a2e; border-left: 2px solid var(--accent); }
td {
padding: 6px 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
color: var(--text);
}
.td-time { color: var(--muted); font-size: 11px; width: 64px; }
.td-kind { width: 88px; }
.td-sev { width: 54px; }
.td-agent { width: 100px; color: var(--muted); }
.td-cmd { color: var(--text); }
.badge {
display: inline-block;
padding: 1px 7px;
border-radius: 99px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
}
.badge-execution { background: #0e3a42; color: var(--cyan); }
.badge-request { background: #0e1e3a; color: var(--blue); }
.badge-approval { background: #0e3020; color: var(--green); }
.badge-alert { background: #3a2a0e; color: var(--yellow); }
.badge-session { background: #2a1a3a; color: var(--magenta); }
.badge-ok { background: #0e3020; color: var(--green); }
.badge-warn { background: #3a2a0e; color: var(--yellow); }
.badge-neutral { background: #202026; color: var(--muted); }
.sev-info { color: var(--muted); }
.sev-warn { color: var(--yellow); }
.sev-crit { color: var(--red); font-weight: 700; }
.detail-pane {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.detail-empty {
color: var(--muted);
font-size: 11px;
margin-top: 40px;
text-align: center;
}
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.detail-title { font-size: 13px; font-weight: 600; color: var(--text); }
.detail-ts { font-size: 10px; color: var(--muted); margin-left: auto; }
.field-group { margin-bottom: 14px; }
.field-label {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 4px;
}
.field-value {
font-size: 12px;
color: var(--text);
word-break: break-all;
}
.env-chip {
display: inline-block;
background: #0e2a3a;
color: var(--cyan);
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
margin: 2px 3px 2px 0;
}
.finding, .policy-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
margin-bottom: 8px;
}
.finding-header, .policy-actions {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.policy-actions { justify-content: space-between; }
.finding-msg { color: var(--muted); font-size: 11px; }
.outcome-success { color: var(--green); font-weight: 600; }
.outcome-failure { color: var(--red); font-weight: 600; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.form-grid .full { grid-column: 1 / -1; }
.check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 4px 8px; }
.check-grid label { color: var(--muted); display: flex; gap: 5px; align-items: center; }
.inline-form { display: flex; gap: 6px; align-items: center; margin-top: 8px; }
.inline-form input { min-width: 0; flex: 1; }
footer {
border-top: 1px solid var(--border);
padding: 4px 20px;
display: flex;
gap: 16px;
align-items: center;
background: var(--surface);
color: var(--muted);
font-size: 10px;
flex-shrink: 0;
}
.count { color: var(--text); }
@media (max-width: 900px) {
body { overflow: auto; height: auto; }
.body { display: block; overflow: visible; }
.table-pane { border-right: 0; min-height: 320px; }
.detail-pane { border-top: 1px solid var(--border); }
}
</style>
</head>
<body>
<header>
<div class="dot"></div>
<div class="logo">ward <span id="page-title">dashboard</span></div>
<div class="header-right">
<a class="btn" id="overview-link" href="/">overview</a>
<a class="btn" id="logs-link" href="/logs">all logs</a>
<button class="btn" id="add-project-btn">add project</button>
<span id="last-refresh">-</span>
<button id="refresh-btn">refresh</button>
</div>
</header>
<div class="filters" id="filters"></div>
<div class="body">
<div class="table-pane">
<div class="table-scroll">
<table id="main-table"></table>
</div>
</div>
<div class="detail-pane" id="detail-pane">
<div class="detail-empty">select an item</div>
</div>
</div>
<footer>
<span>ward browser dashboard</span>
<span class="count" id="item-count">0 items</span>
<span id="filter-count"></span>
</footer>
<script>
const token = new URLSearchParams(location.search).get('token') || '';
const currentPath = window.location.pathname;
const logsProject = projectFromLogPath(currentPath);
let projects = [];
let status = null;
let events = [];
let selectedProject = null;
let selectedRow = null;
let selectedEventKey = null;
let kindFilter = 'all';
let sevFilter = 'all';
let agentFilter = '';
function withToken(path) {
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}token=${encodeURIComponent(token)}`;
}
function link(path) { return withToken(path); }
async function api(path, options = {}) {
const response = await fetch(withToken(path), {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
const error = new Error(data.message || data.error || response.statusText);
error.data = data;
throw error;
}
return data;
}
function projectFromLogPath(path) {
const parts = path.split('/').filter(Boolean);
if (parts.length === 3 && parts[0] === 'projects' && parts[2] === 'logs') {
return decodeURIComponent(parts[1]);
}
return null;
}
function isLogsPage() { return currentPath === '/logs' || logsProject; }
async function load() {
[projects, status] = await Promise.all([
api('/api/projects'),
api('/api/dashboard/status')
]);
if (!selectedProject && projects.length) {
selectedProject = logsProject || (projects.find(p => p.active) || projects[0]).name;
}
if (isLogsPage()) {
await loadEvents(logsProject);
renderLogs();
} else {
renderOverview();
}
document.getElementById('last-refresh').textContent = new Date().toLocaleTimeString();
}
async function loadEvents(project) {
const suffix = project ? `?project=${encodeURIComponent(project)}` : '';
events = await api(`/api/events${suffix}`);
}
function renderOverview() {
document.getElementById('page-title').textContent = 'overview';
document.getElementById('overview-link').href = link('/');
document.getElementById('logs-link').href = link('/logs');
renderProjectFilters();
renderProjectTable();
const project = currentProject();
renderProjectDetail(project);
}
function renderProjectFilters() {
const filters = document.getElementById('filters');
filters.innerHTML = '<span class="filter-label">project</span>' + projects.map(p => (
`<button class="chip ${p.name === selectedProject ? 'active' : ''}" data-project="${esc(p.name)}">${esc(p.name)}</button>`
)).join('');
filters.querySelectorAll('[data-project]').forEach(btn => {
btn.addEventListener('click', () => {
selectedProject = btn.dataset.project;
selectedRow = null;
renderOverview();
});
});
}
function renderProjectTable() {
const table = document.getElementById('main-table');
table.innerHTML = `<thead><tr>
<th>project</th><th>config</th><th>session</th><th>env</th><th>profiles</th>
</tr></thead><tbody></tbody>`;
const tbody = table.querySelector('tbody');
projects.forEach((project, index) => {
const tr = document.createElement('tr');
if (project.name === selectedProject) tr.classList.add('selected');
tr.innerHTML = `
<td><span class="badge ${project.active ? 'badge-ok' : 'badge-neutral'}">${esc(project.name)}</span></td>
<td>${statusBadge(project.configStatus === 'ok', project.configStatus)}</td>
<td>${statusBadge(project.brokerSessionActive, project.brokerSessionActive ? 'active' : 'locked')}</td>
<td>${project.envNames.length}</td>
<td>${project.profiles.length}</td>`;
tr.addEventListener('click', () => {
selectedProject = project.name;
selectedRow = index;
renderOverview();
});
tbody.appendChild(tr);
});
document.getElementById('item-count').textContent = `${projects.length} projects`;
document.getElementById('filter-count').textContent = status ? `${status.instances.length} dashboard instance(s)` : '';
}
function renderProjectDetail(project) {
const pane = document.getElementById('detail-pane');
if (!project) {
pane.innerHTML = '<div class="detail-empty">no projects registered</div>';
return;
}
pane.innerHTML = `
<div class="detail-header">
<span class="badge ${project.brokerSessionActive ? 'badge-ok' : 'badge-warn'}">${project.brokerSessionActive ? 'active' : 'locked'}</span>
<span class="detail-title">${esc(project.name)}</span>
<a class="btn detail-ts" href="${link(`/projects/${encodeURIComponent(project.name)}/logs`)}">logs</a>
</div>
${field('path', shortPath(project.path))}
${field('vault', shortPath(project.vault))}
${field('config', project.configStatus)}
${field('vault keys', project.vaultKeysVerified ? 'verified' : 'unavailable')}
${field('env names', project.envNames.map(envChip).join('') || '<span class="sev-info">none</span>')}
<div class="field-group">
<div class="field-label">profile policies</div>
<div id="profiles"></div>
</div>
<div class="field-group">
<div class="field-label">new profile policy</div>
${profileEditor(project, null)}
</div>`;
renderProfileCards(project);
bindProfileEditors(project);
}
function renderProfileCards(project) {
const host = document.getElementById('profiles');
host.innerHTML = project.profiles.map(profile => `
<div class="policy-card" data-profile-card="${esc(profile.name)}">
<div class="policy-actions">
<span><span class="badge badge-neutral">${esc(profile.name)}</span> ${esc(profile.defaultScope)}</span>
<button class="btn danger" data-delete-profile="${esc(profile.name)}">delete</button>
</div>
${profileEditor(project, profile)}
</div>
`).join('') || '<div class="finding-msg">no profiles</div>';
}
function profileEditor(project, profile) {
const prefix = profile ? `edit-${profile.name}` : 'create';
const env = new Set(profile ? profile.env : []);
const defaultScope = profile ? profile.defaultScope : 'session';
return `
<div class="form-grid" data-profile-editor="${esc(prefix)}">
<input data-field="name" placeholder="name" value="${esc(profile ? profile.name : '')}">
${dropdownMarkup('defaultScope', defaultScope, ['once','session','branch','always'])}
<input class="full" data-field="command" placeholder="command" value="${esc(profile ? profile.command : '')}">
<input class="full" data-field="action" placeholder="action" value="${esc(profile ? profile.action : '')}">
<div class="full check-grid">
${project.envNames.map(name => `<label><input type="checkbox" data-env="${esc(name)}" ${env.has(name) ? 'checked' : ''}>${esc(name)}</label>`).join('')}
</div>
<div class="full inline-form">
<input data-field="extraEnv" placeholder="ENV_NAME">
<button class="btn" data-add-env>Add env</button>
<button class="btn primary" data-save-profile="${esc(profile ? profile.name : '')}">${profile ? 'save' : 'create'}</button>
</div>
</div>`;
}
function bindProfileEditors(project) {
bindDropdowns(document.getElementById('detail-pane'));
document.querySelectorAll('[data-add-env]').forEach(btn => {
btn.addEventListener('click', event => {
const editor = event.target.closest('[data-profile-editor]');
const input = editor.querySelector('[data-field="extraEnv"]');
const name = input.value.trim();
if (!name) return;
const grid = editor.querySelector('.check-grid');
grid.insertAdjacentHTML('beforeend', `<label><input type="checkbox" data-env="${esc(name)}" checked>${esc(name)}</label>`);
input.value = '';
});
});
document.querySelectorAll('[data-save-profile]').forEach(btn => {
btn.addEventListener('click', () => saveProfile(project.name, btn.dataset.saveProfile, btn.closest('[data-profile-editor]')));
});
document.querySelectorAll('[data-delete-profile]').forEach(btn => {
btn.addEventListener('click', () => deleteProfile(project.name, btn.dataset.deleteProfile));
});
}
async function saveProfile(projectName, oldName, editor) {
const body = profilePayload(editor);
const path = oldName
? `/api/projects/${encodeURIComponent(projectName)}/profiles/${encodeURIComponent(oldName)}`
: `/api/projects/${encodeURIComponent(projectName)}/profiles`;
const method = oldName ? 'PATCH' : 'POST';
const updated = await api(path, { method, body: JSON.stringify(body) });
projects = projects.map(project => project.name === projectName ? updated : project);
selectedProject = projectName;
renderOverview();
}
async function deleteProfile(projectName, profileName) {
const updated = await api(`/api/projects/${encodeURIComponent(projectName)}/profiles/${encodeURIComponent(profileName)}`, { method: 'DELETE' });
projects = projects.map(project => project.name === projectName ? updated : project);
renderOverview();
}
function profilePayload(editor) {
return {
name: editor.querySelector('[data-field="name"]').value.trim(),
command: editor.querySelector('[data-field="command"]').value.trim(),
action: editor.querySelector('[data-field="action"]').value.trim(),
defaultScope: editor.querySelector('[data-field="defaultScope"]').dataset.value,
env: [...editor.querySelectorAll('[data-env]:checked')].map(input => input.dataset.env)
};
}
function renderLogs() {
const title = logsProject ? `${logsProject} logs` : 'logs';
document.getElementById('page-title').textContent = title;
document.getElementById('overview-link').href = link('/');
document.getElementById('logs-link').href = link('/logs');
renderLogFilters();
renderLogTable();
const selectedEvent = selectedLogEvent();
if (selectedEvent) {
renderEventDetail(selectedEvent);
} else {
document.getElementById('detail-pane').innerHTML = '<div class="detail-empty">select an event</div>';
}
}
function renderLogFilters() {
const filters = document.getElementById('filters');
filters.innerHTML = `
<span class="filter-label">kind</span>
${['all','execution','request','approval','alert','session'].map(kind => `<button class="chip ${kindFilter === kind ? 'active' : ''}" data-kind="${kind}">${kind === 'execution' ? 'exec' : kind}</button>`).join('')}
<div class="separator"></div>
<span class="filter-label">agent</span>
<div id="agent-filter"></div>
<div class="separator"></div>
<span class="filter-label">severity</span>
${['all','warn','crit'].map(sev => `<button class="chip ${sevFilter === sev ? 'active' : ''}" data-sev="${sev}">${sev === 'crit' ? 'critical' : sev}</button>`).join('')}
`;
filters.querySelectorAll('[data-kind]').forEach(btn => btn.addEventListener('click', () => { kindFilter = btn.dataset.kind; renderLogs(); }));
filters.querySelectorAll('[data-sev]').forEach(btn => btn.addEventListener('click', () => { sevFilter = btn.dataset.sev; renderLogs(); }));
updateAgentSelect();
}
function filteredEvents() {
return events.filter(event => {
const payload = event.payload || event;
if (kindFilter !== 'all' && event._kind !== kindFilter) return false;
const agent = payload.agent || (payload.access && payload.access.agent) || '';
if (agentFilter && agent !== agentFilter) return false;
if (sevFilter === 'warn' && severity(event) === 'info') return false;
if (sevFilter === 'crit' && severity(event) !== 'crit') return false;
return true;
});
}
function renderLogTable() {
const table = document.getElementById('main-table');
table.innerHTML = `<thead><tr>
<th class="td-time">time</th><th class="td-kind">kind</th><th class="td-sev">sev</th><th class="td-agent">agent</th><th class="td-cmd">command / action</th>
</tr></thead><tbody></tbody>`;
const rows = filteredEvents();
selectedRow = selectedEventKey ? rows.findIndex(event => eventKey(event) === selectedEventKey) : null;
if (selectedRow === -1) selectedRow = null;
const tbody = table.querySelector('tbody');
rows.forEach((event, index) => {
const payload = event.payload || event;
const agent = payload.agent || (payload.access && payload.access.agent) || '-';
const cmd = payload.requestedCommand || (payload.access && payload.access.command) || payload.declaredAction || (payload.access && payload.access.action) || payload.eventType || '-';
const sev = severity(event);
const tr = document.createElement('tr');
if (selectedEventKey && eventKey(event) === selectedEventKey) tr.classList.add('selected');
tr.innerHTML = `
<td class="td-time">${esc((event.timestamp || '').slice(11, 19))}</td>
<td class="td-kind"><span class="badge badge-${event._kind}">${event._kind}</span></td>
<td class="td-sev"><span class="sev-${sev}">${sev}</span></td>
<td class="td-agent">${esc(agent)}</td>
<td class="td-cmd">${esc(shorten(cmd, 70))}</td>`;
tr.addEventListener('click', () => {
selectedRow = index;
selectedEventKey = eventKey(event);
renderLogTable();
renderEventDetail(event);
});
tbody.appendChild(tr);
});
document.getElementById('item-count').textContent = `${events.length} events`;
document.getElementById('filter-count').textContent = rows.length < events.length ? `${rows.length} shown` : '';
}
function selectedLogEvent() {
if (!selectedEventKey) return null;
return filteredEvents().find(event => eventKey(event) === selectedEventKey) || null;
}
function eventKey(event) {
return [
event._project || '',
event._kind || '',
event.timestamp || '',
JSON.stringify(event.payload || event)
].join('|');
}
function updateAgentSelect() {
const agents = [...new Set(events.map(event => {
const payload = event.payload || event;
return payload.agent || (payload.access && payload.access.agent) || '';
}).filter(Boolean))].sort();
if (agentFilter && !agents.includes(agentFilter)) agentFilter = '';
const host = document.getElementById('agent-filter');
if (!host) return;
const options = [{ value: '', label: 'all agents' }].concat(agents.map(agent => ({ value: agent, label: agent })));
host.innerHTML = dropdownMarkup('agentFilter', agentFilter, options);
bindDropdowns(host);
const dropdown = host.querySelector('[data-field="agentFilter"]');
dropdown.addEventListener('ward:dropdown-change', event => {
agentFilter = event.detail.value;
renderLogs();
});
}
function dropdownMarkup(field, value, options) {
const normalized = options.map(option => {
if (typeof option === 'string') return { value: option, label: option };
return { value: option.value, label: option.label };
});
const current = normalized.find(option => String(option.value) === String(value)) || normalized[0] || { value: '', label: '' };
return `
<div class="dropdown" data-dropdown data-field="${esc(field)}" data-value="${esc(current.value)}">
<button type="button" class="dropdown-button" data-dropdown-toggle aria-haspopup="listbox">${esc(current.label)}</button>
<div class="dropdown-menu" role="listbox">
${normalized.map(option => {
const active = String(option.value) === String(current.value);
return `<button type="button" class="dropdown-option ${active ? 'active' : ''}" data-dropdown-option data-dropdown-value="${esc(option.value)}" role="option" aria-selected="${active ? 'true' : 'false'}">${esc(option.label)}</button>`;
}).join('')}
</div>
</div>`;
}
function bindDropdowns(root = document) {
root.querySelectorAll('[data-dropdown]').forEach(dropdown => {
if (dropdown.dataset.dropdownBound === 'true') return;
dropdown.dataset.dropdownBound = 'true';
const toggle = dropdown.querySelector('[data-dropdown-toggle]');
toggle.addEventListener('click', event => {
event.stopPropagation();
const wasOpen = dropdown.classList.contains('open');
closeDropdowns(dropdown);
dropdown.classList.toggle('open', !wasOpen);
});
dropdown.querySelectorAll('[data-dropdown-option]').forEach(option => {
option.addEventListener('click', event => {
event.stopPropagation();
dropdown.dataset.value = option.dataset.dropdownValue;
toggle.textContent = option.textContent;
dropdown.querySelectorAll('[data-dropdown-option]').forEach(item => {
const active = item === option;
item.classList.toggle('active', active);
item.setAttribute('aria-selected', active ? 'true' : 'false');
});
dropdown.classList.remove('open');
dropdown.dispatchEvent(new CustomEvent('ward:dropdown-change', {
bubbles: true,
detail: { field: dropdown.dataset.field, value: dropdown.dataset.value }
}));
});
});
});
}
function closeDropdowns(except = null) {
document.querySelectorAll('[data-dropdown].open').forEach(dropdown => {
if (dropdown !== except) dropdown.classList.remove('open');
});
}
function renderEventDetail(event) {
const payload = event.payload || event;
const sev = severity(event);
const agent = payload.agent || (payload.access && payload.access.agent) || null;
const branch = payload.branch || (payload.git && payload.git.branch) || (payload.access && payload.access.branch) || null;
const worktree = (payload.git && payload.git.worktreePath) || payload.cwd || null;
const commit = payload.git && payload.git.commit ? payload.git.commit.slice(0,12) : null;
const cmd = payload.requestedCommand || (payload.access && payload.access.command) || null;
const action = payload.declaredAction || (payload.access && payload.access.action) || null;
const envVars = payload.injectedEnv || payload.requestedEnv || (payload.access && payload.access.env) || [];
const findings = payload.policyFindings || [];
const outcome = payload.outcome || null;
let html = `<div class="detail-header">
<span class="badge badge-${event._kind}">${event._kind}</span>
<span class="sev-${sev}">${sev}</span>
<span class="detail-ts">${event.timestamp || ''}</span>
</div>`;
if (agent) html += field('agent', esc(agent));
if (branch) html += field('branch', esc(branch));
if (worktree) html += field('worktree', esc(shortPath(worktree)));
if (cmd) html += field('command', esc(cmd));
if (action) html += field('action', esc(action));
if (commit) html += field('commit', esc(commit));
if (event._project) html += field('project', esc(event._project));
if (outcome) {
const code = typeof outcome === 'object' ? outcome.exitCode ?? outcome.exit_code : outcome;
const ok = code === 0 || code === 'success';
html += field('outcome', `<span class="${ok ? 'outcome-success' : 'outcome-failure'}">${esc(typeof code === 'number' ? `exit ${code}` : String(code))}</span>`);
}
if (envVars.length) html += field('env vars', envVars.map(envChip).join(''));
if (findings.length) {
html += '<div class="field-group"><div class="field-label">findings</div>';
findings.forEach(finding => {
const cls = finding.severity === 'critical' ? 'sev-crit' : finding.severity === 'warning' ? 'sev-warn' : 'sev-info';
html += `<div class="finding"><div class="finding-header"><span class="${cls}">${esc(finding.severity)}</span><span class="finding-msg">${esc(finding.code)}</span></div><div class="finding-msg">${esc(finding.message)}</div></div>`;
});
html += '</div>';
}
document.getElementById('detail-pane').innerHTML = html;
}
function severity(event) {
const payload = event.payload || event;
if (event._kind === 'alert') return 'warn';
const findings = payload.policyFindings || [];
if (findings.some(f => f.severity === 'critical')) return 'crit';
if (findings.some(f => f.severity === 'warning')) return 'warn';
const decision = payload.decision || '';
if (decision === 'deny') return 'warn';
return 'info';
}
async function addProject() {
try {
let picked;
try {
picked = await api('/api/projects/pick-folder', { method: 'POST', body: '{}' });
} catch (error) {
const manual = prompt(error.message + '\\nProject folder path:');
if (!manual) return;
picked = await api('/api/projects/pick-folder', { method: 'POST', body: JSON.stringify({ path: manual }) });
}
const result = await api('/api/projects/setup', { method: 'POST', body: JSON.stringify({ path: picked.path, sourceProject: setupSourceProject() }) });
selectedProject = result.project;
await load();
} catch (error) {
alert(error.data && error.data.fixCommand ? `${error.message}\\n${error.data.fixCommand}` : error.message);
}
}
function setupSourceProject() {
const selected = projects.find(project => project.name === selectedProject);
if (selected && selected.brokerSessionActive) return selected.name;
const active = projects.find(project => project.brokerSessionActive);
return active ? active.name : (selected ? selected.name : null);
}
function currentProject() { return projects.find(p => p.name === selectedProject) || projects[0] || null; }
function statusBadge(ok, label) { return `<span class="badge ${ok ? 'badge-ok' : 'badge-warn'}">${esc(label)}</span>`; }
function field(label, value) { return `<div class="field-group"><div class="field-label">${label}</div><div class="field-value">${value}</div></div>`; }
function envChip(value) { return `<span class="env-chip">${esc(value)}</span>`; }
function shortPath(value) { const s = String(value || ''); return s.length > 60 ? '...' + s.slice(s.length - 57) : s; }
function shorten(value, max) { const s = String(value || ''); return s.length > max ? s.slice(0, max - 1) + '...' : s; }
function esc(value) {
return String(value ?? '')
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
document.getElementById('refresh-btn').addEventListener('click', load);
document.getElementById('add-project-btn').addEventListener('click', addProject);
document.addEventListener('click', event => {
if (!event.target.closest('[data-dropdown]')) closeDropdowns();
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape') closeDropdowns();
});
load().catch(error => {
document.getElementById('detail-pane').innerHTML = `<div class="finding"><div class="finding-header"><span class="sev-crit">error</span></div><div class="finding-msg">${esc(error.message)}</div></div>`;
});
setInterval(load, 5000);
</script>
</body>
</html>