Files
etalon df08682bed fix: audit log pagination + full-width table, board card button alignment, light sidebar dither
- Audit log: backend only returns a next cursor when the page is full, so the
  "Load more" button no longer needs two clicks (and hides at the last page).
  Table now spans the full screen width and long detail JSON wraps instead of
  overflowing.
- Bounty board: pin each card's action row to the bottom so "Request
  assignment" lines up across equal-height cards.
- Light theme: restore the Y2K dither texture on the sidebar nav panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:36:32 +02:00

404 lines
17 KiB
JavaScript

import { api, toast } from '/static/js/api.js';
const errorBox = document.getElementById('error');
const showErr = (e) => { errorBox.textContent = e.message || String(e); };
// ---- tabs ----
const tabs = document.querySelectorAll('[role=tab]');
tabs.forEach((btn) => btn.addEventListener('click', () => {
tabs.forEach((b) => b.setAttribute('aria-selected', b === btn ? 'true' : 'false'));
document.querySelectorAll('[role=tabpanel]').forEach((p) => { p.hidden = true; });
document.getElementById('tab-' + btn.dataset.tab).hidden = false;
loaders[btn.dataset.tab]();
}));
// ---- customers ----
const dlg = document.getElementById('customer-dialog');
let editingCustomer = null; // null = creating
let consultants = [];
function credsFromForm(type) {
switch (type) {
case 'jira':
return { email: val('c-jira-email'), apiToken: val('c-jira-token') };
case 'azure_devops':
return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') };
case 'youtrack':
return { permanentToken: val('c-yt-token') };
case 'wekan':
return { username: val('c-wekan-user'), password: val('c-wekan-pass') };
default:
return {};
}
}
const val = (id) => document.getElementById(id).value.trim();
function credsEntered(type) {
const c = credsFromForm(type);
return Object.values(c).some(Boolean);
}
document.getElementById('c-type').addEventListener('change', (e) => {
document.querySelectorAll('.creds').forEach((f) => { f.hidden = true; });
document.getElementById('creds-' + e.target.value).hidden = false;
document.getElementById('f-baseurl').style.display =
e.target.value === 'demo' ? 'none' : '';
});
async function loadConsultants() {
const res = await api('GET', '/api/v1/admin/users?role=consultant&limit=200');
consultants = res.users;
const sel = document.getElementById('c-consultants');
sel.replaceChildren(...consultants.map((u) => {
const o = document.createElement('option');
o.value = u.id;
o.textContent = `${u.name} <${u.email}>`;
return o;
}));
}
// Render one username input per selected consultant, prefilled from the
// customer's saved per-consultant ticketing identity map.
function renderIdentities() {
const wrap = document.getElementById('c-identities-wrap');
const host = document.getElementById('c-identities');
const selectedIds = [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value);
const existing = (editingCustomer && editingCustomer.ticketing.consultantIdentities) || {};
if (!selectedIds.length) { wrap.hidden = true; host.replaceChildren(); return; }
wrap.hidden = false;
host.replaceChildren(...selectedIds.map((id) => {
const u = consultants.find((c) => c.id === id) || { name: id, email: '' };
const row = document.createElement('div');
row.className = 'spread';
row.style.marginTop = '6px';
const label = document.createElement('span');
label.className = 'muted';
label.style.flex = '1';
label.textContent = u.name;
const inp = document.createElement('input');
inp.type = 'text';
inp.dataset.identity = id;
inp.placeholder = 'username in ticketing system';
inp.value = existing[id] || '';
inp.style.flex = '1';
row.append(label, inp);
return row;
}));
}
document.getElementById('c-consultants').addEventListener('change', renderIdentities);
function openCustomerDialog(customer) {
editingCustomer = customer || null;
document.getElementById('customer-dialog-title').textContent =
customer ? `Edit ${customer.name}` : 'New customer';
document.getElementById('c-name').value = customer ? customer.name : '';
document.getElementById('c-type').value = customer ? customer.ticketing.type : 'jira';
document.getElementById('c-project').value = customer ? customer.ticketing.projectKey : '';
document.getElementById('c-baseurl').value = customer ? customer.ticketing.baseUrl : '';
document.getElementById('c-poll').value = customer ? customer.ticketing.pollIntervalSec : 60;
document.getElementById('c-budget').value = customer ? customer.defaultBudget : 1000;
document.querySelectorAll('.creds input').forEach((i) => { i.value = ''; });
document.getElementById('c-type').dispatchEvent(new Event('change'));
const sel = document.getElementById('c-consultants');
[...sel.options].forEach((o) => {
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
});
renderIdentities();
document.getElementById('c-test-result').textContent =
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
dlg.showModal();
}
document.getElementById('customer-new').addEventListener('click', async () => {
try { await loadConsultants(); openCustomerDialog(null); } catch (e) { showErr(e); }
});
document.getElementById('c-cancel').addEventListener('click', () => dlg.close());
document.getElementById('c-test').addEventListener('click', async () => {
const out = document.getElementById('c-test-result');
out.textContent = 'Testing…';
const type = val('c-type');
try {
let res;
if (editingCustomer && !credsEntered(type)) {
res = await api('POST', `/api/v1/admin/customers/${editingCustomer.id}/test-connection`, {});
} else {
res = await api('POST', '/api/v1/admin/customers/test-connection', {
type, baseUrl: val('c-baseurl'), projectKey: val('c-project'),
credentials: credsFromForm(type),
});
}
out.textContent = res.ok ? `✓ Connected (${res.latencyMs} ms)` : `${res.error}`;
out.style.color = res.ok ? 'var(--ok)' : 'var(--err)';
} catch (e) { out.textContent = '✗ ' + e.message; out.style.color = 'var(--err)'; }
});
document.getElementById('customer-form').addEventListener('submit', async (e) => {
e.preventDefault();
const type = val('c-type');
const body = {
name: val('c-name'),
type,
baseUrl: val('c-baseurl'),
projectKey: val('c-project'),
pollIntervalSec: parseInt(val('c-poll'), 10) || 60,
defaultBudget: parseFloat(val('c-budget')) || 0,
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
};
const identities = {};
document.querySelectorAll('#c-identities input[data-identity]').forEach((inp) => {
if (inp.value.trim()) identities[inp.dataset.identity] = inp.value.trim();
});
body.consultantIdentities = identities;
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
try {
if (editingCustomer) {
body.version = editingCustomer.version;
await api('PATCH', `/api/v1/admin/customers/${editingCustomer.id}`, body);
} else {
await api('POST', '/api/v1/admin/customers', body);
}
dlg.close();
toast('Customer saved.', 'ok');
loadCustomers();
} catch (err) {
if (err.status === 409) toast('Conflict: ' + err.message, 'err');
else showErr(err);
}
});
async function loadCustomers() {
try {
const res = await api('GET', '/api/v1/admin/customers?includeArchived=true');
const host = document.getElementById('customer-list');
host.replaceChildren(...res.customers.map(renderCustomerCard));
if (!res.customers.length) {
host.textContent = 'No customers yet — create the first one.';
}
} catch (e) { showErr(e); }
}
function renderCustomerCard(c) {
const card = document.createElement('div');
card.className = 'card';
const syncBadge = c.ticketing.lastSyncStatus === 'ok'
? `<span class="badge" style="color:var(--ok)">sync ok</span>`
: c.ticketing.lastSyncStatus === 'error'
? `<span class="badge" style="color:var(--err)" title="${esc(c.ticketing.lastSyncError)}">sync error</span>`
: '<span class="badge">never synced</span>';
card.innerHTML = `
<div class="spread">
<h3>${esc(c.name)} ${c.archived ? '<span class="badge">archived</span>' : ''}</h3>
<span>${syncBadge}</span>
</div>
<p class="muted">${esc(c.ticketing.type)} · project ${esc(c.ticketing.projectKey || '—')} ·
poll ${c.ticketing.pollIntervalSec}s · budget ${c.defaultBudget} ·
${c.consultantIds.length} consultant(s)
${c.ticketing.lastSyncAt && !c.ticketing.lastSyncAt.startsWith('0001') ? ' · last sync ' + new Date(c.ticketing.lastSyncAt).toLocaleString() : ''}</p>
<div>
<button class="btn small" data-act="edit">Edit</button>
<button class="btn small" data-act="sync">Sync now</button>
<button class="btn small" data-act="archive">${c.archived ? 'Unarchive' : 'Archive'}</button>
<button class="btn small danger" data-act="delete">Delete</button>
</div>`;
card.querySelector('[data-act=edit]').addEventListener('click', async () => {
try { await loadConsultants(); openCustomerDialog(c); } catch (e) { showErr(e); }
});
card.querySelector('[data-act=sync]').addEventListener('click', async () => {
try { await api('POST', `/api/v1/admin/customers/${c.id}/sync-now`, {}); toast('Sync queued.', 'ok'); }
catch (e) { toast(e.message, 'err'); }
});
card.querySelector('[data-act=archive]').addEventListener('click', async () => {
try {
await api('PATCH', `/api/v1/admin/customers/${c.id}`, { archived: !c.archived, version: c.version });
loadCustomers();
} catch (e) { toast(e.message, 'err'); }
});
card.querySelector('[data-act=delete]').addEventListener('click', async () => {
if (!confirm(`Delete customer "${c.name}"? This cannot be undone.`)) return;
try { await api('DELETE', `/api/v1/admin/customers/${c.id}`); loadCustomers(); }
catch (e) { toast(e.message, 'err'); }
});
return card;
}
// ---- users ----
let userSearchTimer;
document.getElementById('user-search').addEventListener('input', () => {
clearTimeout(userSearchTimer);
userSearchTimer = setTimeout(loadUsers, 250);
});
async function loadUsers() {
try {
const q = encodeURIComponent(document.getElementById('user-search').value.trim());
const res = await api('GET', `/api/v1/admin/users?q=${q}&limit=200`);
const tbody = document.querySelector('#user-table tbody');
tbody.replaceChildren(...res.users.map(renderUserRow));
} catch (e) { showErr(e); }
}
function renderUserRow(u) {
const tr = document.createElement('tr');
const roleCell = (role) => {
const td = document.createElement('td');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = u.roles[role];
cb.setAttribute('aria-label', `${role} role for ${u.name}`);
cb.addEventListener('change', async () => {
const roles = { ...u.roles, [role]: cb.checked };
try {
const res = await api('PATCH', `/api/v1/admin/users/${u.id}`, { roles });
u = res.user;
toast(`Roles updated for ${u.name}.`, 'ok');
} catch (e) { cb.checked = !cb.checked; toast(e.message, 'err'); }
});
td.appendChild(cb);
return td;
};
const name = document.createElement('td'); name.textContent = u.name;
const email = document.createElement('td'); email.textContent = u.email;
const status = document.createElement('td');
status.textContent = u.disabled ? 'disabled' : 'active';
const actions = document.createElement('td');
const disableBtn = document.createElement('button');
disableBtn.className = 'btn small';
disableBtn.textContent = u.disabled ? 'Enable' : 'Disable';
disableBtn.addEventListener('click', async () => {
try {
const res = await api('PATCH', `/api/v1/admin/users/${u.id}`, { disabled: !u.disabled });
u = res.user; status.textContent = u.disabled ? 'disabled' : 'active';
disableBtn.textContent = u.disabled ? 'Enable' : 'Disable';
} catch (e) { toast(e.message, 'err'); }
});
const resetBtn = document.createElement('button');
resetBtn.className = 'btn small';
resetBtn.textContent = 'Force reset';
resetBtn.addEventListener('click', async () => {
try { await api('PATCH', `/api/v1/admin/users/${u.id}`, { forceReset: true }); toast('Password reset forced.', 'ok'); }
catch (e) { toast(e.message, 'err'); }
});
const delBtn = document.createElement('button');
delBtn.className = 'btn small danger';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', async () => {
if (!confirm(`Delete user ${u.email}?`)) return;
try { await api('DELETE', `/api/v1/admin/users/${u.id}`); tr.remove(); }
catch (e) { toast(e.message, 'err'); }
});
actions.append(disableBtn, document.createTextNode(' '), resetBtn, document.createTextNode(' '), delBtn);
tr.append(name, email, roleCell('admin'), roleCell('consultant'), roleCell('developer'), status, actions);
return tr;
}
// ---- settings ----
async function loadSettings() {
try {
const res = await api('GET', '/api/v1/admin/settings');
const s = res.settings;
document.getElementById('s-branding').value = s.brandingName;
document.getElementById('s-atomizer').value = s.atomizerBaseUrlOverride;
document.getElementById('s-poll').value = s.defaultPollIntervalSec;
document.getElementById('s-budget').value = s.defaultCustomerBudget;
document.getElementById('s-hideclaims').checked = s.hideCompetingClaims;
} catch (e) { showErr(e); }
}
document.getElementById('settings-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('PATCH', '/api/v1/admin/settings', {
brandingName: val('s-branding'),
atomizerBaseUrlOverride: val('s-atomizer'),
defaultPollIntervalSec: parseInt(val('s-poll'), 10),
defaultCustomerBudget: parseFloat(val('s-budget')),
hideCompetingClaims: document.getElementById('s-hideclaims').checked,
});
toast('Settings saved.', 'ok');
} catch (err) { toast(err.message, 'err'); }
});
// ---- status ----
async function loadStatus() {
const host = document.getElementById('status-body');
host.textContent = 'Loading…';
try {
const s = await api('GET', '/api/v1/admin/service-status');
const ext = (e) => `
<div class="spread">
<span><strong>${esc(e.name)}</strong> <span class="muted">${esc(e.url)}</span></span>
<span>${e.healthy
? `<span class="badge" style="color:var(--ok)">healthy · ${e.latencyMs} ms</span>`
: `<span class="badge" style="color:var(--err)" title="${esc(e.error || '')}">down</span>`}
<span class="badge">breaker: ${esc(e.breaker)}</span></span>
</div>`;
let workers = '<p class="muted">No sync workers running.</p>';
if (Array.isArray(s.syncWorkers) && s.syncWorkers.length) {
workers = s.syncWorkers.map((wkr) => `
<div class="spread">
<span>${esc(wkr.customerName)} <span class="muted">every ${wkr.pollIntervalSec}s</span></span>
<span class="badge" style="color:${wkr.lastStatus === 'error' ? 'var(--err)' : 'var(--ok)'}"
title="${esc(wkr.lastError || '')}">${esc(wkr.lastStatus || 'pending')}</span>
</div>`).join('');
}
host.innerHTML = `
<div class="spread"><strong>MongoDB</strong>
${s.mongo.healthy ? '<span class="badge" style="color:var(--ok)">healthy</span>'
: `<span class="badge" style="color:var(--err)">${esc(s.mongo.error)}</span>`}</div>
${ext(s.atomizer)}
${ext(s.workPerformer)}
<h3>Sync workers</h3>${workers}
<h3>Job queue</h3>
<p class="muted">${esc(JSON.stringify(s.jobs))}</p>`;
} catch (e) { host.textContent = e.message; }
}
document.getElementById('status-refresh').addEventListener('click', loadStatus);
// ---- audit ----
let auditCursor = '';
async function loadAudit(reset = true) {
try {
const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim());
if (reset) auditCursor = '';
else if (!auditCursor) return; // already at the last page
const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`);
auditCursor = res.nextCursor;
const rows = res.entries.map((e) => {
const tr = document.createElement('tr');
const cells = [
new Date(e.at).toLocaleString(), e.actorId, e.action,
`${e.entity}/${e.entityId}`, e.diff ? JSON.stringify(e.diff) : '',
];
cells.forEach((c) => {
const td = document.createElement('td');
td.textContent = c;
tr.appendChild(td);
});
return tr;
});
const tbody = document.querySelector('#audit-table tbody');
if (reset) tbody.replaceChildren(...rows);
else tbody.append(...rows);
document.getElementById('audit-more').hidden = !auditCursor;
} catch (e) { showErr(e); }
}
document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true));
document.getElementById('audit-more').addEventListener('click', () => loadAudit(false));
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
const loaders = {
customers: loadCustomers,
users: loadUsers,
settings: loadSettings,
status: loadStatus,
audit: () => loadAudit(true),
};
loadCustomers();