750 lines
30 KiB
JavaScript
Executable file
750 lines
30 KiB
JavaScript
Executable file
'use strict';
|
||
|
||
// ── State ──────────────────────────────────────────────────────────────────
|
||
let currentUser = null;
|
||
let editingUserId = null;
|
||
let users = [];
|
||
let companyFilter = '';
|
||
let roleFilter = new Set(); // empty = all roles
|
||
let statusFilter = null; // null | 'active' | 'inactive'
|
||
let groupBy = 'company'; // 'company' | 'role' | 'status' | null
|
||
let syncroCustomers = []; // [{ id, name }] loaded once from Syncro
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────
|
||
(async function init() {
|
||
try {
|
||
const r = await fetch('/auth/me');
|
||
if (!r.ok) { location.replace('/login.html?next=' + encodeURIComponent(location.pathname)); return; }
|
||
const { user } = await r.json();
|
||
currentUser = user;
|
||
|
||
document.getElementById('user-name').textContent = user.name;
|
||
const roleBadge = document.getElementById('user-role');
|
||
roleBadge.textContent = user.role;
|
||
roleBadge.dataset.role = user.role;
|
||
|
||
// Show server section only for superduperadmin
|
||
if (user.role === 'superduperadmin') {
|
||
document.getElementById('section-server').style.display = '';
|
||
}
|
||
|
||
// Hide elevated role options for non-superduperadmin
|
||
if (user.role !== 'superduperadmin') {
|
||
document.querySelector('#f-role option[value="superduperadmin"]').remove();
|
||
document.querySelector('#f-role option[value="admin"]').remove();
|
||
}
|
||
|
||
// Load users and Syncro customers in parallel
|
||
await Promise.all([loadUsers(), loadSyncroCustomers()]);
|
||
bindEvents();
|
||
loadApiUsage();
|
||
setInterval(loadApiUsage, 10_000);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
})();
|
||
|
||
// ── API helpers ────────────────────────────────────────────────────────────
|
||
async function api(method, path, body) {
|
||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||
const r = await fetch(path, opts);
|
||
const data = await r.json().catch(() => ({}));
|
||
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||
return data;
|
||
}
|
||
|
||
// ── Syncro customers ───────────────────────────────────────────────────────
|
||
async function loadSyncroCustomers() {
|
||
const hint = document.getElementById('f-customer-hint');
|
||
const listbox = document.getElementById('f-syncro-customer');
|
||
try {
|
||
let page = 1, all = [];
|
||
while (true) {
|
||
const r = await fetch(`/syncro-api/customers?per_page=100&page=${page}`);
|
||
const data = await r.json();
|
||
const batch = data.customers ?? [];
|
||
const totalPages = data.meta?.total_pages ?? 1;
|
||
all = all.concat(batch);
|
||
if (page >= totalPages) break;
|
||
page++;
|
||
}
|
||
syncroCustomers = all
|
||
.map(c => ({ id: c.id, name: c.business_name ?? c.name ?? `Customer ${c.id}` }))
|
||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||
|
||
populateCustomerListbox('');
|
||
hint.textContent = 'Links this user to a Syncro customer for access control.';
|
||
} catch (e) {
|
||
hint.textContent = 'Could not load Syncro customers.';
|
||
console.error('Syncro customer load failed:', e);
|
||
}
|
||
}
|
||
|
||
function populateCustomerListbox(filter) {
|
||
const listbox = document.getElementById('f-syncro-customer');
|
||
const current = listbox.value;
|
||
const q = filter.toLowerCase();
|
||
|
||
// Keep the "None" option then add matching customers
|
||
const matches = q
|
||
? syncroCustomers.filter(c => c.name.toLowerCase().includes(q))
|
||
: syncroCustomers;
|
||
|
||
listbox.innerHTML = '<option value="">— None (internal user) —</option>';
|
||
for (const c of matches) {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = c.name;
|
||
listbox.appendChild(opt);
|
||
}
|
||
|
||
// Restore selection if still present
|
||
if (current && listbox.querySelector(`option[value="${current}"]`)) {
|
||
listbox.value = current;
|
||
}
|
||
}
|
||
|
||
// ── Load & render users ────────────────────────────────────────────────────
|
||
async function loadUsers() {
|
||
try {
|
||
const { users: list } = await api('GET', '/admin/users');
|
||
users = list;
|
||
rebuildCompanyFilter();
|
||
renderUsers();
|
||
} catch (e) {
|
||
document.getElementById('user-tbody').innerHTML =
|
||
`<tr><td colspan="7" class="empty-state" style="color:var(--red);">Failed to load users: ${e.message}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function rebuildCompanyFilter() {
|
||
// Collect unique non-empty companies, sorted
|
||
const companies = [...new Set(
|
||
users.map(u => u.company).filter(Boolean)
|
||
)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||
|
||
// Filter dropdown
|
||
const select = document.getElementById('company-filter');
|
||
const prev = select.value;
|
||
select.innerHTML = '<option value="">All Companies</option>';
|
||
for (const c of companies) {
|
||
const opt = document.createElement('option');
|
||
opt.value = c;
|
||
opt.textContent = c;
|
||
select.appendChild(opt);
|
||
}
|
||
select.value = companies.includes(prev) ? prev : '';
|
||
companyFilter = select.value;
|
||
|
||
}
|
||
|
||
function renderUsers() {
|
||
const tbody = document.getElementById('user-tbody');
|
||
|
||
const visible = users.filter(u => {
|
||
if (companyFilter && u.company !== companyFilter) return false;
|
||
if (roleFilter.size > 0 && !roleFilter.has(u.role)) return false;
|
||
if (statusFilter === 'active' && !u.active) return false;
|
||
if (statusFilter === 'inactive' && u.active) return false;
|
||
return true;
|
||
});
|
||
|
||
const hasFilter = companyFilter || roleFilter.size > 0 || statusFilter !== null;
|
||
if (!visible.length) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="empty-state">${hasFilter ? 'No users match the current filters.' : 'No users found.'}</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
const rows = [];
|
||
|
||
if (groupBy) {
|
||
const keyOf = u => {
|
||
if (groupBy === 'company') return u.company || '';
|
||
if (groupBy === 'role') return u.role;
|
||
if (groupBy === 'status') return u.active ? 'active' : 'inactive';
|
||
return '';
|
||
};
|
||
|
||
const labelOf = key => {
|
||
if (groupBy === 'company') return key || 'No Company';
|
||
if (groupBy === 'role') return { client: 'Client', tech: 'Tech', admin: 'Admin', superduperadmin: 'Super Admin' }[key] ?? key;
|
||
if (groupBy === 'status') return key === 'active' ? 'Active' : 'Inactive';
|
||
return key;
|
||
};
|
||
|
||
const sortKeys = keys => {
|
||
if (groupBy === 'company') return keys.sort((a, b) => !a && b ? 1 : a && !b ? -1 : a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||
if (groupBy === 'role') return keys.sort((a, b) => (['superduperadmin','admin','tech','client'].indexOf(a) - ['superduperadmin','admin','tech','client'].indexOf(b)));
|
||
if (groupBy === 'status') return keys.sort((a, b) => a === 'active' ? -1 : 1);
|
||
return keys;
|
||
};
|
||
|
||
const grouped = new Map();
|
||
for (const u of visible) {
|
||
const key = keyOf(u);
|
||
if (!grouped.has(key)) grouped.set(key, []);
|
||
grouped.get(key).push(u);
|
||
}
|
||
|
||
for (const key of sortKeys([...grouped.keys()])) {
|
||
rows.push(`<tr class="group-row"><td colspan="7">${esc(labelOf(key))}</td></tr>`);
|
||
for (const u of grouped.get(key)) rows.push(userRow(u));
|
||
}
|
||
} else {
|
||
for (const u of visible) rows.push(userRow(u));
|
||
}
|
||
|
||
tbody.innerHTML = rows.join('');
|
||
}
|
||
|
||
function userRow(u) {
|
||
const activeClass = u.active ? 'active' : 'inactive';
|
||
const activeLabel = u.active ? 'Active' : 'Inactive';
|
||
const created = new Date(u.created_at + 'Z').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||
const isSelf = u.id === currentUser.id;
|
||
|
||
const toggleBtn = u.active
|
||
? `<button class="btn btn-danger btn-sm" data-action="toggle" data-id="${u.id}" data-activate="false" ${isSelf ? 'disabled title="Cannot deactivate your own account"' : ''}>Deactivate</button>`
|
||
: `<button class="btn btn-ghost btn-sm" data-action="toggle" data-id="${u.id}" data-activate="true">Activate</button>`;
|
||
|
||
return `
|
||
<tr>
|
||
<td class="td-name">${esc(u.name)}${isSelf ? ' <span style="font-size:.72rem;color:var(--gray-400);font-weight:400;">(you)</span>' : ''}</td>
|
||
<td class="td-username">${esc(u.username)}</td>
|
||
<td style="color:var(--gray-600);font-size:.85rem;">${u.company ? esc(u.company) : '<span style="color:var(--gray-300);">—</span>'}</td>
|
||
<td><span class="role-badge" data-role="${esc(u.role)}">${esc(u.role)}</span></td>
|
||
<td><span class="status-badge ${activeClass}"><span class="status-dot"></span>${activeLabel}</span></td>
|
||
<td style="color:var(--gray-500);font-size:.82rem;">${created}</td>
|
||
<td class="td-actions">
|
||
<button class="btn btn-ghost btn-sm" data-action="edit" data-id="${u.id}">Edit</button>
|
||
${toggleBtn}
|
||
</td>
|
||
</tr>`;
|
||
}
|
||
|
||
// ── App menu ───────────────────────────────────────────────────────────────
|
||
function initAdminMenu() {
|
||
const toggle = document.getElementById('btn-menu-toggle');
|
||
const menu = document.getElementById('app-menu');
|
||
if (!toggle || !menu) return;
|
||
|
||
toggle.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const opening = menu.hidden;
|
||
menu.hidden = !opening;
|
||
toggle.setAttribute('aria-expanded', String(opening));
|
||
});
|
||
|
||
document.addEventListener('click', e => {
|
||
if (!menu.hidden && !menu.contains(e.target) && e.target !== toggle && !toggle.contains(e.target)) {
|
||
menu.hidden = true;
|
||
toggle.setAttribute('aria-expanded', 'false');
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && !menu.hidden) {
|
||
menu.hidden = true;
|
||
toggle.setAttribute('aria-expanded', 'false');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── User filter panel ──────────────────────────────────────────────────────
|
||
function bindFilterPanel() {
|
||
const panel = document.getElementById('user-filter-panel');
|
||
const filterBtn = document.getElementById('btn-user-filter');
|
||
|
||
filterBtn?.addEventListener('click', () => {
|
||
panel.hidden = !panel.hidden;
|
||
filterBtn.classList.toggle('active', !panel.hidden);
|
||
});
|
||
|
||
panel?.addEventListener('click', e => {
|
||
const chip = e.target.closest('.af-chip');
|
||
if (!chip) return;
|
||
const group = chip.closest('[data-filter-group]')?.dataset.filterGroup;
|
||
|
||
if (group === 'role') {
|
||
const val = chip.dataset.value;
|
||
if (roleFilter.has(val)) { roleFilter.delete(val); chip.classList.remove('active'); }
|
||
else { roleFilter.add(val); chip.classList.add('active'); }
|
||
} else if (group === 'status') {
|
||
statusFilter = chip.dataset.value === '' ? null : chip.dataset.value;
|
||
chip.closest('.af-chips').querySelectorAll('.af-chip').forEach(c =>
|
||
c.classList.toggle('active', c.dataset.value === (chip.dataset.value))
|
||
);
|
||
} else if (group === 'groupby') {
|
||
groupBy = chip.dataset.value === '' ? null : chip.dataset.value;
|
||
chip.closest('.af-chips').querySelectorAll('.af-chip').forEach(c =>
|
||
c.classList.toggle('active', c.dataset.value === chip.dataset.value)
|
||
);
|
||
}
|
||
|
||
updateFilterBadge();
|
||
renderUsers();
|
||
});
|
||
|
||
document.getElementById('af-clear')?.addEventListener('click', () => {
|
||
roleFilter.clear();
|
||
statusFilter = null;
|
||
companyFilter = '';
|
||
document.getElementById('company-filter').value = '';
|
||
panel?.querySelectorAll('[data-filter-group="role"] .af-chip').forEach(c => c.classList.remove('active'));
|
||
panel?.querySelectorAll('[data-filter-group="status"] .af-chip').forEach(c =>
|
||
c.classList.toggle('active', c.dataset.value === '')
|
||
);
|
||
groupBy = 'company';
|
||
panel?.querySelectorAll('[data-filter-group="groupby"] .af-chip').forEach(c =>
|
||
c.classList.toggle('active', c.dataset.value === 'company')
|
||
);
|
||
updateFilterBadge();
|
||
renderUsers();
|
||
});
|
||
|
||
document.getElementById('company-filter')?.addEventListener('change', e => {
|
||
companyFilter = e.target.value;
|
||
updateFilterBadge();
|
||
renderUsers();
|
||
});
|
||
}
|
||
|
||
function updateFilterBadge() {
|
||
const count = (companyFilter ? 1 : 0) + roleFilter.size + (statusFilter !== null ? 1 : 0);
|
||
const badge = document.getElementById('user-filter-badge');
|
||
if (badge) { badge.textContent = count; badge.hidden = count === 0; }
|
||
}
|
||
|
||
// ── Bind events ────────────────────────────────────────────────────────────
|
||
function bindEvents() {
|
||
initAdminMenu();
|
||
bindFilterPanel();
|
||
|
||
document.getElementById('btn-new-user').addEventListener('click', openCreate);
|
||
|
||
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||
await fetch('/auth/logout', { method: 'POST' });
|
||
location.replace('/login.html');
|
||
});
|
||
|
||
// Table actions — delegated so they work after innerHTML re-renders
|
||
document.getElementById('user-tbody').addEventListener('click', e => {
|
||
const btn = e.target.closest('button[data-action]');
|
||
if (!btn || btn.disabled) return;
|
||
const id = Number(btn.dataset.id);
|
||
if (btn.dataset.action === 'edit') {
|
||
openEdit(id);
|
||
} else if (btn.dataset.action === 'toggle') {
|
||
confirmToggleActive(id, btn.dataset.activate === 'true');
|
||
}
|
||
});
|
||
|
||
// Customer picker search
|
||
document.getElementById('f-customer-search').addEventListener('input', e => {
|
||
populateCustomerListbox(e.target.value);
|
||
});
|
||
|
||
// Modal close / cancel
|
||
document.getElementById('modal-close').addEventListener('click', closeUserModal);
|
||
document.getElementById('modal-cancel').addEventListener('click', closeUserModal);
|
||
document.getElementById('user-modal').addEventListener('click', e => {
|
||
if (e.target === e.currentTarget) closeUserModal();
|
||
});
|
||
|
||
// Confirm modal
|
||
document.getElementById('confirm-close').addEventListener('click', closeConfirm);
|
||
document.getElementById('confirm-cancel').addEventListener('click', closeConfirm);
|
||
document.getElementById('confirm-modal').addEventListener('click', e => {
|
||
if (e.target === e.currentTarget) closeConfirm();
|
||
});
|
||
|
||
document.getElementById('user-form').addEventListener('submit', handleUserFormSubmit);
|
||
|
||
const btnRestart = document.getElementById('btn-restart');
|
||
if (btnRestart) {
|
||
btnRestart.addEventListener('click', () => {
|
||
showConfirm(
|
||
'Restart Server',
|
||
'The server process will exit and PM2 will restart it automatically. This takes about 1–2 seconds. Proceed?',
|
||
'Restart',
|
||
'btn-accent',
|
||
doRestart
|
||
);
|
||
});
|
||
}
|
||
|
||
document.getElementById('btn-refresh-usage')?.addEventListener('click', loadApiUsage);
|
||
|
||
// History panel toggle
|
||
document.getElementById('btn-history-toggle')?.addEventListener('click', () => {
|
||
_historyPanelVisible = !_historyPanelVisible;
|
||
const panel = document.getElementById('usage-history-panel');
|
||
if (panel) panel.hidden = !_historyPanelVisible;
|
||
document.getElementById('btn-history-toggle')?.classList.toggle('active', _historyPanelVisible);
|
||
_setHistoryWindowDescription();
|
||
loadApiUsage(); // immediate refresh so bar + chart update
|
||
});
|
||
|
||
// Timeframe chips
|
||
document.querySelectorAll('.usage-tf-chip').forEach(chip => {
|
||
chip.addEventListener('click', () => {
|
||
_historyWindowMs = parseInt(chip.dataset.window, 10);
|
||
document.querySelectorAll('.usage-tf-chip').forEach(c =>
|
||
c.classList.toggle('active', c === chip)
|
||
);
|
||
_setHistoryWindowDescription();
|
||
loadApiUsage();
|
||
});
|
||
});
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') { closeUserModal(); closeConfirm(); }
|
||
});
|
||
}
|
||
|
||
// ── User modal ─────────────────────────────────────────────────────────────
|
||
function openCreate() {
|
||
editingUserId = null;
|
||
document.getElementById('modal-title').textContent = 'New User';
|
||
document.getElementById('modal-submit').textContent = 'Create User';
|
||
document.getElementById('f-password-label').textContent = 'Password';
|
||
document.getElementById('f-password-hint').textContent = 'Minimum 12 characters.';
|
||
document.getElementById('f-password').required = true;
|
||
document.getElementById('user-form').reset();
|
||
document.getElementById('modal-error').style.display = 'none';
|
||
document.getElementById('f-username').disabled = false;
|
||
document.getElementById('f-role').disabled = false;
|
||
document.getElementById('f-customer-search').value = '';
|
||
populateCustomerListbox('');
|
||
document.getElementById('f-syncro-customer').value = '';
|
||
document.getElementById('user-modal').style.display = 'flex';
|
||
document.getElementById('f-name').focus();
|
||
}
|
||
|
||
function openEdit(id) {
|
||
const u = users.find(x => x.id === id);
|
||
if (!u) return;
|
||
editingUserId = id;
|
||
|
||
document.getElementById('modal-title').textContent = 'Edit User';
|
||
document.getElementById('modal-submit').textContent = 'Save Changes';
|
||
document.getElementById('f-password-label').textContent = 'New Password';
|
||
document.getElementById('f-password-hint').textContent = 'Leave blank to keep current password. Minimum 12 characters if changing.';
|
||
document.getElementById('f-password').required = false;
|
||
document.getElementById('modal-error').style.display = 'none';
|
||
|
||
const isSelf = u.id === currentUser.id;
|
||
|
||
document.getElementById('f-name').value = u.name;
|
||
document.getElementById('f-username').value = u.username;
|
||
document.getElementById('f-username').disabled = true;
|
||
document.getElementById('f-role').disabled = isSelf;
|
||
document.getElementById('f-password').value = '';
|
||
|
||
// Pre-select Syncro customer
|
||
document.getElementById('f-customer-search').value = '';
|
||
populateCustomerListbox('');
|
||
document.getElementById('f-syncro-customer').value = u.syncro_customer_id ?? '';
|
||
|
||
const roleSelect = document.getElementById('f-role');
|
||
if (!roleSelect.querySelector(`option[value="${u.role}"]`)) {
|
||
const opt = document.createElement('option');
|
||
opt.value = u.role;
|
||
opt.textContent = u.role;
|
||
opt.dataset.temp = '1';
|
||
roleSelect.insertBefore(opt, roleSelect.firstChild);
|
||
}
|
||
roleSelect.value = u.role;
|
||
|
||
document.getElementById('user-modal').style.display = 'flex';
|
||
document.getElementById('f-name').focus();
|
||
}
|
||
|
||
function closeUserModal() {
|
||
document.getElementById('user-modal').style.display = 'none';
|
||
document.getElementById('f-role').disabled = false;
|
||
document.querySelectorAll('#f-role option[data-temp]').forEach(o => o.remove());
|
||
}
|
||
|
||
async function handleUserFormSubmit(e) {
|
||
e.preventDefault();
|
||
const errEl = document.getElementById('modal-error');
|
||
const submit = document.getElementById('modal-submit');
|
||
errEl.style.display = 'none';
|
||
submit.disabled = true;
|
||
|
||
const name = document.getElementById('f-name').value.trim();
|
||
const username = document.getElementById('f-username').value.trim();
|
||
const role = document.getElementById('f-role').value;
|
||
const password = document.getElementById('f-password').value;
|
||
|
||
const customerSelect = document.getElementById('f-syncro-customer');
|
||
const syncroCustomerId = customerSelect.value ? Number(customerSelect.value) : null;
|
||
const company = syncroCustomerId
|
||
? (customerSelect.options[customerSelect.selectedIndex]?.text ?? '')
|
||
: '';
|
||
|
||
try {
|
||
if (editingUserId === null) {
|
||
await api('POST', '/admin/users', { name, username, company, syncro_customer_id: syncroCustomerId, role, password });
|
||
toast('User created successfully.', 'success');
|
||
} else {
|
||
const body = { name, company, syncro_customer_id: syncroCustomerId, role };
|
||
if (password) body.password = password;
|
||
await api('PATCH', `/admin/users/${editingUserId}`, body);
|
||
toast('User updated successfully.', 'success');
|
||
}
|
||
closeUserModal();
|
||
await loadUsers();
|
||
} catch (err) {
|
||
errEl.textContent = err.message;
|
||
errEl.style.display = '';
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
}
|
||
|
||
// ── Toggle active ──────────────────────────────────────────────────────────
|
||
function confirmToggleActive(id, activate) {
|
||
const u = users.find(x => x.id === id);
|
||
if (!u) return;
|
||
const action = activate ? 'activate' : 'deactivate';
|
||
showConfirm(
|
||
`${activate ? 'Activate' : 'Deactivate'} User`,
|
||
`Are you sure you want to ${action} <strong>${esc(u.name)}</strong> (${esc(u.username)})?${!activate ? ' They will be unable to log in.' : ''}`,
|
||
activate ? 'Activate' : 'Deactivate',
|
||
activate ? 'btn-primary' : 'btn-danger',
|
||
async () => {
|
||
try {
|
||
await api('PATCH', `/admin/users/${id}`, { active: activate });
|
||
toast(`User ${activate ? 'activated' : 'deactivated'}.`, activate ? 'success' : 'info');
|
||
await loadUsers();
|
||
} catch (err) {
|
||
toast(err.message, 'error');
|
||
}
|
||
}
|
||
);
|
||
}
|
||
|
||
// ── Restart ────────────────────────────────────────────────────────────────
|
||
async function doRestart() {
|
||
const btn = document.getElementById('btn-restart');
|
||
btn.disabled = true;
|
||
btn.innerHTML = `
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin .7s linear infinite">
|
||
<polyline points="23 4 23 10 17 10"/>
|
||
<path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/>
|
||
</svg>
|
||
Restarting…`;
|
||
try {
|
||
await api('POST', '/admin/restart');
|
||
} catch {
|
||
// Server closed before responding — expected
|
||
}
|
||
toast('Server is restarting… page will refresh in a few seconds.', 'info');
|
||
setTimeout(() => location.reload(), 4000);
|
||
}
|
||
|
||
// ── Confirm modal ──────────────────────────────────────────────────────────
|
||
function showConfirm(title, message, okLabel, okClass, callback) {
|
||
document.getElementById('confirm-title').textContent = title;
|
||
document.getElementById('confirm-message').innerHTML = message;
|
||
const okBtn = document.getElementById('confirm-ok');
|
||
okBtn.textContent = okLabel;
|
||
okBtn.className = `btn btn-sm ${okClass}`;
|
||
okBtn.onclick = async () => { closeConfirm(); await callback(); };
|
||
document.getElementById('confirm-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeConfirm() {
|
||
document.getElementById('confirm-modal').style.display = 'none';
|
||
}
|
||
|
||
// ── Toast ──────────────────────────────────────────────────────────────────
|
||
function toast(msg, type = 'info') {
|
||
const container = document.getElementById('toast-container');
|
||
const el = document.createElement('div');
|
||
el.className = `toast ${type}`;
|
||
el.textContent = msg;
|
||
container.appendChild(el);
|
||
setTimeout(() => el.remove(), 4000);
|
||
}
|
||
|
||
// ── API usage ──────────────────────────────────────────────────────────────
|
||
let _historyWindowMs = 3_600_000; // 1h — active only when panel is visible
|
||
let _historyPanelVisible = false;
|
||
|
||
async function loadApiUsage() {
|
||
const bar = document.getElementById('api-usage-bar');
|
||
const label = document.getElementById('api-usage-label');
|
||
const sub = document.getElementById('api-usage-sub');
|
||
if (!bar) return;
|
||
|
||
try {
|
||
// Always fetch the current 60s count + 7-day limit-hit tally
|
||
const { requests, limit, limitHits7d = 0 } = await api('GET', '/admin/syncro-usage');
|
||
|
||
// Update limit-hit badge on the History button
|
||
const badge = document.getElementById('history-badge');
|
||
if (badge) {
|
||
badge.textContent = limitHits7d > 999 ? '999+' : String(limitHits7d);
|
||
badge.hidden = limitHits7d === 0;
|
||
}
|
||
|
||
let displayReq, displayLimit, pct;
|
||
|
||
if (_historyPanelVisible) {
|
||
// Fetch per-minute history for the selected window
|
||
const { buckets } = await api('GET', `/admin/syncro-history?window=${_historyWindowMs}`);
|
||
const windowTotal = (buckets ?? []).reduce((s, b) => s + b.count, 0);
|
||
displayReq = windowTotal;
|
||
displayLimit = Math.round(_historyWindowMs / 60_000) * limit; // minutes × 180
|
||
pct = Math.min(100, (windowTotal / displayLimit) * 100);
|
||
drawUsageChart(buckets ?? []);
|
||
} else {
|
||
displayReq = requests;
|
||
displayLimit = limit;
|
||
pct = Math.min(100, (requests / limit) * 100);
|
||
}
|
||
|
||
bar.style.width = pct + '%';
|
||
bar.className = 'api-usage-bar' + (pct >= 80 ? ' crit' : pct >= 50 ? ' warn' : '');
|
||
label.textContent = displayReq === 0
|
||
? `— / ${displayLimit.toLocaleString()}`
|
||
: `${displayReq.toLocaleString()} / ${displayLimit.toLocaleString()}`;
|
||
|
||
const timeStr = new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
sub.textContent = `Updated ${timeStr} · auto-refreshes every 10s`;
|
||
} catch (e) {
|
||
if (sub) sub.textContent = 'Failed to load usage data.';
|
||
}
|
||
}
|
||
|
||
function _setHistoryWindowDescription() {
|
||
const el = document.getElementById('api-usage-desc');
|
||
if (!el) return;
|
||
if (!_historyPanelVisible) {
|
||
el.textContent = 'Rolling 60-second request window';
|
||
return;
|
||
}
|
||
const labels = { 3600000: '1-hour', 86400000: '24-hour', 604800000: '7-day' };
|
||
el.textContent = `Rolling ${labels[_historyWindowMs] ?? ''} request window`;
|
||
}
|
||
|
||
function drawUsageChart(buckets) {
|
||
const canvas = document.getElementById('usage-chart');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Size the backing store to physical pixels for crispness
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const displayW = canvas.offsetWidth;
|
||
const displayH = canvas.offsetHeight;
|
||
if (displayW === 0 || displayH === 0) return;
|
||
canvas.width = displayW * dpr;
|
||
canvas.height = displayH * dpr;
|
||
ctx.scale(dpr, dpr);
|
||
|
||
const W = displayW, H = displayH;
|
||
const now = Date.now();
|
||
|
||
// Aggregation block size
|
||
let blockMs;
|
||
if (_historyWindowMs <= 3_600_000) blockMs = 60_000; // 1h → 1-min blocks (60 pts)
|
||
else if (_historyWindowMs <= 86_400_000) blockMs = 10 * 60_000; // 24h → 10-min blocks (144 pts)
|
||
else blockMs = 60 * 60_000; // 7d → 1-hour blocks (168 pts)
|
||
|
||
// Build lookup map from raw per-minute buckets
|
||
const bucketMap = new Map(buckets.map(b => [b.ts, b.count]));
|
||
|
||
// Generate aggregated points (avg req/min in each block)
|
||
const windowStart = now - _historyWindowMs;
|
||
const firstBlockStart = Math.floor(windowStart / blockMs) * blockMs;
|
||
const lastBlockStart = Math.floor(now / blockMs) * blockMs;
|
||
const points = [];
|
||
for (let t = firstBlockStart; t <= lastBlockStart; t += blockMs) {
|
||
let total = 0, mins = 0;
|
||
for (let m = t; m < t + blockMs; m += 60_000) { total += bucketMap.get(m) ?? 0; mins++; }
|
||
points.push({ t, value: mins > 0 ? total / mins : 0 });
|
||
}
|
||
if (points.length === 0) return;
|
||
|
||
// Layout padding
|
||
const padL = 28, padR = 8, padT = 10, padB = 20;
|
||
const chartW = W - padL - padR;
|
||
const chartH = H - padT - padB;
|
||
const maxY = 180;
|
||
|
||
const toX = i => padL + (points.length > 1 ? i / (points.length - 1) : 0.5) * chartW;
|
||
const toY = v => padT + chartH * (1 - Math.min(v, maxY) / maxY);
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Grid lines + Y-axis labels
|
||
[0, 60, 120, 180].forEach(v => {
|
||
const y = toY(v);
|
||
ctx.strokeStyle = v === 0 ? '#d1d5db' : '#e5e7eb';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash(v === 0 ? [] : [3, 3]);
|
||
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + chartW, y); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
ctx.fillStyle = '#9ca3af';
|
||
ctx.font = '9px sans-serif';
|
||
ctx.textAlign = 'right';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(String(v), padL - 4, y);
|
||
});
|
||
|
||
// Filled area gradient
|
||
const gradient = ctx.createLinearGradient(0, padT, 0, padT + chartH);
|
||
gradient.addColorStop(0, 'rgba(43,84,153,.22)');
|
||
gradient.addColorStop(1, 'rgba(43,84,153,.02)');
|
||
|
||
ctx.beginPath();
|
||
points.forEach((p, i) => {
|
||
if (i === 0) ctx.moveTo(toX(i), toY(p.value));
|
||
else ctx.lineTo(toX(i), toY(p.value));
|
||
});
|
||
ctx.lineTo(toX(points.length - 1), padT + chartH);
|
||
ctx.lineTo(toX(0), padT + chartH);
|
||
ctx.closePath();
|
||
ctx.fillStyle = gradient;
|
||
ctx.fill();
|
||
|
||
// Line
|
||
ctx.beginPath();
|
||
points.forEach((p, i) => {
|
||
if (i === 0) ctx.moveTo(toX(i), toY(p.value));
|
||
else ctx.lineTo(toX(i), toY(p.value));
|
||
});
|
||
ctx.strokeStyle = '#2b5499';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.lineJoin = 'round';
|
||
ctx.stroke();
|
||
|
||
// X-axis labels (5 evenly spaced)
|
||
const labelCount = Math.min(5, points.length);
|
||
ctx.fillStyle = '#9ca3af';
|
||
ctx.font = '9px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'alphabetic';
|
||
for (let li = 0; li < labelCount; li++) {
|
||
const idx = Math.round(li * (points.length - 1) / (labelCount - 1));
|
||
const d = new Date(points[idx].t);
|
||
const label = blockMs >= 60 * 60_000
|
||
? d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||
: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
ctx.fillText(label, toX(idx), H - 3);
|
||
}
|
||
}
|
||
|
||
// ── Utility ───────────────────────────────────────────────────────────────
|
||
function esc(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|