664 lines
24 KiB
JavaScript
Executable File
664 lines
24 KiB
JavaScript
Executable File
const PING_MAX = 30;
|
|
let pingResults = [];
|
|
let manifest = null;
|
|
const logStreams = new Map();
|
|
let statusPollTimer = null;
|
|
|
|
/** Fallback when manifest API fails and static file is an older v1 without widgets. */
|
|
const DEFAULT_WIDGETS = [
|
|
{ id: 'authoring-queue', type: 'queue', title: 'Authoring builds', integration: 'authoring' },
|
|
{ id: 'authoring-detail', type: 'authoringDetail', title: 'Authoring detail', integration: 'authoring' },
|
|
{ id: 'watcher', type: 'badge', title: 'Lima watcher', integration: 'watcher' },
|
|
{ id: 'system-resources', type: 'systemResources', title: 'System resources', integration: 'resources' },
|
|
{ id: 'timesheet-summary', type: 'timesheet', title: 'Hours this week', integration: 'timesheet' },
|
|
{ id: 'scripts-health', type: 'log', title: 'Script logs', integration: 'scripts', logKey: 'orgWebBuild' },
|
|
{ id: 'calibre-sync', type: 'log', title: 'Calibre export', integration: 'scripts', logKey: 'calibreSync' },
|
|
{ id: 'org-web-build', type: 'buildStatus', title: 'Website build', integration: 'builds', lastRunKey: 'org_web' },
|
|
];
|
|
|
|
function manifestWidgets(data) {
|
|
return Array.isArray(data?.widgets) && data.widgets.length > 0 ? data.widgets : DEFAULT_WIDGETS;
|
|
}
|
|
|
|
function apiFetch(url, options = {}) {
|
|
return fetch(url, { cache: 'no-store', ...options });
|
|
}
|
|
|
|
export async function initDashboard() {
|
|
manifest = await loadManifest();
|
|
applyDashboardMeta(manifest);
|
|
renderLinks(manifest.links);
|
|
renderWidgets(manifestWidgets(manifest));
|
|
renderBuilds(manifest.builds);
|
|
await refreshIntegrationWidgets();
|
|
startStatusPolling(manifest.integrations);
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
setDailyMessage();
|
|
startCountdown();
|
|
setInterval(fetchHealth, 10000);
|
|
fetchHealth();
|
|
}
|
|
|
|
async function loadManifest() {
|
|
try {
|
|
const apiRes = await apiFetch('/api/zone/manifest');
|
|
if (apiRes.ok) {
|
|
const data = await apiRes.json();
|
|
validateManifest(data);
|
|
return data;
|
|
}
|
|
} catch {
|
|
/* fallback to static file for local dev */
|
|
}
|
|
const res = await apiFetch('data/manifest.json');
|
|
if (!res.ok) throw new Error('Failed to load manifest');
|
|
const data = await res.json();
|
|
validateManifest(data);
|
|
return data;
|
|
}
|
|
|
|
function validateManifest(data) {
|
|
const required = ['dashboard', 'links', 'builds', 'status'];
|
|
for (const key of required) {
|
|
if (!(key in data)) throw new Error(`manifest missing ${key}`);
|
|
}
|
|
const ids = new Set();
|
|
for (const link of data.links) {
|
|
if (ids.has(link.id)) throw new Error(`duplicate link id ${link.id}`);
|
|
ids.add(link.id);
|
|
}
|
|
const buildIds = new Set();
|
|
for (const build of data.builds) {
|
|
if (buildIds.has(build.id)) throw new Error(`duplicate build id ${build.id}`);
|
|
buildIds.add(build.id);
|
|
}
|
|
}
|
|
|
|
function applyDashboardMeta({ dashboard }) {
|
|
document.title = dashboard.title;
|
|
document.querySelector('.topbar h1').textContent = dashboard.title;
|
|
document.querySelector('.tagline').textContent = dashboard.tagline;
|
|
}
|
|
|
|
function renderLinks(links) {
|
|
const nav = document.getElementById('quickLinks');
|
|
nav.innerHTML = '';
|
|
const order = ['platform', 'infra'];
|
|
for (const group of order) {
|
|
for (const link of links.filter(l => l.group === group)) {
|
|
const a = document.createElement('a');
|
|
a.href = link.href;
|
|
a.className = 'link-card';
|
|
a.dataset.linkId = link.id;
|
|
if (link.external) {
|
|
a.target = '_blank';
|
|
a.rel = 'noreferrer';
|
|
}
|
|
const span = document.createElement('span');
|
|
span.textContent = link.label;
|
|
a.appendChild(span);
|
|
nav.appendChild(a);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderWidgets(widgets) {
|
|
const grid = document.getElementById('widgetGrid');
|
|
if (!grid) return;
|
|
grid.innerHTML = '';
|
|
for (const widget of widgets) {
|
|
grid.appendChild(createWidgetCard(widget));
|
|
}
|
|
}
|
|
|
|
function createWidgetCard(widget) {
|
|
const card = document.createElement('article');
|
|
card.className = 'widget-card';
|
|
card.dataset.widgetId = widget.id;
|
|
card.dataset.integration = widget.integration || '';
|
|
card.innerHTML = `
|
|
<div class="widget-head">
|
|
<h3 class="widget-title">${widget.title}</h3>
|
|
<span class="badge neutral widget-badge" id="widget-badge-${widget.id}">—</span>
|
|
</div>
|
|
<div class="widget-body" id="widget-body-${widget.id}">Loading…</div>`;
|
|
return card;
|
|
}
|
|
|
|
async function refreshIntegrationWidgets() {
|
|
try {
|
|
const res = await apiFetch('/api/zone/status');
|
|
if (!res.ok) {
|
|
setIntegrationPollMessage(`Status poll failed (${res.status})`);
|
|
return false;
|
|
}
|
|
const status = await res.json();
|
|
updateWidgets(status);
|
|
setIntegrationPollMessage(`Integrations updated: ${new Date().toLocaleTimeString()}`);
|
|
return true;
|
|
} catch (err) {
|
|
setIntegrationPollMessage('Status poll offline');
|
|
console.warn('zone status poll failed', err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function setIntegrationPollMessage(text) {
|
|
const el = document.getElementById('lastUpdated');
|
|
if (el) el.textContent = text;
|
|
}
|
|
|
|
function startStatusPolling(integrations) {
|
|
if (statusPollTimer) clearInterval(statusPollTimer);
|
|
const pollValues = Object.values(integrations || {}).map(i => i.pollMs || 10000);
|
|
const pollMs = pollValues.length > 0 ? Math.min(...pollValues, 10000) : 10000;
|
|
|
|
statusPollTimer = setInterval(() => refreshIntegrationWidgets(), pollMs);
|
|
}
|
|
|
|
function widgetElements(widgetId) {
|
|
const card = document.querySelector(`[data-widget-id="${widgetId}"]`);
|
|
return {
|
|
body: card?.querySelector('.widget-body'),
|
|
badge: card?.querySelector('.widget-badge'),
|
|
};
|
|
}
|
|
|
|
function updateWidgets(status) {
|
|
const widgets = manifestWidgets(manifest);
|
|
for (const widget of widgets) {
|
|
try {
|
|
updateWidgetCard(widget, status);
|
|
} catch (err) {
|
|
const { body } = widgetElements(widget.id);
|
|
if (body) body.textContent = 'Update error';
|
|
console.warn(`widget ${widget.id} update failed`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateWidgetCard(widget, status) {
|
|
const { body, badge } = widgetElements(widget.id);
|
|
if (!body) return;
|
|
|
|
const type = widget.type || widget.integration;
|
|
|
|
switch (type) {
|
|
case 'queue': {
|
|
const a = status.authoring || {};
|
|
const running = a.running ? 'Running' : 'Idle';
|
|
setBadge(badge, running, a.running ? 'warn' : a.error ? 'error' : 'ok');
|
|
const current = a.current?.title || a.message || 'No builds yet';
|
|
const queued = a.queued ?? a.pendingCount ?? (a.pending?.length || 0);
|
|
body.textContent = `${current} · queued: ${queued}`;
|
|
break;
|
|
}
|
|
case 'authoringDetail': {
|
|
const a = status.authoring || {};
|
|
const pending = a.pendingCount ?? (a.pending?.length || 0);
|
|
setBadge(badge, String(pending), pending > 0 ? 'warn' : 'ok');
|
|
const failed = a.lastFailedTitle ? `Last failed: ${a.lastFailedTitle}` : 'No recent failures';
|
|
body.textContent = `Pending: ${pending} · ${failed}`;
|
|
break;
|
|
}
|
|
case 'badge': {
|
|
const w = status.watcher || {};
|
|
const active = w.ActiveState || w.active || 'unknown';
|
|
const isActive = active === 'active';
|
|
setBadge(badge, active, isActive ? 'ok' : 'warn');
|
|
body.textContent = w.SubState ? `SubState: ${w.SubState}` : (w.message || 'Watcher');
|
|
break;
|
|
}
|
|
case 'timesheet': {
|
|
const t = status.timesheet || {};
|
|
setBadge(badge, t.available ? 'OK' : '—', t.available ? 'ok' : 'neutral');
|
|
body.textContent = t.available
|
|
? `Week ${t.weekStart}: ${t.hoursWorked}h worked (${t.delta >= 0 ? '+' : ''}${t.delta}h vs contracted)`
|
|
: (t.message || 'No data');
|
|
break;
|
|
}
|
|
case 'systemResources': {
|
|
const resources = status.systemResources || {};
|
|
const cpuPct = numberOrNull(resources.cpu?.usagePct);
|
|
const memoryPct = numberOrNull(resources.memory?.usedPct);
|
|
const storagePct = numberOrNull(resources.storage?.usedPct);
|
|
const maxPct = Math.max(cpuPct ?? 0, memoryPct ?? 0, storagePct ?? 0);
|
|
setBadge(badge, maxPct >= 90 ? 'High' : 'OK', maxPct >= 90 ? 'error' : maxPct >= 75 ? 'warn' : 'ok');
|
|
body.innerHTML = `
|
|
${resourceRow('CPU', cpuPct, resources.cpu?.cores ? `${resources.cpu.cores} cores` : '')}
|
|
${resourceRow('RAM', memoryPct, formatBytes(resources.memory?.usedBytes, resources.memory?.totalBytes))}
|
|
${resourceRow('Disk', storagePct, formatBytes(resources.storage?.usedBytes, resources.storage?.totalBytes))}`;
|
|
break;
|
|
}
|
|
case 'log': {
|
|
const info = (status.scripts || {})[widget.logKey] || {};
|
|
const label = info.label || widget.logKey;
|
|
const stale = info.stale;
|
|
setBadge(badge, stale ? 'Stale' : (info.exists === false ? 'Missing' : 'OK'),
|
|
stale || info.exists === false ? 'error' : 'ok');
|
|
const mtime = info.mtime ? new Date(info.mtime).toLocaleString() : 'never';
|
|
const line = typeof info.lastLine === 'string' ? info.lastLine.slice(0, 80) : '';
|
|
body.textContent = `${label}: ${mtime}${info.staleReason ? ` (${info.staleReason})` : ''}${line ? ` · ${line}` : ''}`;
|
|
break;
|
|
}
|
|
case 'buildStatus': {
|
|
const run = (status.lastRuns || {})[widget.lastRunKey] || {};
|
|
const exit = run.exitCode;
|
|
const ok = exit === 0 || exit === '0';
|
|
setBadge(badge,
|
|
run.running ? 'Running' : (exit === '' || exit == null ? '—' : `exit ${exit}`),
|
|
run.running ? 'warn' : ok ? 'ok' : 'error');
|
|
const when = run.lastRun && run.lastRun !== 'never'
|
|
? new Date(run.lastRun).toLocaleString()
|
|
: 'never';
|
|
body.textContent = `Last run: ${when}`;
|
|
break;
|
|
}
|
|
default:
|
|
body.textContent = `Unknown widget type: ${type}`;
|
|
setBadge(badge, '?', 'neutral');
|
|
}
|
|
}
|
|
|
|
function setBadge(el, text, tone) {
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.className = `badge ${tone} widget-badge`;
|
|
}
|
|
|
|
function numberOrNull(value) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function formatPct(value) {
|
|
return value == null ? '—' : `${Math.round(value)}%`;
|
|
}
|
|
|
|
function formatBytes(used, total) {
|
|
const usedNum = Number(used);
|
|
const totalNum = Number(total);
|
|
if (!Number.isFinite(usedNum) || !Number.isFinite(totalNum) || totalNum <= 0) return '';
|
|
return `${bytesToGiB(usedNum)} / ${bytesToGiB(totalNum)}`;
|
|
}
|
|
|
|
function bytesToGiB(bytes) {
|
|
return `${(bytes / 1073741824).toFixed(1)} GiB`;
|
|
}
|
|
|
|
function resourceRow(label, pct, detail) {
|
|
const width = Math.max(0, Math.min(100, pct ?? 0));
|
|
return `
|
|
<div class="resource-row">
|
|
<div class="resource-line">
|
|
<span>${label}</span>
|
|
<span>${formatPct(pct)}${detail ? ` · ${detail}` : ''}</span>
|
|
</div>
|
|
<div class="resource-meter"><span style="width:${width}%"></span></div>
|
|
</div>`;
|
|
}
|
|
|
|
function safeDomId(value) {
|
|
return String(value).replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
}
|
|
|
|
function buildDomIds(build) {
|
|
if (build.id === 'org_web') {
|
|
return {
|
|
btnId: 'buildBtn',
|
|
killId: 'killWebBtn',
|
|
resultId: 'buildResult',
|
|
logId: 'logOutput',
|
|
lastRunId: 'webLastRunMeta',
|
|
};
|
|
}
|
|
if (build.id === 'emacs') {
|
|
return {
|
|
btnId: 'emacsBtn',
|
|
killId: 'killEmacsBtn',
|
|
resultId: 'emacsResult',
|
|
logId: 'emacsLogs',
|
|
lastRunId: 'emacsLastRunMeta',
|
|
};
|
|
}
|
|
const id = safeDomId(build.id);
|
|
return {
|
|
btnId: `${id}Btn`,
|
|
killId: `${id}CancelBtn`,
|
|
resultId: `${id}Result`,
|
|
logId: `${id}Logs`,
|
|
lastRunId: `${id}LastRunMeta`,
|
|
};
|
|
}
|
|
|
|
function renderBuilds(builds) {
|
|
const grid = document.getElementById('buildGrid');
|
|
grid.innerHTML = '';
|
|
for (const build of builds) {
|
|
grid.appendChild(createBuildCard(build));
|
|
}
|
|
document.querySelectorAll('.toggleLogs').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const target = document.getElementById(btn.dataset.target);
|
|
target.classList.toggle('hidden');
|
|
btn.textContent = target.classList.contains('hidden') ? 'Show logs' : 'Hide logs';
|
|
});
|
|
});
|
|
}
|
|
|
|
function createBuildCard(build) {
|
|
const article = document.createElement('article');
|
|
article.className = 'panel run-card';
|
|
article.dataset.buildId = build.id;
|
|
article.setAttribute('data-build-id', build.id);
|
|
|
|
const { btnId, killId, resultId, logId, lastRunId } = buildDomIds(build);
|
|
|
|
article.innerHTML = `
|
|
<div class="panel-header">
|
|
<h2 class="panel-title">${build.label}</h2>
|
|
<span class="badge neutral">${build.badge}</span>
|
|
</div>
|
|
<div class="panel-body">
|
|
<div class="btn-row">
|
|
<button class="btn btn-primary" id="${btnId}">${build.runLabel}</button>
|
|
<button class="btn btn-danger kill-btn" id="${killId}">Cancel</button>
|
|
</div>
|
|
<div class="run-summary">
|
|
<p id="${resultId}" class="status-text">Waiting for action</p>
|
|
<div class="last-run-meta" id="${lastRunId}"></div>
|
|
</div>
|
|
<div class="log-tools">
|
|
<span class="log-label">${build.label} log</span>
|
|
<button class="btn btn-ghost toggleLogs" data-target="${logId}">Show logs</button>
|
|
</div>
|
|
<div id="${logId}" class="log-box hidden" aria-live="polite"></div>
|
|
</div>`;
|
|
|
|
article.querySelector(`#${btnId}`).addEventListener('click', () => runBuild(build, btnId, killId, resultId, logId));
|
|
article.querySelector(`#${killId}`).addEventListener('click', async () => {
|
|
const ok = await showConfirm('Cancel Build', `Force-kill the running ${build.label.toLowerCase()}?`);
|
|
if (ok) killEndpoint(build.cancel.path, build.label);
|
|
});
|
|
|
|
return article;
|
|
}
|
|
|
|
function updateClock() {
|
|
const now = new Date();
|
|
document.getElementById('clockDisplay').textContent = now.toTimeString().slice(0, 8);
|
|
document.getElementById('dateDisplay').textContent = now.toLocaleDateString('en-GB', {
|
|
weekday: 'short', day: 'numeric', month: 'short', year: 'numeric'
|
|
});
|
|
}
|
|
|
|
function showToast(msg, type = 'info', duration = 3500) {
|
|
const el = document.createElement('div');
|
|
el.className = `toast ${type}`;
|
|
el.textContent = msg;
|
|
document.getElementById('toast-container').appendChild(el);
|
|
setTimeout(() => {
|
|
el.style.transition = 'opacity 0.3s,transform 0.3s';
|
|
el.style.opacity = '0';
|
|
el.style.transform = 'translateX(120%)';
|
|
setTimeout(() => el.remove(), 300);
|
|
}, duration);
|
|
}
|
|
|
|
function showConfirm(title, body) {
|
|
return new Promise(resolve => {
|
|
const overlay = document.getElementById('modalOverlay');
|
|
document.getElementById('modalTitle').textContent = title;
|
|
document.getElementById('modalBody').textContent = body;
|
|
overlay.classList.remove('hidden');
|
|
function cleanup(r) {
|
|
overlay.classList.add('hidden');
|
|
document.getElementById('modalConfirm').removeEventListener('click', onY);
|
|
document.getElementById('modalCancel').removeEventListener('click', onN);
|
|
resolve(r);
|
|
}
|
|
const onY = () => cleanup(true);
|
|
const onN = () => cleanup(false);
|
|
document.getElementById('modalConfirm').addEventListener('click', onY);
|
|
document.getElementById('modalCancel').addEventListener('click', onN);
|
|
});
|
|
}
|
|
|
|
function addPingResult(ok) {
|
|
pingResults.push(ok);
|
|
if (pingResults.length > PING_MAX) pingResults.shift();
|
|
const container = document.getElementById('pingHistory');
|
|
container.innerHTML = '';
|
|
pingResults.forEach(r => {
|
|
const b = document.createElement('div');
|
|
b.className = 'ping-bar';
|
|
b.style.height = r ? '28px' : '10px';
|
|
b.style.background = r ? 'var(--green)' : 'var(--red)';
|
|
container.appendChild(b);
|
|
});
|
|
const pct = Math.round((pingResults.filter(Boolean).length / pingResults.length) * 100);
|
|
document.getElementById('uptimePct').textContent = `${pct}%`;
|
|
document.getElementById('uptimeFill').style.width = `${pct}%`;
|
|
}
|
|
|
|
async function fetchUptime() {
|
|
try {
|
|
const res = await fetch(manifest.status.uptime);
|
|
const data = await res.json();
|
|
const ms = data.uptimeMs;
|
|
const d = Math.floor(ms / 86400000);
|
|
const h = Math.floor((ms % 86400000) / 3600000);
|
|
const m = Math.floor((ms % 3600000) / 60000);
|
|
document.getElementById('uptimeDays').textContent = d;
|
|
document.getElementById('uptimeHours').textContent = h;
|
|
document.getElementById('uptimeMins').textContent = m;
|
|
document.getElementById('uptimeStart').textContent = new Date(data.serverStart).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
document.getElementById('uptimeStat').textContent = `${d}d ${h}h ${m}m`;
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
async function fetchLastRuns() {
|
|
try {
|
|
const res = await fetch(manifest.status.lastRuns);
|
|
const data = await res.json();
|
|
for (const build of manifest.builds) {
|
|
renderLastRun(buildDomIds(build).lastRunId, data[build.lastRunKey]);
|
|
updateBuildStatusFromLastRun(build, data[build.lastRunKey]);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
function updateBuildStatusFromLastRun(build, info) {
|
|
if (!build.statusDotId || !info || info.lastRun === 'never') return;
|
|
const dot = document.getElementById(build.statusDotId);
|
|
const text = document.getElementById(build.statusTextId);
|
|
if (!dot || !text) return;
|
|
if (info.exitCode === 0 || (build.successExitCodes && build.successExitCodes.includes(info.exitCode))) {
|
|
dot.className = 'dot green';
|
|
text.textContent = 'Idle';
|
|
} else if (info.exitCode !== 'never' && info.exitCode !== undefined) {
|
|
dot.className = 'dot red';
|
|
text.textContent = 'Error';
|
|
}
|
|
}
|
|
|
|
function renderLastRun(elId, info) {
|
|
const el = document.getElementById(elId);
|
|
if (!el || !info) return;
|
|
const t = info.lastRun && info.lastRun !== 'never'
|
|
? new Date(info.lastRun).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
|
: 'Never';
|
|
const code = info.exitCode !== undefined && info.exitCode !== 'never' ? info.exitCode : null;
|
|
const codeHtml = code !== null
|
|
? `<span style="color:${code === 0 ? 'var(--green)' : 'var(--red)'}">exit ${code}</span>`
|
|
: '';
|
|
el.innerHTML = `<span>Last run: ${t}</span>${codeHtml}`;
|
|
}
|
|
|
|
async function fetchHealth() {
|
|
try {
|
|
await fetch(manifest.status.health);
|
|
document.getElementById('health').textContent = 'Online';
|
|
document.getElementById('healthBadge').textContent = 'Online';
|
|
document.getElementById('healthBadge').className = 'badge ok';
|
|
document.getElementById('serverDot').className = 'dot green';
|
|
document.getElementById('globalStatus').className = 'global-ok';
|
|
document.getElementById('globalStatus').innerHTML = '<div class="status-pulse"></div><span>All systems running normally</span>';
|
|
addPingResult(true);
|
|
fetchUptime();
|
|
fetchLastRuns();
|
|
} catch {
|
|
document.getElementById('health').textContent = 'Offline';
|
|
document.getElementById('healthBadge').textContent = 'Offline';
|
|
document.getElementById('healthBadge').className = 'badge error';
|
|
document.getElementById('serverDot').className = 'dot red';
|
|
document.getElementById('globalStatus').className = 'global-error';
|
|
document.getElementById('globalStatus').innerHTML = '<div class="status-pulse"></div><span>Attention required</span>';
|
|
addPingResult(false);
|
|
}
|
|
document.getElementById('lastUpdated').textContent = `Last checked: ${new Date().toLocaleTimeString()}`;
|
|
}
|
|
|
|
function startLogStream(url, container, buildId) {
|
|
closeStream(buildId);
|
|
container.innerHTML = '';
|
|
const es = new EventSource(url);
|
|
es.addEventListener('log', e => {
|
|
const line = document.createElement('span');
|
|
line.className = 'log-line';
|
|
line.innerHTML = e.data;
|
|
container.appendChild(line);
|
|
container.scrollTop = container.scrollHeight;
|
|
});
|
|
es.addEventListener('done', () => {
|
|
es.close();
|
|
logStreams.delete(buildId);
|
|
});
|
|
es.onerror = () => {
|
|
es.close();
|
|
logStreams.delete(buildId);
|
|
const line = document.createElement('span');
|
|
line.className = 'log-line ansi-dim';
|
|
line.textContent = '-- stream closed --';
|
|
container.appendChild(line);
|
|
};
|
|
logStreams.set(buildId, es);
|
|
}
|
|
|
|
function closeStream(buildId) {
|
|
const s = logStreams.get(buildId);
|
|
if (s) s.close();
|
|
logStreams.delete(buildId);
|
|
}
|
|
|
|
async function killEndpoint(url, label) {
|
|
try {
|
|
const res = await fetch(url, { method: 'DELETE' });
|
|
if (res.ok) showToast(`${label} cancelled`, 'warn');
|
|
else showToast('Nothing to cancel', 'info');
|
|
} catch {
|
|
showToast('Kill request failed', 'error');
|
|
}
|
|
}
|
|
|
|
async function runBuild(build, btnId, killId, resultId, logId) {
|
|
const ok = await showConfirm(build.confirmTitle, build.confirmBody);
|
|
if (!ok) return;
|
|
|
|
const btn = document.getElementById(btnId);
|
|
const killBtn = document.getElementById(killId);
|
|
const res = document.getElementById(resultId);
|
|
const logEl = document.getElementById(logId);
|
|
const dot = document.getElementById(build.statusDotId);
|
|
const statusText = document.getElementById(build.statusTextId);
|
|
|
|
btn.disabled = true;
|
|
killBtn.classList.add('visible');
|
|
res.textContent = `Running ${build.label.toLowerCase()}...`;
|
|
res.className = 'status-text running';
|
|
if (dot) dot.className = 'dot yellow';
|
|
if (statusText) statusText.textContent = 'Running';
|
|
showToast(`${build.label} started`, 'info');
|
|
|
|
try {
|
|
const r = await fetch(build.start.path, { method: build.start.method || 'POST' });
|
|
if (!r.ok) throw new Error();
|
|
closeStream(build.id);
|
|
startLogStream(build.logs.path, logEl, build.id);
|
|
pollStatus(build, btnId, killId, resultId);
|
|
} catch {
|
|
res.textContent = `Could not start ${build.label.toLowerCase()}`;
|
|
res.className = 'status-text failed';
|
|
btn.disabled = false;
|
|
killBtn.classList.remove('visible');
|
|
if (dot) dot.className = 'dot red';
|
|
showToast(`Failed to start ${build.label.toLowerCase()}`, 'error');
|
|
}
|
|
}
|
|
|
|
function pollStatus(build, btnId, killId, resultId) {
|
|
const interval = setInterval(async () => {
|
|
const r = await fetch(build.status.path);
|
|
const s = await r.json();
|
|
if (!s.running) {
|
|
clearInterval(interval);
|
|
document.getElementById(btnId).disabled = false;
|
|
document.getElementById(killId).classList.remove('visible');
|
|
const res = document.getElementById(resultId);
|
|
const dot = document.getElementById(build.statusDotId);
|
|
const statusText = document.getElementById(build.statusTextId);
|
|
const ok = build.successExitCodes
|
|
? build.successExitCodes.includes(s.lastExitCode)
|
|
: s.lastExitCode === 0;
|
|
if (ok) {
|
|
res.textContent = `${build.label} completed`;
|
|
res.className = 'status-text success';
|
|
if (dot) dot.className = 'dot green';
|
|
if (statusText) statusText.textContent = 'Done';
|
|
showToast(`${build.label} complete`, 'success');
|
|
} else {
|
|
res.textContent = `${build.label} finished with issue (code ${s.lastExitCode})`;
|
|
res.className = 'status-text failed';
|
|
if (dot) dot.className = 'dot red';
|
|
if (statusText) statusText.textContent = 'Error';
|
|
showToast(`${build.label} finished with errors`, 'error');
|
|
}
|
|
closeStream(build.id);
|
|
fetchLastRuns();
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
const messages = ['You are my favourite person.'];
|
|
|
|
function setDailyMessage() {
|
|
const today = new Date().toDateString();
|
|
const index = today.split('').reduce((a, c) => a + c.charCodeAt(0), 0) % messages.length;
|
|
document.getElementById('dailyMessage').textContent = messages[index];
|
|
}
|
|
|
|
function startCountdown() {
|
|
const target = new Date('2026-08-01T00:00:00');
|
|
document.getElementById('countdownLabel').textContent = 'Wedding';
|
|
function update() {
|
|
const diff = target - new Date();
|
|
if (diff <= 0) {
|
|
document.getElementById('countdownDisplay').innerHTML = '<p style="color:var(--green);font-weight:700">Nikah is now</p>';
|
|
return;
|
|
}
|
|
document.getElementById('cd-days').textContent = String(Math.floor(diff / 86400000)).padStart(3, '0');
|
|
document.getElementById('cd-hours').textContent = String(Math.floor((diff / 3600000) % 24)).padStart(2, '0');
|
|
document.getElementById('cd-mins').textContent = String(Math.floor((diff / 60000) % 60)).padStart(2, '0');
|
|
document.getElementById('cd-secs').textContent = String(Math.floor((diff / 1000) % 60)).padStart(2, '0');
|
|
}
|
|
update();
|
|
setInterval(update, 1000);
|
|
}
|
|
|
|
initDashboard().catch(err => {
|
|
console.error(err);
|
|
document.getElementById('globalStatus').className = 'global-error';
|
|
document.getElementById('globalStatus').innerHTML = '<div class="status-pulse"></div><span>Failed to load dashboard manifest</span>';
|
|
});
|