const ORDER = ['Backend', 'Frontend', 'Infrastructure', 'Other stacks'];
let LAST = null;
let OPEN = null; let LOG_TIMER = null;
const SELECTED = new Set();
const el = (tag, cls, text) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
};
const get = (url) => fetch(url, { cache: 'no-store' }).then((r) => r.json());
function selectable(s) {
return canStart(s) || canStopRestart(s);
}
function canStopRestart(s) {
return s.restartable && !!s.container;
}
function canStart(s) {
return !!s.startable && (!s.container || s.container.state !== 'running');
}
function syncBulkBar() {
const bar = document.getElementById('bulk');
const n = SELECTED.size;
bar.classList.toggle('show', n > 0);
document.getElementById('bulk-count').textContent = n === 1 ? '1 selected' : n + ' selected';
}
function clearSelection() {
SELECTED.clear();
syncBulkBar();
document.querySelectorAll('.card.selected').forEach((c) => c.classList.remove('selected'));
document.querySelectorAll('.pick:checked').forEach((p) => {
p.checked = false;
});
}
function pruneSelection(services) {
const ok = new Set(services.filter(selectable).map((s) => s.service));
for (const name of [...SELECTED]) {
if (!ok.has(name)) SELECTED.delete(name);
}
}
function copyBtn(value) {
const b = el('button', 'copy', 'copy');
b.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(value).then(() => {
b.textContent = 'copied';
setTimeout(() => (b.textContent = 'copy'), 1200);
});
};
return b;
}
function row(label, value, { mono = true, copy = true } = {}) {
const r = el('div', 'row');
r.append(el('span', 'row-label', label));
r.append(el('span', mono ? 'row-value mono' : 'row-value', value));
if (copy) r.append(copyBtn(value));
return r;
}
function card(s) {
const c = el('div', 'card');
c.tabIndex = 0;
if (SELECTED.has(s.service)) c.classList.add('selected');
c.onclick = () => openDrawer(s.service);
c.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openDrawer(s.service);
}
};
const top = el('div', 'card-top');
const pick = el('input', 'pick');
pick.type = 'checkbox';
pick.checked = SELECTED.has(s.service);
pick.disabled = !selectable(s);
pick.title = selectable(s)
? 'Select for bulk start / stop / restart'
: 'Nothing to start, stop, or restart here';
pick.onclick = (e) => e.stopPropagation();
pick.onchange = (e) => {
e.stopPropagation();
if (pick.checked) SELECTED.add(s.service);
else SELECTED.delete(s.service);
c.classList.toggle('selected', pick.checked);
syncBulkBar();
};
top.append(pick, el('span', 'dot ' + s.status), el('span', 'name', s.label));
top.append(el('span', 'state', s.detail || s.status));
c.append(top);
if (s.note) c.append(el('div', 'note', s.note));
c.append(
el('div', 'meta', s.container ? `${s.container.container} · ${s.container.status}` : 'no container — run run-stack up')
);
c.append(actionRow(s));
return c;
}
function actionRow(s) {
const actions = el('div', 'actions');
actions.append(openBtn(s));
actions.append(startBtn(s));
actions.append(restartBtn(s));
actions.append(stopBtn(s));
actions.append(reloadBtn(s));
actions.append(logsBtn(s));
actions.append(clearCacheBtn(s));
actions.append(shellBtn(s));
if (s.deploy) actions.append(deployBtn(s));
actions.append(el('span', 'hint', 'details →'));
return actions;
}
function cta(label, title, onClick, { enabled = true, disabledTitle = '' } = {}) {
const b = el('button', 'open', label);
b.type = 'button';
b.title = enabled ? title : disabledTitle || title;
if (!enabled) {
b.disabled = true;
b.classList.add('unavailable');
} else {
b.onclick = (e) => {
e.stopPropagation();
onClick();
};
}
return b;
}
function openBtn(s) {
if (!s.link) {
return cta('Open', '', null, { enabled: false, disabledTitle: 'This service has no web UI' });
}
const a = el('a', 'open', 'Open');
a.href = s.link;
a.target = '_blank';
a.rel = 'noreferrer';
a.title = s.link;
a.onclick = (e) => e.stopPropagation();
return a;
}
function startBtn(s) {
return cta('Start', 'Starts this service (or creates it if missing)', () =>
runAction('./api/start', s.service, 'Starting ' + s.service + '…'), {
enabled: canStart(s),
disabledTitle: 'Already running',
});
}
function restartBtn(s) {
return cta('Restart', 'Restarts this container', () => runAction('./api/restart', s.service, 'Restarting ' + s.service + '…'), {
enabled: s.restartable && !!s.container,
disabledTitle: s.restartable ? 'No container yet — run run-stack up' : 'One-shot service, nothing to restart',
});
}
function stopBtn(s) {
return cta('Stop', 'Stops this container', () => runAction('./api/stop', s.service, 'Stopping ' + s.service + '…'), {
enabled: s.restartable && !!s.container,
disabledTitle: s.restartable ? 'No container yet — run run-stack up' : 'One-shot service, nothing to stop',
});
}
function reloadBtn(s) {
return cta('Reload', 'Tells Metro to reload connected apps (no Docker restart)', () =>
runAction('./api/reload', s.service, 'Reloading ' + s.service + '…'), {
enabled: s.reloadable && !!s.container && s.container.state === 'running',
disabledTitle: s.reloadable
? 'Metro must be running — run run-stack up mobile-client'
: 'Only Metro (mobile-client) can reload apps',
});
}
function logsBtn(s) {
return cta('Logs', 'Opens this service’s recent logs', () => openDrawer(s.service));
}
function clearCacheBtn(s) {
return cta(
'Clear cache',
'Deletes this service’s build caches, then restarts it',
() => runAction('./api/clear-cache', s.service, 'Clearing cache…'),
{
enabled: s.clearable && !!s.container,
disabledTitle: s.clearable ? 'No container yet — run run-stack up' : 'This service has no cache to clear',
}
);
}
function shellBtn(s) {
return cta('Shell', 'Copies a docker exec command for this container', () => copyText(s.shell), {
enabled: !!s.shell,
disabledTitle: 'No container yet — run run-stack up',
});
}
function deployBtn(s) {
return cta('Deploy', 'Runs ' + s.deploy.command, () => askDeploy(s), {
enabled: !!s.container,
disabledTitle: 'No container yet — run run-stack up',
});
}
function copyText(value) {
navigator.clipboard.writeText(value).then(
() => toast('Copied: ' + value),
() => toast('Could not copy: ' + value)
);
}
function toast(message) {
const body = document.getElementById('launch-body');
body.replaceChildren(el('p', 'note', message));
const done = el('button', 'open', 'Done');
done.type = 'button';
done.onclick = hideLaunchDialog;
body.append(done);
showLaunchDialog();
}
function askDeploy(s) {
const body = document.getElementById('launch-body');
body.replaceChildren();
body.append(el('p', 'note', 'Deploy ' + s.label + ' (' + s.deploy.target + '). Pick an environment:'));
body.append(el('pre', 'logs', s.deploy.command));
s.deploy.envs.forEach((env) => {
const b = el('button', 'launch-opt');
b.type = 'button';
b.append(el('strong', null, env));
b.append(el('span', 'note', env === s.deploy.defaultEnv ? 'default' : ''));
b.onclick = () =>
runAction('./api/deploy', s.service, 'Deploying ' + s.deploy.target + ' → ' + env + '… this can take a while.', { env });
body.append(b);
});
const cancel = el('button', 'open', 'Cancel');
cancel.type = 'button';
cancel.onclick = hideLaunchDialog;
body.append(cancel);
showLaunchDialog();
}
function showLaunchDialog() {
document.getElementById('launch-scrim').classList.add('show');
document.getElementById('launch').classList.add('show');
}
async function runAction(url, service, pending, extra = {}) {
const body = document.getElementById('launch-body');
body.replaceChildren(el('p', 'note', pending));
showLaunchDialog();
try {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service, ...extra }),
}).then((x) => x.json());
showLaunchResult(r);
} catch (e) {
showLaunchResult({ ok: false, detail: e.message });
}
}
async function runBulkAction(url, verb) {
const services = [...SELECTED];
if (!services.length) return;
const body = document.getElementById('launch-body');
body.replaceChildren(el('p', 'note', `${verb} ${services.length} service${services.length === 1 ? '' : 's'}…`));
showLaunchDialog();
const lines = [];
let okCount = 0;
for (const service of services) {
try {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service }),
}).then((x) => x.json());
if (r.ok) okCount += 1;
lines.push(`${service}: ${r.detail || (r.ok ? 'ok' : 'failed')}`);
} catch (e) {
lines.push(`${service}: ${e.message}`);
}
}
clearSelection();
showLaunchResult({
ok: okCount === services.length,
detail: `${okCount}/${services.length} succeeded`,
output: lines.join('\n'),
});
refresh();
}
function hideLaunchDialog() {
document.getElementById('launch').classList.remove('show');
document.getElementById('launch-scrim').classList.remove('show');
}
function launchDialogOpen() {
return document.getElementById('launch').classList.contains('show');
}
function showLaunchResult(r) {
const body = document.getElementById('launch-body');
body.replaceChildren();
body.append(el('p', r.ok ? 'test-result good' : 'test-result bad', r.detail || (r.ok ? 'Opened' : 'Failed')));
if (r.output) body.append(el('pre', 'logs', r.output));
if (r.url) {
const img = el('img', 'qr');
img.alt = 'Metro URL';
img.src =
'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodeURIComponent(r.url);
body.append(img);
const line = el('div', 'row');
line.append(el('span', 'row-value mono', r.url), copyBtn(r.url));
body.append(line);
}
const done = el('button', 'open', 'Done');
done.type = 'button';
done.onclick = hideLaunchDialog;
body.append(done);
}
function closeDrawer() {
OPEN = null;
clearInterval(LOG_TIMER);
LOG_TIMER = null;
document.getElementById('drawer').classList.remove('show');
document.getElementById('scrim').classList.remove('show');
}
function openDrawer(service) {
OPEN = service;
const s = (LAST?.services || []).find((x) => x.service === service);
if (!s) return;
const d = document.getElementById('drawer');
const body = el('div', 'drawer-body');
const head = el('div', 'drawer-head');
const title = el('div');
title.append(el('h3', null, s.label));
title.append(el('div', 'sub', s.service));
const x = el('button', 'close', '×');
x.onclick = closeDrawer;
head.append(title, x);
const badge = el('div', 'badge ' + s.status);
badge.append(el('span', 'dot ' + s.status), el('span', null, s.detail || s.status));
body.append(badge);
if (s.note) body.append(el('p', 'note', s.note));
const info = el('div', 'section');
info.append(el('h4', null, 'Container'));
if (s.container) {
info.append(row('name', s.container.container));
info.append(row('image', s.container.image));
info.append(row('state', `${s.container.state} · ${s.container.status}`, { copy: false }));
} else {
info.append(el('div', 'note', 'Not created. Run run-stack up'));
}
body.append(info);
const controls = el('div', 'section');
controls.append(el('h4', null, 'Actions'));
const rowBtns = el('div', 'actions');
rowBtns.append(openBtn(s), startBtn(s), restartBtn(s), stopBtn(s), reloadBtn(s), clearCacheBtn(s), shellBtn(s));
if (s.deploy) rowBtns.append(deployBtn(s));
controls.append(rowBtns);
controls.append(
el('p', 'note', 'Start creates or wakes the container. Restart bounces it; Stop leaves it exited; Reload asks Metro to refresh apps (no Docker restart). Clear cache wipes build caches, then restarts.')
);
body.append(controls);
const endpoints = el('div', 'section');
endpoints.append(el('h4', null, 'Endpoints'));
if (s.link) {
const r = el('div', 'row');
r.append(el('span', 'row-label', 'host'));
const a = el('a', 'row-value mono link', s.link);
a.href = s.link;
a.target = '_blank';
a.rel = 'noreferrer';
r.append(a, copyBtn(s.link));
endpoints.append(r);
}
endpoints.append(el('div', 'probe-slot'));
body.append(endpoints);
const cmds = el('div', 'section');
cmds.append(el('h4', null, 'Commands'));
const commandRows = [
['start', `run-stack up ${s.service}`],
['logs', `run-stack logs ${s.service}`],
['shell', `run-stack shell ${s.service}`],
['restart', `run-stack restart ${s.service}`],
['stop', s.container ? `docker stop ${s.container.container}` : 'docker stop <container>'],
];
if (s.reloadable) commandRows.push(['reload', 'run-stack reload']);
commandRows.forEach(([k, v]) => cmds.append(row(k, v)));
body.append(cmds);
if (['backend', 'web', 'admin', 'landing', 'mobile-client'].includes(s.service)) {
body.append(credentialsSection(s.service));
}
const logs = el('div', 'section');
const logsHead = el('div', 'section-head');
logsHead.append(el('h4', null, 'Recent logs'), copyLogsBtn());
logs.append(logsHead);
const pre = el('pre', 'logs', 'loading…');
pre.id = 'log-pre';
logs.append(pre);
body.append(logs);
d.replaceChildren(head, body);
d.classList.add('show');
document.getElementById('scrim').classList.add('show');
loadLogs(service);
clearInterval(LOG_TIMER);
LOG_TIMER = setInterval(() => loadLogs(service), 5000);
}
const LOG_RULES = [
[/\b(error|err|fatal|exception|failed|failure|panic)\b/i, 'error'],
[/\b(warn|warning|deprecated)\b/i, 'warn'],
[/\b(ready|success|succeeded|started|listening|compiled|done|bundled)\b/i, 'success'],
[/\b(log|info|notice)\b/i, 'info'],
[/\b(debug|trace|verbose)\b/i, 'debug'],
];
const logLevel = (line) => (LOG_RULES.find(([re]) => re.test(line)) || [null, ''])[1];
const stripAnsi = (text) => text.replace(/\u001b\[[0-9;]*m/g, "");
function renderLogs(pre, text) {
const lines = stripAnsi(text).split('\n');
pre.replaceChildren(...lines.map((line) => el('span', 'log-line ' + logLevel(line), line + '\n')));
}
function copyLogsBtn() {
const b = el('button', 'copy', 'copy');
b.type = 'button';
b.onclick = (e) => {
e.stopPropagation();
const pre = document.getElementById('log-pre');
navigator.clipboard.writeText(pre ? pre.textContent : '').then(() => {
b.textContent = 'copied';
setTimeout(() => (b.textContent = 'copy'), 1200);
});
};
return b;
}
async function loadLogs(service) {
if (OPEN !== service) return;
try {
const d = await get('./api/logs?service=' + encodeURIComponent(service) + '&tail=120');
if (OPEN !== service) return;
const pre = document.getElementById('log-pre');
if (pre) {
renderLogs(pre, d.logs);
pre.scrollTop = pre.scrollHeight;
}
const slot = document.querySelector('.probe-slot');
if (slot && d.probe) slot.replaceChildren(row('probe', d.probe, { copy: false }));
} catch (e) {
}
}
function credentialsSection(service) {
const wrap = el('div', 'section');
wrap.append(el('h4', null, 'Test credentials'));
const hint = el('div', 'note', 'From run/config/credentials.json. Click Test to run a real login against the API.');
wrap.append(hint);
const holder = el('div');
wrap.append(holder);
get('./api/credentials').then((c) => {
holder.replaceChildren();
if (c.countryNote) holder.append(el('div', 'note', c.countryNote));
if (!c.accounts.length) {
holder.append(el('div', 'note', 'No accounts configured. Copy config/credentials.example.json to config/credentials.json.'));
}
c.accounts
.filter((a) => (service === 'admin' ? a.use === 'admin' : service === 'backend' ? true : a.use !== 'admin'))
.forEach((a) => holder.append(account(a)));
if (service === 'backend') {
const infra = el('div', 'sub-block');
infra.append(el('h5', null, 'Connections'));
c.infra.forEach((i) => infra.append(row(i.label, i.value)));
holder.append(infra);
}
});
return wrap;
}
function account(a) {
const box = el('div', 'account');
const head = el('div', 'account-head');
head.append(el('strong', null, a.role));
const result = el('span', 'test-result');
if (a.login) {
const test = el('button', 'test', 'Test login');
test.onclick = async () => {
test.disabled = true;
result.className = 'test-result';
result.textContent = 'testing…';
try {
const r = await fetch('./api/test-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: a.login }),
}).then((x) => x.json());
result.textContent = (r.ok ? '✓ ' : '✗ ') + r.detail;
result.className = 'test-result ' + (r.ok ? 'good' : 'bad');
} catch (e) {
result.textContent = '✗ ' + e.message;
result.className = 'test-result bad';
}
test.disabled = false;
};
head.append(test);
}
box.append(head);
Object.entries(a.fields || {}).forEach(([k, v]) => box.append(row(k, String(v))));
if (a.source) box.append(el('div', 'source', a.source));
box.append(result);
return box;
}
async function refresh() {
let data;
try {
data = await get('./api/status');
} catch (e) {
document.getElementById('sub').textContent = 'dashboard unreachable';
return;
}
LAST = data;
pruneSelection(data.services);
const label = (data.projectLabel || data.project) + ' · local stack';
document.getElementById('title').textContent = label;
document.title = label;
document.getElementById('c-ok').textContent = data.summary.ok;
document.getElementById('c-pending').textContent = data.summary.pending;
document.getElementById('c-down').textContent = data.summary.down;
document.getElementById('sub').textContent =
(data.projectLabel || data.project) + ' · updated ' + new Date().toLocaleTimeString();
const banner = document.getElementById('banner');
if (data.dockerError) {
banner.style.display = 'block';
banner.textContent =
'Cannot read the Docker socket (' + data.dockerError + '). Container state is unavailable.';
} else {
banner.style.display = 'none';
}
const groups = document.getElementById('groups');
groups.replaceChildren();
for (const name of ORDER) {
const items = data.services.filter((s) => s.group === name);
if (!items.length) continue;
groups.append(el('h2', null, name));
const grid = el('div', 'grid');
items.forEach((s) => grid.append(card(s)));
groups.append(grid);
}
syncBulkBar();
}
document.getElementById('bulk-start').onclick = () => runBulkAction('./api/start', 'Start');
document.getElementById('bulk-restart').onclick = () => runBulkAction('./api/restart', 'Restart');
document.getElementById('bulk-stop').onclick = () => runBulkAction('./api/stop', 'Stop');
document.getElementById('bulk-clear').onclick = clearSelection;
document.getElementById('scrim').onclick = closeDrawer;
document.getElementById('launch-scrim').onclick = hideLaunchDialog;
document.getElementById('launch-close').onclick = hideLaunchDialog;
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (launchDialogOpen()) hideLaunchDialog();
else closeDrawer();
});
refresh();
setInterval(refresh, 5000);