<!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>
<link rel="icon" href="/favicon.png" type="image/png">
<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;
--table-pane-width: 58%;
}
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 {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 700;
font-size: 13px;
color: var(--text);
letter-spacing: 0.04em;
}
.brand-logo {
width: 92px;
height: 26px;
display: block;
flex: 0 0 auto;
object-fit: contain;
}
.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); }
.notification-wrap { position: relative; }
.notification-btn.active {
color: var(--text);
border-color: var(--accent);
background: #1c1a2e;
}
.notification-count {
display: inline-block;
min-width: 16px;
padding: 0 5px;
margin-left: 5px;
border-radius: 99px;
background: var(--accent);
color: #fff;
font-size: 10px;
line-height: 16px;
text-align: center;
}
.notification-panel {
position: absolute;
z-index: 80;
top: calc(100% + 8px);
right: 0;
width: min(560px, calc(100vw - 32px));
max-height: min(680px, calc(100vh - 88px));
overflow: auto;
display: none;
background: #101014;
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 20px 44px rgba(0, 0, 0, 0.48);
padding: 10px;
}
.notification-panel.open { display: block; }
.notification-list { display: grid; gap: 8px; }
.notification-item {
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
padding: 9px 10px;
}
.notification-top {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 7px;
}
.notification-title { color: var(--text); font-weight: 700; }
.notification-meta { margin-left: auto; color: var(--muted); font-size: 10px; }
.notification-message { color: var(--muted); line-height: 1.45; margin-bottom: 8px; }
.notification-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.notification-code {
display: block;
padding: 6px 7px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
overflow-wrap: anywhere;
line-height: 1.4;
}
.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 var(--table-pane-width);
min-width: 280px;
max-width: 74%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.splitter {
position: relative;
flex: 0 0 7px;
cursor: col-resize;
background: var(--bg);
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
.splitter::after {
content: '';
position: absolute;
top: 50%;
left: 2px;
width: 1px;
height: 34px;
transform: translateY(-50%);
background: #2a2a32;
box-shadow: 2px 0 0 #2a2a32;
}
.splitter:hover,
.body.resizing .splitter { border-color: var(--accent); }
.body.resizing {
cursor: col-resize;
user-select: none;
}
.body.resizing * { user-select: none; }
.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;
min-width: 320px;
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-fit, minmax(min(100%, 170px), 1fr));
gap: 6px 10px;
align-items: start;
}
.check-grid label {
min-width: 0;
color: var(--muted);
display: flex;
gap: 5px;
align-items: flex-start;
line-height: 1.35;
overflow-wrap: anywhere;
word-break: break-word;
}
.check-grid input[type="checkbox"] {
flex: 0 0 auto;
margin-top: 1px;
}
.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 { max-width: none; min-height: 320px; }
.splitter { display: none; }
.detail-pane { border-top: 1px solid var(--border); }
}
</style>
</head>
<body>
<header>
<div class="dot"></div>
<div class="logo"><img class="brand-logo" src="/assets/ward-logo-dark.png" alt="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>
<a class="btn" id="team-link" href="/team">team</a>
<a class="btn" id="cloud-link" href="/cloud">cloud</a>
<button class="btn" id="add-project-btn">add project</button>
<div class="notification-wrap" id="notification-wrap">
<button class="btn notification-btn" id="notifications-btn" type="button">notifications<span class="notification-count" id="notification-count">0</span></button>
<div class="notification-panel" id="notification-panel"></div>
</div>
<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="splitter" id="splitter" role="separator" aria-orientation="vertical" aria-label="Resize dashboard panes"></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>
<span id="edit-status"></span>
</footer>
<script>
const token = new URLSearchParams(location.search).get('token') || '';
const currentPath = window.location.pathname;
const logsProject = projectFromLogPath(currentPath);
const teamProject = projectFromTeamPath(currentPath);
let projects = [];
let status = null;
let events = [];
let team = null;
let cloud = null;
let selectedProject = null;
let selectedRow = null;
let selectedEventKey = null;
let pendingProvision = null;
let lastProjectClick = { name: null, at: 0 };
let kindFilter = 'all';
let sevFilter = 'all';
let agentFilter = '';
let notifications = [];
let notificationsOpen = false;
let editState = { dirty: false, refreshDeferred: false };
const paneWidthStorageKey = 'ward.dashboard.tablePaneWidth';
const autoRefreshMs = 5000;
restorePaneWidth();
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 projectFromTeamPath(path) {
const parts = path.split('/').filter(Boolean);
if (parts.length === 3 && parts[0] === 'projects' && parts[2] === 'team') {
return decodeURIComponent(parts[1]);
}
return null;
}
function isLogsPage() { return currentPath === '/logs' || logsProject; }
function isTeamPage() { return currentPath === '/team' || teamProject; }
function isCloudPage() { return currentPath === '/cloud'; }
async function load() {
[projects, status, notifications] = await Promise.all([
api('/api/projects'),
api('/api/dashboard/status'),
api('/api/notifications')
]);
if (!selectedProject && projects.length) {
selectedProject = logsProject || teamProject || (projects.find(p => p.active) || projects[0]).name;
}
if (isLogsPage()) {
await loadEvents(logsProject);
renderLogs();
} else if (isTeamPage()) {
await loadTeam(selectedProject);
renderTeam();
} else if (isCloudPage()) {
await loadCloud();
renderCloud();
} else {
renderOverview();
}
document.getElementById('last-refresh').textContent = new Date().toLocaleTimeString();
renderNotificationBadge();
if (notificationsOpen) renderNotifications();
}
async function loadEvents(project) {
const suffix = project ? `?project=${encodeURIComponent(project)}` : '';
events = await api(`/api/events${suffix}`);
}
async function loadTeam(project) {
team = project ? await api(`/api/teams/projects/${encodeURIComponent(project)}`) : null;
}
async function loadCloud() {
cloud = await api('/api/cloud');
}
function renderOverview() {
document.getElementById('page-title').textContent = 'overview';
document.getElementById('overview-link').href = link('/');
document.getElementById('logs-link').href = link('/logs');
document.getElementById('team-link').href = link('/team');
document.getElementById('cloud-link').href = link('/cloud');
renderProjectFilters();
renderProjectTable();
if (pendingProvision) {
renderProvisionForm();
return;
}
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', () => {
if (!confirmDiscardDrafts()) return;
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>setup</th><th>session</th><th>store</th><th>team</th><th>env</th><th>profiles</th><th>agents</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.setupStatus === 'configured' || project.configStatus === 'ok', project.setupStatus || 'configured')}</td>
<td>${statusBadge(project.brokerSessionActive, project.brokerSessionActive ? 'active' : 'locked')}</td>
<td>${statusBadge(Boolean(project.storeSnapshot), project.storeSnapshot ? (project.storeSnapshot.stale ? 'stale' : 'stored') : 'missing')}</td>
<td>${project.team ? `${project.team.memberCount}/${project.team.policyCount}` : '0/0'}</td>
<td>${project.envNames.length}</td>
<td>${project.profiles.length}</td>
<td>${(project.agentPolicies || []).length}</td>`;
tr.addEventListener('click', () => {
if (projectWasDoubleClicked(project.name)) {
openProjectLogs(project.name);
return;
}
if (!confirmDiscardDrafts()) return;
selectedProject = project.name;
selectedRow = index;
renderOverview();
});
tr.addEventListener('dblclick', () => openProjectLogs(project.name));
tbody.appendChild(tr);
});
document.getElementById('item-count').textContent = `${projects.length} projects`;
document.getElementById('filter-count').textContent = status ? `${status.instances.length} dashboard instance(s)` : '';
}
function projectWasDoubleClicked(projectName) {
const now = Date.now();
const isDouble = lastProjectClick.name === projectName && now - lastProjectClick.at < 450;
lastProjectClick = { name: projectName, at: now };
return isDouble;
}
function openProjectLogs(projectName) {
if (!confirmDiscardDrafts()) return;
window.location.href = link(`/projects/${encodeURIComponent(projectName)}/logs`);
}
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>
<a class="btn detail-ts" href="${link(`/projects/${encodeURIComponent(project.name)}/team`)}">team</a>
</div>
${project.parentProject ? field('workspace', esc(project.parentProject)) : ''}
${project.packageName ? field('package', esc(project.packageName)) : ''}
${field('path', shortPath(project.path))}
${field('vault', shortPath(project.vault))}
${field('config', project.configStatus)}
${field('setup', project.setupStatus || 'configured')}
${field('session', project.brokerSessionActive ? `active until ${esc(formatDateTime(project.brokerSessionExpiresAt))}` : 'locked')}
${field('project store', project.storeSnapshot ? `${project.storeSnapshot.stale ? 'stale' : 'ready'} · ${esc(project.storeSnapshot.updatedAt)}` : 'missing')}
${field('team', project.team ? `${project.team.memberCount} member(s), ${project.team.policyCount} policy(s), ${project.team.agentCount} agent(s)` : 'missing')}
<div class="inline-form">
<button class="btn" data-lock-project="${esc(project.name)}">lock project</button>
<button class="btn" data-lock-all>lock all</button>
<button class="btn" data-copy-unlock="${esc(project.name)}">copy unlock</button>
<button class="btn danger" data-remove-project="${esc(project.name)}">remove project</button>
</div>
${project.setupAvailable ? `<button class="btn primary" data-setup-detected="${esc(project.name)}">setup app</button>` : ''}
${field('vault keys', project.vaultKeysVerified ? 'verified' : 'unavailable')}
${field('env names', project.envNames.map(envChip).join('') || '<span class="sev-info">none</span>')}
${field('agent policies', (project.agentPolicies || []).map(policy => `<span class="env-chip">${esc(policy.agent)}</span>`).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);
bindDetectedProjectSetup(project);
bindProjectControls(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-dirty-scope 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'));
bindDirtyTracking(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;
if (!isValidEnvName(name)) {
alert(`Invalid env name: ${name}`);
return;
}
if ([...editor.querySelectorAll('[data-env]')].some(existing => existing.dataset.env === name)) {
alert(`${name} is already in this profile editor`);
return;
}
const grid = editor.querySelector('.check-grid');
grid.insertAdjacentHTML('beforeend', `<label><input type="checkbox" data-env="${esc(name)}" checked>${esc(name)}</label>`);
input.value = '';
markEditorDirty();
});
});
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;
clearEditorDirty();
renderOverview();
}
async function deleteProfile(projectName, profileName) {
if (!confirmDiscardDrafts()) return;
const updated = await api(`/api/projects/${encodeURIComponent(projectName)}/profiles/${encodeURIComponent(profileName)}`, { method: 'DELETE' });
projects = projects.map(project => project.name === projectName ? updated : project);
clearEditorDirty();
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 renderTeam() {
const project = currentProject();
document.getElementById('page-title').textContent = project ? `${project.name} team` : 'team';
document.getElementById('overview-link').href = link('/');
document.getElementById('logs-link').href = link(project ? `/projects/${encodeURIComponent(project.name)}/logs` : '/logs');
document.getElementById('team-link').href = link('/team');
document.getElementById('cloud-link').href = link('/cloud');
renderTeamFilters();
renderTeamTable();
renderTeamDetail(project);
}
function renderTeamFilters() {
const filters = document.getElementById('filters');
filters.innerHTML = '<span class="filter-label">project</span>' + projects.map(p => (
`<button class="chip ${p.name === selectedProject ? 'active' : ''}" data-team-project="${esc(p.name)}">${esc(p.name)}</button>`
)).join('');
filters.querySelectorAll('[data-team-project]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirmDiscardDrafts()) return;
selectedProject = btn.dataset.teamProject;
await loadTeam(selectedProject);
renderTeam();
});
});
}
function renderTeamTable() {
const table = document.getElementById('main-table');
table.innerHTML = `<thead><tr>
<th>project</th><th>members</th><th>policies</th><th>agents</th><th>session</th>
</tr></thead><tbody></tbody>`;
const tbody = table.querySelector('tbody');
projects.forEach(project => {
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>${project.team ? project.team.memberCount : 0}</td>
<td>${project.team ? project.team.policyCount : 0}</td>
<td>${project.team ? project.team.agentCount : 0}</td>
<td>${statusBadge(project.brokerSessionActive, project.brokerSessionActive ? 'active' : 'locked')}</td>`;
tr.addEventListener('click', async () => {
if (!confirmDiscardDrafts()) return;
selectedProject = project.name;
await loadTeam(selectedProject);
renderTeam();
});
tbody.appendChild(tr);
});
document.getElementById('item-count').textContent = `${projects.length} team project(s)`;
document.getElementById('filter-count').textContent = team ? `${Object.keys(team.members || {}).length} members · ${Object.keys(team.policies || {}).length} policies` : '';
}
function renderTeamDetail(project) {
const pane = document.getElementById('detail-pane');
if (!project || !team) {
pane.innerHTML = '<div class="detail-empty">select a project</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)} team</span>
<a class="btn detail-ts" href="${link(`/projects/${encodeURIComponent(project.name)}/logs`)}">logs</a>
</div>
${field('policy source', 'local team simulator')}
${field('members', String(Object.keys(team.members || {}).length))}
${field('policies', String(Object.keys(team.policies || {}).length))}
<div class="field-group">
<div class="field-label">members</div>
<div id="team-members"></div>
</div>
<div class="field-group">
<div class="field-label">new member</div>
${memberEditor(null)}
</div>
<div class="field-group">
<div class="field-label">agent env policies</div>
<div id="team-policies"></div>
</div>
<div class="field-group">
<div class="field-label">new policy</div>
${teamPolicyEditor(project, null)}
</div>`;
renderMemberCards(project);
renderTeamPolicyCards(project);
bindTeamEditors(project);
}
function renderMemberCards(project) {
const host = document.getElementById('team-members');
const members = Object.values(team.members || {}).sort((a, b) => a.id.localeCompare(b.id));
host.innerHTML = members.map(member => `
<div class="policy-card">
<div class="policy-actions">
<span><span class="badge badge-neutral">${esc(member.id)}</span> ${esc(member.role)}</span>
<button class="btn danger" data-delete-member="${esc(member.id)}">delete</button>
</div>
${memberEditor(member)}
</div>
`).join('') || '<div class="finding-msg">no members</div>';
}
function memberEditor(member) {
return `
<div class="form-grid" data-dirty-scope data-member-editor="${esc(member ? member.id : 'create')}">
<input data-field="id" placeholder="member id or email" value="${esc(member ? member.id : '')}">
${dropdownMarkup('role', member ? member.role : 'developer', ['owner','admin','developer','viewer'])}
<input class="full" data-field="name" placeholder="display name" value="${esc(member ? member.name : '')}">
<input class="full" data-field="agents" placeholder="agent identities, comma separated" value="${esc(member ? (member.agents || []).join(', ') : '')}">
<div class="full inline-form">
<button class="btn primary" data-save-member="${esc(member ? member.id : '')}">${member ? 'save' : 'create'}</button>
</div>
</div>`;
}
function renderTeamPolicyCards(project) {
const host = document.getElementById('team-policies');
const policies = Object.values(team.policies || {}).sort((a, b) => a.name.localeCompare(b.name));
host.innerHTML = policies.map(policy => `
<div class="policy-card">
<div class="policy-actions">
<span><span class="badge badge-neutral">${esc(policy.name)}</span> ${(policy.agents || []).map(envChip).join('')}</span>
<button class="btn danger" data-delete-team-policy="${esc(policy.name)}">delete</button>
</div>
${teamPolicyEditor(project, policy)}
</div>
`).join('') || '<div class="finding-msg">no team policies</div>';
}
function teamPolicyEditor(project, policy) {
const profiles = new Set(policy ? policy.profiles || [] : []);
const env = new Set(policy ? policy.env || [] : []);
const members = Object.values(team.members || {}).sort((a, b) => a.id.localeCompare(b.id));
return `
<div class="form-grid" data-dirty-scope data-team-policy-editor="${esc(policy ? policy.name : 'create')}">
<input data-field="name" placeholder="policy name" value="${esc(policy ? policy.name : '')}">
${dropdownMarkup('memberId', policy && policy.memberId ? policy.memberId : '', [{ value: '', label: 'no member' }].concat(members.map(member => ({ value: member.id, label: member.id }))))}
<input class="full" data-field="agents" placeholder="agent identities, comma separated" value="${esc(policy ? (policy.agents || []).join(', ') : '')}">
<div class="field-label full">profiles</div>
<div class="full check-grid">
${project.profiles.map(profile => `<label><input type="checkbox" data-policy-profile="${esc(profile.name)}" ${profiles.has(profile.name) ? 'checked' : ''}>${esc(profile.name)}</label>`).join('') || '<span class="sev-info">none</span>'}
</div>
<div class="field-label full">env names</div>
<div class="full check-grid">
${project.envNames.map(name => `<label><input type="checkbox" data-policy-env="${esc(name)}" ${env.has(name) ? 'checked' : ''}>${esc(name)}</label>`).join('') || '<span class="sev-info">none</span>'}
</div>
<div class="full inline-form">
<button class="btn primary" data-save-team-policy="${esc(policy ? policy.name : '')}">${policy ? 'save' : 'create'}</button>
</div>
</div>`;
}
function bindTeamEditors(project) {
bindDropdowns(document.getElementById('detail-pane'));
bindDirtyTracking(document.getElementById('detail-pane'));
document.querySelectorAll('[data-save-member]').forEach(btn => {
btn.addEventListener('click', () => saveTeamMember(project.name, btn.dataset.saveMember, btn.closest('[data-member-editor]')));
});
document.querySelectorAll('[data-delete-member]').forEach(btn => {
btn.addEventListener('click', () => deleteTeamMember(project.name, btn.dataset.deleteMember));
});
document.querySelectorAll('[data-save-team-policy]').forEach(btn => {
btn.addEventListener('click', () => saveTeamPolicy(project.name, btn.dataset.saveTeamPolicy, btn.closest('[data-team-policy-editor]')));
});
document.querySelectorAll('[data-delete-team-policy]').forEach(btn => {
btn.addEventListener('click', () => deleteTeamPolicy(project.name, btn.dataset.deleteTeamPolicy));
});
}
async function saveTeamMember(projectName, oldId, editor) {
const body = {
id: editor.querySelector('[data-field="id"]').value.trim(),
name: editor.querySelector('[data-field="name"]').value.trim(),
role: editor.querySelector('[data-field="role"]').dataset.value,
agents: splitList(editor.querySelector('[data-field="agents"]').value)
};
const path = oldId
? `/api/teams/projects/${encodeURIComponent(projectName)}/members/${encodeURIComponent(oldId)}`
: `/api/teams/projects/${encodeURIComponent(projectName)}/members`;
const method = oldId ? 'PATCH' : 'POST';
await mutateTeam(path, method, body, projectName);
}
async function deleteTeamMember(projectName, memberId) {
if (!confirmDiscardDrafts()) return;
await mutateTeam(`/api/teams/projects/${encodeURIComponent(projectName)}/members/${encodeURIComponent(memberId)}`, 'DELETE', null, projectName);
}
async function saveTeamPolicy(projectName, oldName, editor) {
const memberId = editor.querySelector('[data-field="memberId"]').dataset.value;
const body = {
name: editor.querySelector('[data-field="name"]').value.trim(),
memberId: memberId || null,
agents: splitList(editor.querySelector('[data-field="agents"]').value),
profiles: [...editor.querySelectorAll('[data-policy-profile]:checked')].map(input => input.dataset.policyProfile),
env: [...editor.querySelectorAll('[data-policy-env]:checked')].map(input => input.dataset.policyEnv)
};
const path = oldName
? `/api/teams/projects/${encodeURIComponent(projectName)}/policies/${encodeURIComponent(oldName)}`
: `/api/teams/projects/${encodeURIComponent(projectName)}/policies`;
const method = oldName ? 'PATCH' : 'POST';
await mutateTeam(path, method, body, projectName);
}
async function deleteTeamPolicy(projectName, policyName) {
if (!confirmDiscardDrafts()) return;
await mutateTeam(`/api/teams/projects/${encodeURIComponent(projectName)}/policies/${encodeURIComponent(policyName)}`, 'DELETE', null, projectName);
}
async function mutateTeam(path, method, body, projectName) {
try {
team = await api(path, { method, body: body ? JSON.stringify(body) : undefined });
projects = await api('/api/projects');
selectedProject = projectName;
clearEditorDirty();
renderTeam();
} catch (error) {
alert(error.data && error.data.fixCommand ? `${error.message}\\n${error.data.fixCommand}` : error.message);
}
}
function renderCloud() {
const status = (cloud && cloud.status) || {};
const auth = status.auth || null;
const teams = (cloud && cloud.teams) || [];
const catalog = (cloud && cloud.catalog) || null;
const environments = (catalog && catalog.environments) || [];
const audit = (cloud && cloud.audit) || [];
document.getElementById('page-title').textContent = 'cloud';
document.getElementById('overview-link').href = link('/');
document.getElementById('logs-link').href = link('/logs');
document.getElementById('team-link').href = link('/team');
document.getElementById('cloud-link').href = link('/cloud');
document.getElementById('filters').innerHTML = `
<span class="filter-label">local cloud</span>
<span class="chip active">${status.running ? 'running' : 'stopped'}</span>
<span class="chip">${auth ? esc(auth.accountEmail) : 'not logged in'}</span>
<span class="chip">${status.dbExists ? 'database ready' : 'no database'}</span>`;
renderCloudTable(teams, environments);
renderCloudDetail(status, teams, environments, audit);
}
function renderCloudTable(teams, environments) {
const table = document.getElementById('main-table');
table.innerHTML = `<thead><tr>
<th>kind</th><th>name</th><th>owner / project</th><th>access</th><th>updated</th>
</tr></thead><tbody></tbody>`;
const tbody = table.querySelector('tbody');
teams.forEach(team => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><span class="badge badge-session">team</span></td>
<td>${esc(team.name)}</td>
<td>${esc(team.ownerEmail)}</td>
<td>${statusBadge(true, 'managed')}</td>
<td>${esc((team.createdAt || '').slice(0, 19))}</td>`;
tbody.appendChild(tr);
});
environments.forEach(env => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><span class="badge badge-request">environment</span></td>
<td>${esc(env.teamName)} / ${esc(env.projectName)} / ${esc(env.name)}</td>
<td>${esc(env.projectName)}</td>
<td>${statusBadge(env.keyWrapAvailable && !env.rewrapRequired, env.rewrapRequired ? 'rewrap required' : (env.keyWrapAvailable ? 'available' : 'locked'))}</td>
<td>${esc((env.updatedAt || '').slice(0, 19))}</td>`;
tbody.appendChild(tr);
});
document.getElementById('item-count').textContent = `${teams.length} teams`;
document.getElementById('filter-count').textContent = `${environments.length} environments`;
}
function renderCloudDetail(status, teams, environments, audit) {
const instance = status.instance || null;
const auth = status.auth || null;
const backendLabel = status.running && instance ? `${esc(instance.url)} · pid ${esc(instance.pid)}` : `start with ${esc('ward cloud dev start')}`;
const loginUrl = instance ? instance.url : 'http://127.0.0.1:8787';
const pane = document.getElementById('detail-pane');
pane.innerHTML = `
<div class="detail-header">
<span class="badge ${status.running ? 'badge-ok' : 'badge-warn'}">${status.running ? 'running' : 'stopped'}</span>
<span class="detail-title">local cloud dev</span>
</div>
${field('backend', backendLabel)}
${field('database', shortPath(status.db || ''))}
${field('account', auth ? `${esc(auth.accountEmail)} · ${esc(auth.deviceName)}` : `login with ${esc('ward auth login --cloud-url ')}${esc(loginUrl)}`)}
${field('teams', String(teams.length))}
${field('accessible environments', String(environments.length))}
<div class="field-group">
<div class="field-label">environments</div>
<div class="field-value">${environments.map(env => `
<div class="policy-card">
<div class="policy-actions">
<span><span class="badge badge-neutral">${esc(env.name)}</span> ${esc(env.teamName)} / ${esc(env.projectName)}</span>
${statusBadge(env.keyWrapAvailable && !env.rewrapRequired, env.rewrapRequired ? 'rewrap required' : (env.keyWrapAvailable ? 'ready' : 'locked'))}
</div>
<div>${(env.envNames || []).map(envChip).join('') || '<span class="sev-info">no env names</span>'}</div>
<div class="finding-msg">${(env.profileNames || []).length} profile(s), ${(env.agentNames || []).length} agent policy name(s)</div>
</div>`).join('') || '<span class="sev-info">none</span>'}</div>
</div>
<div class="field-group">
<div class="field-label">recent cloud audit</div>
<div class="field-value">${audit.slice(0, 8).map(event => `
<div class="policy-card">
<div class="policy-actions">
<span><span class="badge badge-approval">${esc(event.payload && event.payload.type ? event.payload.type : 'audit')}</span> ${esc(event.actorEmail || '-')}</span>
<span class="detail-ts">${esc((event.createdAt || '').slice(0, 19))}</span>
</div>
<pre>${esc(JSON.stringify(event.payload || {}, null, 2))}</pre>
</div>`).join('') || '<span class="sev-info">no synced audit events</span>'}</div>
</div>`;
}
function splitList(value) {
return String(value || '').split(',').map(item => item.trim()).filter(Boolean);
}
function isValidEnvName(name) {
return /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(name);
}
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');
document.getElementById('team-link').href = link(logsProject ? `/projects/${encodeURIComponent(logsProject)}/team` : '/team');
document.getElementById('cloud-link').href = link('/cloud');
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 = eventAgent(payload, '');
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 = eventAgent(payload, '-');
const cmd = eventCommand(payload, '-');
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 eventAgent(payload, '');
}).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 eventSnapshot(payload) {
return payload.requestSnapshot || null;
}
function eventAgent(payload, fallback = null) {
const snapshot = eventSnapshot(payload);
return payload.agent || (payload.access && payload.access.agent) || (snapshot && snapshot.agent) || fallback;
}
function eventBranch(payload, fallback = null) {
const snapshot = eventSnapshot(payload);
return payload.branch || (payload.git && payload.git.branch) || (payload.access && payload.access.branch) || (snapshot && snapshot.branch) || (snapshot && snapshot.git && snapshot.git.branch) || fallback;
}
function eventWorktree(payload, fallback = null) {
const snapshot = eventSnapshot(payload);
return (payload.git && payload.git.worktreePath) || payload.cwd || (snapshot && snapshot.git && snapshot.git.worktreePath) || fallback;
}
function eventCommit(payload, fallback = null) {
const snapshot = eventSnapshot(payload);
return (payload.git && payload.git.commit) || (snapshot && snapshot.git && snapshot.git.commit) || fallback;
}
function eventCommand(payload, fallback = null) {
const snapshot = eventSnapshot(payload);
return payload.requestedCommand || (payload.access && payload.access.command) || (snapshot && snapshot.command) || payload.declaredAction || (payload.access && payload.access.action) || (snapshot && snapshot.action) || payload.eventType || fallback;
}
function eventAction(payload, fallback = null) {
const snapshot = eventSnapshot(payload);
return payload.declaredAction || (payload.access && payload.access.action) || (snapshot && snapshot.action) || fallback;
}
function eventEnvVars(payload) {
const snapshot = eventSnapshot(payload);
return payload.injectedEnv || payload.requestedEnv || (payload.access && payload.access.env) || (snapshot && (snapshot.env || snapshot.requestedEnv)) || [];
}
function eventFindings(payload) {
const snapshot = eventSnapshot(payload);
return payload.policyFindings || (payload.policy && payload.policy.findings) || (snapshot && snapshot.policyFindings) || [];
}
function relatedAuditEvents(event) {
const payload = event.payload || event;
const correlationId = payload.correlationId || null;
const requestId = payload.requestId || null;
if (!correlationId && !requestId) return [];
return events.filter(candidate => {
if (candidate === event) return false;
const candidatePayload = candidate.payload || candidate;
return (correlationId && candidatePayload.correlationId === correlationId)
|| (requestId && (candidatePayload.requestId === requestId || candidatePayload.grantOriginRequestId === requestId));
});
}
function requestOutcome(event) {
if (event._kind !== 'request') return null;
const payload = event.payload || event;
const related = relatedAuditEvents(event);
if (related.some(item => {
const relatedPayload = item.payload || item;
return item._kind === 'execution' && relatedPayload.eventType === 'execution.finished';
})) return 'executed';
const approval = related.find(item => item._kind === 'approval');
if (approval) {
const decision = (approval.payload || approval).decision;
if (decision && typeof decision === 'object') return decision.approved ? 'approved' : 'denied';
}
if (payload.expiresAt && Date.parse(payload.expiresAt) < Date.now()) return 'expired';
return 'pending';
}
function renderEventDetail(event) {
const payload = event.payload || event;
const sev = severity(event);
const agent = eventAgent(payload, null);
const branch = eventBranch(payload, null);
const worktree = eventWorktree(payload, null);
const commitValue = eventCommit(payload, null);
const commit = commitValue ? commitValue.slice(0,12) : null;
const cmd = eventCommand(payload, null);
const action = eventAction(payload, null);
const envVars = eventEnvVars(payload);
const findings = eventFindings(payload);
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));
const outcomeLabel = requestOutcome(event);
if (outcomeLabel) html += field('request status', esc(outcomeLabel));
if (payload.correlationId) html += field('correlation', esc(payload.correlationId));
if (payload.requestId) html += field('request', esc(payload.requestId));
if (payload.expiresAt) html += field('expires', esc(payload.expiresAt));
if (payload.approvalChannel) html += field('approved by', esc(payload.approvalChannel));
if (payload.approvalScope) html += field('approval scope', esc(payload.approvalScope));
if (payload.approvalSource) html += field('approval source', esc(payload.approvalSource));
if (payload.grantId) html += field('grant', esc(payload.grantId));
if (payload.grantOriginRequestId) html += field('grant request', esc(payload.grantOriginRequestId));
if (payload.approvalReceiptHash) html += field('receipt', esc(shorten(payload.approvalReceiptHash, 24)));
if (payload.decision && typeof payload.decision === 'object') {
html += field('decision', `<span class="${payload.decision.approved ? 'outcome-success' : 'outcome-failure'}">${payload.decision.approved ? 'approved' : 'denied'}</span>`);
if (payload.decision.scope) html += field('decision scope', esc(payload.decision.scope));
if (payload.decision.source) html += field('decision source', esc(payload.decision.source));
}
if (payload.criticalConfirmation) html += field('critical confirmation', 'true');
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 = eventFindings(payload);
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' || (decision && typeof decision === 'object' && decision.approved === false)) return 'warn';
return 'info';
}
async function addProject() {
if (!confirmDiscardDrafts()) return;
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 sourceProject = setupSourceProject();
if (!sourceProject) throw new Error('no source project is available');
pendingProvision = {
path: picked.path,
sourceProject,
project: defaultProjectName(picked.path)
};
renderProvisionForm();
} catch (error) {
alert(error.data && error.data.fixCommand ? `${error.message}\\n${error.data.fixCommand}` : error.message);
}
}
function renderProvisionForm() {
const source = projects.find(project => project.name === pendingProvision.sourceProject);
const pane = document.getElementById('detail-pane');
if (!source) {
pane.innerHTML = '<div class="detail-empty">source project not found</div>';
return;
}
selectedProject = source.name;
const profileNames = source.profiles.map(profile => profile.name);
pane.innerHTML = `
<div class="detail-header">
<span class="badge ${source.brokerSessionActive ? 'badge-ok' : 'badge-warn'}">${source.brokerSessionActive ? 'source active' : 'source locked'}</span>
<span class="detail-title">create project</span>
<button class="btn detail-ts" data-provision-cancel>cancel</button>
</div>
${field('source', esc(source.name))}
${field('target', shortPath(pendingProvision.path))}
<div class="form-grid" data-dirty-scope data-provision-editor>
<input class="full" data-field="project" placeholder="project name" value="${esc(pendingProvision.project)}">
<div class="field-label full">env names</div>
<div class="full check-grid">
${source.envNames.map(name => `<label><input type="checkbox" data-provision-env="${esc(name)}" checked>${esc(name)}</label>`).join('') || '<span class="sev-info">none</span>'}
</div>
<div class="field-label full">profiles</div>
<div class="full check-grid">
${profileNames.map(name => `<label><input type="checkbox" data-provision-profile="${esc(name)}" checked>${esc(name)}</label>`).join('') || '<span class="sev-info">none</span>'}
</div>
<input class="full" data-field="agents" placeholder="agent allowlist, comma separated">
<div class="field-label full">initial team members</div>
<input data-field="members" placeholder="member ids/emails, comma separated">
${dropdownMarkup('memberRole', 'developer', ['owner','admin','developer','viewer'])}
<div class="full inline-form">
<button class="btn primary" data-provision-create>create</button>
</div>
</div>`;
bindDropdowns(pane);
bindDirtyTracking(pane);
pane.querySelector('[data-provision-cancel]').addEventListener('click', () => {
if (!confirmDiscardDrafts()) return;
pendingProvision = null;
clearEditorDirty();
renderOverview();
});
pane.querySelector('[data-provision-create]').addEventListener('click', createProvisionProject);
}
async function createProvisionProject() {
const editor = document.querySelector('[data-provision-editor]');
const body = {
sourceProject: pendingProvision.sourceProject,
path: pendingProvision.path,
project: editor.querySelector('[data-field="project"]').value.trim(),
env: [...editor.querySelectorAll('[data-provision-env]:checked')].map(input => input.dataset.provisionEnv),
profiles: [...editor.querySelectorAll('[data-provision-profile]:checked')].map(input => input.dataset.provisionProfile),
agents: splitList(editor.querySelector('[data-field="agents"]').value),
members: splitList(editor.querySelector('[data-field="members"]').value).map(id => ({
id,
name: id,
role: editor.querySelector('[data-field="memberRole"]').dataset.value,
agents: []
}))
};
try {
const result = await api('/api/projects/provision', { method: 'POST', body: JSON.stringify(body) });
pendingProvision = null;
selectedProject = result.project;
clearEditorDirty();
await load();
} catch (error) {
alert(error.data && error.data.fixCommand ? `${error.message}\\n${error.data.fixCommand}` : error.message);
}
}
function bindDetectedProjectSetup(project) {
const btn = document.querySelector('[data-setup-detected]');
if (!btn) return;
btn.addEventListener('click', async () => {
if (!confirmDiscardDrafts()) return;
try {
btn.disabled = true;
btn.textContent = 'setting up';
await api('/api/projects/setup', {
method: 'POST',
body: JSON.stringify({ path: project.path, project: project.name, sourceProject: setupSourceProject() })
});
await load();
} catch (error) {
alert(error.message);
btn.disabled = false;
btn.textContent = 'setup app';
}
});
}
function bindProjectControls(project) {
document.querySelectorAll('[data-lock-project]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm(`Lock Ward session for ${project.name}?`)) return;
try {
await api(`/api/projects/${encodeURIComponent(project.name)}/lock`, { method: 'POST', body: '{}' });
await reloadAfterExternalMutation();
} catch (error) {
alert(error.data && error.data.fixCommand ? `${error.message}\n${error.data.fixCommand}` : error.message);
}
});
});
document.querySelectorAll('[data-lock-all]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Lock all Ward sessions?')) return;
try {
await api('/api/sessions/lock-all', { method: 'POST', body: '{}' });
await reloadAfterExternalMutation();
} catch (error) {
alert(error.message);
}
});
});
document.querySelectorAll('[data-copy-unlock]').forEach(btn => {
btn.addEventListener('click', async () => {
const command = unlockCommand(project);
try {
await navigator.clipboard.writeText(command);
document.getElementById('edit-status').textContent = 'unlock command copied';
} catch (_) {
window.prompt('Copy unlock command', command);
}
});
});
document.querySelectorAll('[data-remove-project]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirmDiscardDrafts()) return;
const typed = window.prompt(`Remove ${project.name} from Ward? Type the project name to confirm.`);
if (typed !== project.name) return;
try {
await api(`/api/projects/${encodeURIComponent(project.name)}/remove`, {
method: 'POST',
body: JSON.stringify({ confirm: project.name, exportPath: '.env.export', restoreEnv: false })
});
selectedProject = null;
clearEditorDirty();
await load();
} catch (error) {
alert(error.data && error.data.fixCommand ? `${error.message}\n${error.data.fixCommand}` : error.message);
}
});
});
}
function unlockCommand(project) {
if (project.packageName || project.parentProject) {
const app = project.packageName || project.name.split(':').pop();
return `ward unlock --app ${shellQuote(app)} --ttl 8h`;
}
return `ward unlock --project ${shellQuote(project.name)} --ttl 8h`;
}
function shellQuote(value) {
if (/^[A-Za-z0-9_:\-./]+$/.test(value)) return value;
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function formatDateTime(value) {
if (!value) return 'unknown';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleTimeString();
}
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 renderNotificationBadge() {
const count = notifications.length;
const badge = document.getElementById('notification-count');
const btn = document.getElementById('notifications-btn');
badge.textContent = String(count);
badge.style.display = count ? 'inline-block' : 'none';
btn.classList.toggle('active', notificationsOpen || count > 0);
}
function toggleNotifications(force) {
notificationsOpen = typeof force === 'boolean' ? force : !notificationsOpen;
document.getElementById('notification-panel').classList.toggle('open', notificationsOpen);
renderNotificationBadge();
if (notificationsOpen) renderNotifications();
}
function renderNotifications() {
const panel = document.getElementById('notification-panel');
if (!notifications.length) {
panel.innerHTML = '<div class="detail-empty">no pending notifications</div>';
return;
}
panel.innerHTML = `<div class="notification-list">${notifications.map(notificationMarkup).join('')}</div>`;
panel.querySelectorAll('[data-approve-scope]').forEach(button => {
button.addEventListener('click', async () => {
await approveNotification(button.dataset.notificationId, button.dataset.approveScope);
});
});
panel.querySelectorAll('[data-deny-notification]').forEach(button => {
button.addEventListener('click', async () => {
await denyNotification(button.dataset.notificationId, button.dataset.notificationKind);
});
});
panel.querySelectorAll('[data-dismiss-notification]').forEach(button => {
button.addEventListener('click', async () => {
await dismissNotification(button.dataset.notificationId);
});
});
panel.querySelectorAll('[data-copy-command]').forEach(button => {
button.addEventListener('click', async () => {
const command = button.dataset.copyCommand;
try { await navigator.clipboard.writeText(command); button.textContent = 'copied'; }
catch (_) { prompt('Copy command:', command); }
});
});
}
function notificationMarkup(notification) {
const riskClass = notification.risk === 'critical' ? 'sev-crit' : notification.risk === 'warning' ? 'sev-warn' : 'sev-info';
const env = (notification.env || []).length ? field('env', notification.env.map(envChip).join('')) : '';
const command = notification.command ? field('command', `<span class="notification-code">${esc(notification.command)}</span>`) : '';
const worktree = notification.worktree ? field('worktree', esc(shortPath(notification.worktree))) : '';
const message = notification.message ? `<div class="notification-message">${esc(notification.message)}</div>` : '';
const findings = (notification.findings || []).length ? `<div class="field-group"><div class="field-label">findings</div>${notification.findings.map(findingMarkup).join('')}</div>` : '';
const fallback = notification.fixCommand || notification.denyCommand || firstApproveCommand(notification);
const fallbackMarkup = fallback ? `${field('fallback command', `<span class="notification-code">${esc(fallback)}</span>`)}<button class="btn" data-copy-command="${esc(fallback)}">copy command</button>` : '';
return `<div class="notification-item">
<div class="notification-top">
<span class="badge ${notification.kind === 'worktreeApproval' ? 'badge-alert' : 'badge-request'}">${esc(notification.kind)}</span>
<span class="notification-title">${esc(notification.title)}</span>
<span class="${riskClass}">${esc(notification.risk)}</span>
<span class="notification-meta">${esc(timeAgo(notification.createdAt))}</span>
</div>
${field('project', esc(notification.project))}
${notification.agent ? field('agent', esc(notification.agent)) : ''}
${command}
${worktree}
${env}
${message}
${findings}
${fallbackMarkup}
${notificationActions(notification)}
</div>`;
}
function findingMarkup(finding) {
const cls = finding.severity === 'critical' ? 'sev-crit' : finding.severity === 'warning' ? 'sev-warn' : 'sev-info';
return `<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>`;
}
function notificationActions(notification) {
const id = esc(notification.id);
let html = '<div class="notification-actions">';
if (notification.canApprove) {
if (notification.kind === 'worktreeApproval') {
html += `<button class="btn primary" data-approve-scope="worktree" data-notification-id="${id}">approve worktree</button>`;
} else {
(notification.approvalOptions || []).filter(scope => scope !== 'deny').forEach(scope => {
html += `<button class="btn primary" data-approve-scope="${esc(scope)}" data-notification-id="${id}">approve ${esc(scope)}</button>`;
});
}
}
if (notification.canDeny) {
html += `<button class="btn danger" data-deny-notification="${id}" data-notification-kind="${esc(notification.kind)}">deny</button>`;
}
if (notification.canDismiss) {
html += `<button class="btn" data-dismiss-notification="${id}">dismiss</button>`;
}
html += '</div>';
return html;
}
function firstApproveCommand(notification) {
const command = (notification.approveCommands || [])[0];
return command ? command.command : null;
}
async function approveNotification(id, scope) {
const notification = notifications.find(item => item.id === id);
if (!notification) return;
try {
if (notification.kind === 'worktreeApproval' || scope === 'worktree') {
await api(`/api/worktrees/${encodeURIComponent(id)}/approve`, { method: 'POST', body: '{}' });
} else {
await api(`/api/approvals/${encodeURIComponent(id)}/approve`, {
method: 'POST',
body: JSON.stringify({ scope, confirmCritical: notification.kind === 'criticalApproval' })
});
}
await reloadAfterExternalMutation();
} catch (error) {
alert(error.data && error.data.fixCommand ? `${error.message}\\n${error.data.fixCommand}` : error.message);
}
}
async function denyNotification(id, kind) {
try {
const path = kind === 'worktreeApproval' ? `/api/worktrees/${encodeURIComponent(id)}/deny` : `/api/approvals/${encodeURIComponent(id)}/deny`;
await api(path, { method: 'POST', body: '{}' });
await reloadAfterExternalMutation();
} catch (error) {
alert(error.message);
}
}
async function dismissNotification(id) {
try {
await api(`/api/notifications/${encodeURIComponent(id)}/dismiss`, { method: 'POST', body: '{}' });
await reloadAfterExternalMutation();
} catch (error) {
alert(error.message);
}
}
function timeAgo(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
return `${Math.round(minutes / 60)}h`;
}
function bindDirtyTracking(root = document) {
root.querySelectorAll('[data-dirty-scope]').forEach(scope => {
if (scope.dataset.dirtyBound === 'true') return;
scope.dataset.dirtyBound = 'true';
scope.addEventListener('input', markEditorDirty);
scope.addEventListener('change', markEditorDirty);
scope.addEventListener('ward:dropdown-change', markEditorDirty);
scope.addEventListener('focusin', updateEditStatus);
scope.addEventListener('focusout', () => setTimeout(updateEditStatus, 0));
});
}
function markEditorDirty() {
editState.dirty = true;
editState.refreshDeferred = false;
updateEditStatus();
}
function clearEditorDirty() {
editState.dirty = false;
editState.refreshDeferred = false;
updateEditStatus();
}
function hasEditorFocus() {
const active = document.activeElement;
return Boolean(active && active.closest && active.closest('[data-dirty-scope]'));
}
function shouldPauseAutoRefresh() {
return editState.dirty || hasEditorFocus();
}
function confirmDiscardDrafts() {
if (!editState.dirty) return true;
if (!window.confirm('Discard unsaved changes?')) return false;
clearEditorDirty();
return true;
}
async function guardedLoad(options = {}) {
const manual = Boolean(options.manual);
if (manual) {
if (!confirmDiscardDrafts()) return;
} else if (shouldPauseAutoRefresh()) {
editState.refreshDeferred = true;
updateEditStatus();
return;
}
editState.refreshDeferred = false;
updateEditStatus();
await load();
}
async function reloadAfterExternalMutation() {
if (shouldPauseAutoRefresh()) {
await refreshNotificationsOnly();
editState.refreshDeferred = true;
updateEditStatus();
return;
}
await load();
}
async function refreshNotificationsOnly() {
notifications = await api('/api/notifications');
renderNotificationBadge();
if (notificationsOpen) renderNotifications();
}
function updateEditStatus() {
const status = document.getElementById('edit-status');
if (!status) return;
if (editState.dirty) {
status.textContent = 'unsaved changes';
} else if (editState.refreshDeferred || hasEditorFocus()) {
status.textContent = 'refresh paused while editing';
} else {
status.textContent = '';
}
}
function startAutoRefresh() {
window.setInterval(() => {
guardedLoad().catch(error => console.warn('dashboard refresh failed', error));
}, autoRefreshMs);
}
function bindNotificationStream() {
if (!window.EventSource) return;
const source = new EventSource(withToken('/api/notifications/stream'));
source.addEventListener('notifications', event => {
try {
const data = JSON.parse(event.data);
notifications = data.notifications || [];
renderNotificationBadge();
if (notificationsOpen) renderNotifications();
} catch (_) {}
});
source.onerror = () => {
source.close();
setTimeout(bindNotificationStream, 2500);
};
}
function defaultProjectName(path) {
const parts = String(path || '').split('/').filter(Boolean);
return parts[parts.length - 1] || 'ward-project';
}
function bindSplitter() {
const splitter = document.getElementById('splitter');
const body = document.querySelector('.body');
if (!splitter || !body) return;
splitter.addEventListener('pointerdown', event => {
if (window.matchMedia('(max-width: 900px)').matches) return;
event.preventDefault();
splitter.setPointerCapture(event.pointerId);
body.classList.add('resizing');
const move = moveEvent => {
const rect = body.getBoundingClientRect();
const percent = ((moveEvent.clientX - rect.left) / rect.width) * 100;
setPaneWidth(percent, true);
};
const stop = stopEvent => {
body.classList.remove('resizing');
splitter.releasePointerCapture(stopEvent.pointerId);
splitter.removeEventListener('pointermove', move);
};
splitter.addEventListener('pointermove', move);
splitter.addEventListener('pointerup', stop, { once: true });
splitter.addEventListener('pointercancel', stop, { once: true });
});
}
function restorePaneWidth() {
try {
const saved = window.localStorage.getItem(paneWidthStorageKey);
if (saved) setPaneWidth(Number(saved), false);
} catch (_) {}
}
function setPaneWidth(value, persist) {
const percent = Math.min(72, Math.max(32, Number(value) || 58));
document.documentElement.style.setProperty('--table-pane-width', `${percent}%`);
if (persist) {
try { window.localStorage.setItem(paneWidthStorageKey, String(percent)); } catch (_) {}
}
}
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', () => {
guardedLoad({ manual: true }).catch(error => alert(error.message));
});
document.getElementById('add-project-btn').addEventListener('click', addProject);
document.getElementById('notifications-btn').addEventListener('click', event => {
event.stopPropagation();
toggleNotifications();
});
bindSplitter();
document.addEventListener('click', event => {
const linkEl = event.target.closest('a[href]');
if (linkEl && !confirmDiscardDrafts()) {
event.preventDefault();
return;
}
if (notificationsOpen && !event.target.closest('#notification-wrap')) toggleNotifications(false);
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>`;
});
bindNotificationStream();
startAutoRefresh();
</script>
</body>
</html>