← back to Fleet Tv
public/atlas.html
217 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Fleet TV — Atlas</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #07080a;
--fg: #e8e6e0;
--dim: #6a6862;
--rule: rgba(255,255,255,0.08);
--pane: #11141a;
--accent: #c9a14b;
}
html, body { background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; min-height: 100vh; }
.head {
position: sticky; top: 0; z-index: 5;
display: flex; align-items: baseline; gap: 1.5rem;
padding: 1rem 1.5rem;
background: linear-gradient(180deg, rgba(7,8,10,0.94), rgba(7,8,10,0.6));
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--rule);
}
.head h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; }
.head .crumb { color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
.head .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
.head .crumb a:hover { color: var(--fg); border-color: var(--rule); }
.head .stamp { margin-left: auto; color: var(--dim); font-size: 0.78rem; letter-spacing: 0.18em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
/* — KPI strip — */
.kpis {
display: grid; gap: 0.6rem; padding: 1rem 1.5rem;
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.kpi {
background: var(--pane); border: 1px solid var(--rule); border-radius: 5px;
padding: 0.85rem 1rem;
}
.kpi .label { font-size: 0.66rem; letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); }
.kpi .value { font-size: 1.6rem; line-height: 1.1; margin-top: 0.25rem; font-weight: 300; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
.kpi.ok .value { color: #6dbe7d; }
.kpi.warn .value { color: #d9a04a; }
.kpi.err .value { color: #d96a6a; }
/* — Categories — */
.cats { padding: 0.5rem 1.5rem 4rem; display: grid; gap: 1.25rem; }
.cat {
background: var(--pane); border: 1px solid var(--rule); border-radius: 6px;
overflow: hidden; border-left: 3px solid currentColor;
color: var(--accent);
}
.cat header {
padding: 0.85rem 1.25rem; border-bottom: 1px solid var(--rule);
display: flex; align-items: baseline; gap: 0.85rem; color: var(--fg);
}
.cat header .name { font-size: 0.85rem; letter-spacing: 0.2em; text-transform: uppercase; font-weight: 500; }
.cat header .count { color: var(--dim); font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase; }
.cat header .swatch { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.tiles {
display: grid; gap: 0.5rem; padding: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.tile {
background: var(--bg); border: 1px solid var(--rule); border-radius: 4px;
padding: 0.75rem 0.85rem;
display: grid; gap: 0.4rem;
transition: background 0.2s ease, border-color 0.2s ease;
position: relative;
color: var(--fg);
}
.tile:hover { background: #15181f; border-color: rgba(255,255,255,0.18); }
.tile .top { display: flex; align-items: center; gap: 0.5rem; }
.tile .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.tile .dot.online { background: #6dbe7d; box-shadow: 0 0 6px rgba(109,190,125,0.5); }
.tile .dot.errored { background: #d96a6a; }
.tile .dot.stopped { background: #888; }
.tile .name { font-size: 0.85rem; line-height: 1.1; word-break: break-word; }
.tile .desc { font-size: 0.72rem; line-height: 1.4; color: var(--dim); }
.tile .desc.muted { font-style: italic; opacity: 0.5; }
.tile .meta {
display: flex; gap: 0.65rem; align-items: baseline;
color: var(--dim); font-size: 0.62rem; letter-spacing: 0.16em; text-transform: uppercase;
font-variant-numeric: tabular-nums;
border-top: 1px dashed var(--rule); padding-top: 0.4rem; margin-top: 0.1rem;
}
.tile .meta b { color: var(--fg); font-weight: 500; }
.tile .restart-warn { color: #d9a04a; }
.splash {
position: fixed; inset: 0; display: grid; place-items: center;
background: var(--bg); z-index: 10;
transition: opacity 0.5s ease;
}
.splash.hide { opacity: 0; pointer-events: none; }
.splash .mark {
font-size: 1rem; letter-spacing: 0.42em; text-transform: uppercase;
color: var(--accent);
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
</style>
</head>
<body>
<div class="splash" id="splash"><div class="mark">Atlas · scanning portfolio</div></div>
<div class="head">
<h1>Fleet TV</h1>
<span class="crumb"><a href="/">wall</a> · <a href="/mosaic.html">mosaic</a> · <a href="/stats.html">stats</a> · <a href="/search.html">search</a> · <a href="/today.html">today</a> · atlas</span>
<div class="stamp" id="stamp">—</div>
</div>
<div class="kpis">
<div class="kpi"><div class="label">Total processes</div><div class="value" id="kpi-total">—</div></div>
<div class="kpi ok"><div class="label">Online</div><div class="value" id="kpi-online">—</div></div>
<div class="kpi err"><div class="label">Errored / stopped</div><div class="value" id="kpi-err">—</div></div>
<div class="kpi warn"><div class="label">Total restarts</div><div class="value" id="kpi-restarts">—</div></div>
<div class="kpi"><div class="label">RSS total</div><div class="value" id="kpi-mem">—</div></div>
</div>
<div class="cats" id="cats"></div>
<script>
(() => {
const $ = (id) => document.getElementById(id);
function humanUptime(ms) {
if (!ms || ms < 0) return '—';
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm';
if (s < 86400) return Math.floor(s/3600) + 'h';
return Math.floor(s/86400) + 'd';
}
async function load() {
$('splash').classList.remove('hide');
try {
const r = await fetch('/api/atlas', { cache: 'no-store' });
const j = await r.json();
$('kpi-total').textContent = j.summary.total;
$('kpi-online').textContent = j.summary.online;
$('kpi-err').textContent = j.summary.errored;
$('kpi-restarts').textContent = j.summary.totalRestarts.toLocaleString();
$('kpi-mem').textContent = j.summary.totalMemMb >= 1024
? (j.summary.totalMemMb / 1024).toFixed(1) + 'gb'
: j.summary.totalMemMb + 'mb';
$('stamp').textContent = new Date(j.generatedAt).toTimeString().slice(0,8) + ' · R refresh';
const cats = $('cats');
cats.innerHTML = '';
for (const c of j.categories) {
const sec = document.createElement('section');
sec.className = 'cat';
sec.style.color = c.accent;
sec.innerHTML = `
<header>
<span class="swatch"></span>
<span class="name">${escape(c.name)}</span>
<span class="count">${c.projects.length} ${c.projects.length === 1 ? 'process' : 'processes'}</span>
</header>
<div class="tiles">
${c.projects.map(p => `
<div class="tile">
<div class="top">
<span class="dot ${escape(p.status)}"></span>
<span class="name">${escape(p.name)}</span>
</div>
<div class="desc ${p.description ? '' : 'muted'}">${p.description ? escape(p.description) : '(no description on file)'}</div>
<div class="meta">
<span>${escape(p.status)}</span>
<span>${humanUptime(p.uptimeMs)}</span>
<span><b>${p.memMb}</b>mb</span>
<span class="${p.restartTime > 20 ? 'restart-warn' : ''}"><b>${p.restartTime}</b>↺</span>
</div>
</div>
`).join('')}
</div>
`;
cats.appendChild(sec);
}
} catch (e) {
console.error(e);
} finally {
setTimeout(() => $('splash').classList.add('hide'), 250);
}
}
document.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 'r') load();
if (e.key.toLowerCase() === 'w') location.href = '/';
if (e.key.toLowerCase() === 'm') location.href = '/mosaic.html';
if (e.key.toLowerCase() === 's' && !e.metaKey && !e.ctrlKey) location.href = '/stats.html';
if (e.key.toLowerCase() === 't') location.href = '/today.html';
if (e.key === '/') { e.preventDefault(); location.href = '/search.html'; }
if (e.key.toLowerCase() === 'x') location.href = '/matrix.html';
});
function escape(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
}
load();
setInterval(load, 30 * 1000); // live-ish: re-scan pm2 every 30s
})();
</script>
</body>
</html>