← 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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
  }

  load();
  setInterval(load, 30 * 1000); // live-ish: re-scan pm2 every 30s
})();
</script>
</body>
</html>