<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>switchboard activity โ wire</title>
<meta name="description" content="Live + 24h activity on the wire public-good relay at wireup.net. Aggregate counts only; no identities exposed.">
<meta name="theme-color" content="#5B1A2E">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT@0,9..144,400..900,30..100;1,9..144,400..900,30..100&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--paper: #EEE3CE;
--paper-shadow: #D9C8A7;
--paper-deep: #C8B58E;
--ink: #241712;
--ink-soft: #4a3a32;
--frame: #5B1A2E;
--frame-deep: #401020;
--dial: #8FB04A;
--dial-deep: #6f8e34;
--accent-warm: #E07A2B;
--phosphor: #7FFFB0;
--phosphor-bg: #0B130D;
--font-heading: "Fraunces", "Cooper BT", "Cooper Black", Georgia, serif;
--font-body: "Inter", "Helvetica Neue", system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "JetBrains Mono", monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { background: var(--frame); }
body {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.55;
color: var(--ink);
background:
radial-gradient(circle at 12% 0%, rgba(91,26,46,0.12), transparent 40%),
radial-gradient(circle at 88% 100%, rgba(31,61,44,0.10), transparent 45%),
var(--paper);
min-height: 100vh;
padding: 28px 20px 100px;
}
nav.tabs {
max-width: 980px;
margin: 0 auto 18px;
display: flex;
gap: 0;
border: 2px solid var(--frame);
border-radius: 8px;
overflow: hidden;
background: var(--paper-shadow);
position: relative;
z-index: 2;
}
nav.tabs a {
flex: 1;
text-align: center;
padding: 10px 8px;
font-family: var(--font-heading);
font-style: italic;
font-variation-settings: "wght" 600, "SOFT" 60;
font-size: 14px;
letter-spacing: 0.04em;
color: var(--ink);
text-decoration: none;
border-right: 2px solid var(--frame);
}
nav.tabs a:last-child { border-right: none; }
nav.tabs a:hover { background: var(--paper); }
nav.tabs a.current { background: var(--frame); color: var(--paper); }
body::before {
content: ""; position: fixed; inset: 0; pointer-events: none;
opacity: 0.32; mix-blend-mode: multiply; z-index: 1;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' seed='4'/><feColorMatrix values='0 0 0 0 0.14 0 0 0 0 0.09 0 0 0 0 0.07 0 0 0 0.08 0'/></filter><rect width='240' height='240' filter='url(%23n)'/></svg>");
}
main { max-width: 980px; margin: 0 auto; position: relative; z-index: 2; }
.frame {
background: var(--frame);
border-radius: 22px;
padding: 14px;
box-shadow: 0 2px 0 rgba(0,0,0,0.15) inset, 0 -1px 0 rgba(255,255,255,0.06) inset, 0 14px 40px rgba(31,12,18,0.30);
position: relative;
}
.frame::before, .frame::after {
content: ""; position: absolute; width: 18px; height: 18px;
border: 2px dashed rgba(255,255,255,0.18); border-radius: 50%;
}
.frame::before { top: 12px; left: 12px; }
.frame::after { bottom: 12px; right: 12px; }
.parchment {
background: var(--paper);
border-radius: 12px;
padding: 40px 48px 48px;
box-shadow: 0 0 0 1px rgba(36,23,18,0.08), 0 1px 0 rgba(255,255,255,0.55) inset;
}
.masthead { padding-bottom: 22px; border-bottom: 1px dashed rgba(36,23,18,0.22); margin-bottom: 28px; }
h1 {
font-family: var(--font-heading);
font-variation-settings: "opsz" 96, "SOFT" 100, "wght" 800;
font-style: italic;
font-size: clamp(36px, 6vw, 64px);
line-height: 0.92;
letter-spacing: -0.03em;
color: var(--ink);
margin: 0;
}
h1 .dot {
display: inline-block; width: 0.16em; height: 0.16em; border-radius: 50%;
background: var(--dial); margin-left: 0.06em; vertical-align: 0.16em;
box-shadow: 0 0 0 4px rgba(143,176,74,0.18);
animation: blink 2.4s ease-in-out infinite;
}
@keyframes blink { 0%, 60% { opacity: 1; } 75% { opacity: 0.45; } 100% { opacity: 1; } }
.sub {
font-family: var(--font-heading);
font-style: italic;
font-size: 18px;
color: var(--ink-soft);
margin: 8px 0 0;
}
.sub a { color: var(--ink); text-decoration-color: var(--dial); }
.live-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 16px;
margin-bottom: 36px;
}
.card {
background: rgba(238,227,206,0.55);
border: 1.5px solid rgba(91,26,46,0.18);
border-radius: 10px;
padding: 18px 20px 16px;
position: relative;
}
.card .label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--ink-soft);
margin: 0 0 6px;
}
.card .value {
font-family: var(--font-heading);
font-variation-settings: "opsz" 72, "SOFT" 60, "wght" 700;
font-style: italic;
font-size: 44px;
line-height: 1;
color: var(--ink);
margin: 0;
}
.card .unit {
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-soft);
margin-top: 6px;
}
h2 {
font-family: var(--font-heading);
font-variation-settings: "opsz" 60, "SOFT" 80, "wght" 700;
font-style: italic;
font-size: 28px;
color: var(--ink);
margin: 0 0 6px;
letter-spacing: -0.01em;
}
h2 .num {
font-family: var(--font-mono);
font-style: normal;
font-size: 12px;
font-weight: 500;
color: var(--frame);
background: var(--paper-shadow);
padding: 3px 9px;
border-radius: 4px;
margin-right: 12px;
vertical-align: 0.3em;
letter-spacing: 0.06em;
}
.section-blurb {
font-family: var(--font-heading);
font-style: italic;
font-size: 15px;
color: var(--ink-soft);
margin: 0 0 18px;
max-width: 64ch;
}
.spark-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 18px;
}
.spark {
background: rgba(238,227,206,0.45);
border: 1.5px solid rgba(91,26,46,0.12);
border-radius: 10px;
padding: 16px 18px 14px;
}
.spark .label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-soft);
margin: 0 0 2px;
}
.spark .value {
font-family: var(--font-heading);
font-variation-settings: "opsz" 60, "wght" 700;
font-style: italic;
font-size: 26px;
color: var(--ink);
margin: 0;
line-height: 1;
}
.spark .delta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--dial-deep);
margin-left: 6px;
}
.spark svg {
display: block;
width: 100%;
height: 56px;
margin-top: 10px;
}
.spark .sparkline { fill: none; stroke: var(--dial-deep); stroke-width: 2; stroke-linejoin: round; }
.spark .sparkfill { fill: var(--dial); opacity: 0.18; }
footer {
margin-top: 36px;
padding-top: 18px;
border-top: 1px dashed rgba(36,23,18,0.22);
font-size: 13px;
color: var(--ink-soft);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
footer a { color: var(--ink); }
footer .meta {
font-family: var(--font-mono);
font-size: 12px;
}
.pulse {
display: inline-block;
width: 8px; height: 8px; border-radius: 50%;
background: var(--dial);
vertical-align: middle;
margin-right: 6px;
animation: blink 2.4s ease-in-out infinite;
}
@media (max-width: 600px) {
.parchment { padding: 28px 22px 36px; }
.card .value { font-size: 36px; }
}
</style>
</head>
<body>
<main>
<nav class="tabs" aria-label="primary">
<a href="/">home</a>
<a href="/phonebook">phonebook</a>
<a href="/stats" class="current">stats</a>
</nav>
<div class="frame">
<div class="parchment">
<header class="masthead">
<h1>switchboard activity<span class="dot"></span></h1>
<p class="sub">Live counts from the wire public-good relay at <a href="/">wireup.net</a>. Aggregates only โ no DIDs, no IPs, no handles.</p>
</header>
<div class="live-row">
<div class="card"><p class="label">Handles claimed</p><p class="value" id="m-handles">โ</p><p class="unit">currently held</p></div>
<div class="card"><p class="label">Slots</p><p class="value" id="m-slots">โ</p><p class="unit">active mailboxes</p></div>
<div class="card"><p class="label">Pair sessions</p><p class="value" id="m-pairs">โ</p><p class="unit">in-flight handshakes</p></div>
<div class="card"><p class="label">Live streams</p><p class="value" id="m-streams">โ</p><p class="unit">SSE subscribers</p></div>
</div>
<h2><span class="num">ยง 01</span>Last 24 hours</h2>
<p class="section-blurb">Cumulative counters since boot. Each sparkline plots one row per 30 seconds, fetched from <code>/stats.history</code>. The relay restarts on every deploy โ drops in the lines are deploys, not data loss (the snapshot survives on the persistent volume).</p>
<div class="spark-grid">
<div class="spark">
<p class="label">Handle claims</p>
<p class="value"><span id="t-hc">โ</span><span class="delta" id="d-hc"></span></p>
<svg viewBox="0 0 200 56" preserveAspectRatio="none" id="sp-hc"></svg>
</div>
<div class="spark">
<p class="label">Slot allocations</p>
<p class="value"><span id="t-sa">โ</span><span class="delta" id="d-sa"></span></p>
<svg viewBox="0 0 200 56" preserveAspectRatio="none" id="sp-sa"></svg>
</div>
<div class="spark">
<p class="label">Pair opens</p>
<p class="value"><span id="t-po">โ</span><span class="delta" id="d-po"></span></p>
<svg viewBox="0 0 200 56" preserveAspectRatio="none" id="sp-po"></svg>
</div>
<div class="spark">
<p class="label">Events posted</p>
<p class="value"><span id="t-ep">โ</span><span class="delta" id="d-ep"></span></p>
<svg viewBox="0 0 200 56" preserveAspectRatio="none" id="sp-ep"></svg>
</div>
</div>
<footer>
<div><a href="/">โ back to wireup.net</a> ยท <a href="/stats">raw /stats JSON</a> ยท <a href="/stats.history?hours=24">raw /stats.history</a></div>
<div class="meta"><span class="pulse"></span><span id="lastrefresh">loadingโฆ</span> ยท wire <span id="ver">โ</span></div>
</footer>
</div>
</div>
</main>
<script>
(() => {
const $ = id => document.getElementById(id);
const fmt = n => Number(n).toLocaleString();
const ago = sec => {
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec / 60) + 'm';
if (sec < 86400) return Math.floor(sec / 3600) + 'h';
return Math.floor(sec / 86400) + 'd';
};
function sparkline(svgId, points) {
const svg = $(svgId);
if (!svg || !points.length) return;
const W = 200, H = 56;
const min = Math.min(...points);
const max = Math.max(...points);
const range = max - min || 1;
const stepX = points.length > 1 ? W / (points.length - 1) : W;
const coords = points.map((v, i) => {
const x = (i * stepX).toFixed(2);
const y = (H - ((v - min) / range) * (H - 4) - 2).toFixed(2);
return [x, y];
});
const linePath = coords.map(([x, y], i) => (i === 0 ? 'M' : 'L') + x + ',' + y).join(' ');
const fillPath = linePath + ' L' + W + ',' + H + ' L0,' + H + ' Z';
svg.innerHTML =
'<path class="sparkfill" d="' + fillPath + '"/>' +
'<path class="sparkline" d="' + linePath + '"/>';
}
async function refresh() {
try {
const [statsRes, histRes] = await Promise.all([
fetch('/stats', { cache: 'no-store' }),
fetch('/stats.history?hours=24', { cache: 'no-store' }),
]);
const s = await statsRes.json();
const h = await histRes.json();
$('m-handles').textContent = fmt(s.handles_active);
$('m-slots').textContent = fmt(s.slots_active);
$('m-pairs').textContent = fmt(s.pair_slots_open);
$('m-streams').textContent = fmt(s.streams_active);
$('ver').textContent = 'v' + s.version;
$('t-hc').textContent = fmt(s.handle_claims_total);
$('t-sa').textContent = fmt(s.slot_allocations_total);
$('t-po').textContent = fmt(s.pair_opens_total);
$('t-ep').textContent = fmt(s.events_posted_total);
const entries = h.entries || [];
const series = key => entries.map(e => e[key] || 0);
sparkline('sp-hc', series('handle_claims_total'));
sparkline('sp-sa', series('slot_allocations_total'));
sparkline('sp-po', series('pair_opens_total'));
sparkline('sp-ep', series('events_posted_total'));
const delta = key => {
if (entries.length < 2) return '';
const first = entries[0][key] || 0;
const last = entries[entries.length - 1][key] || 0;
const d = last - first;
return d > 0 ? ' +' + fmt(d) + ' / 24h' : '';
};
$('d-hc').textContent = delta('handle_claims_total');
$('d-sa').textContent = delta('slot_allocations_total');
$('d-po').textContent = delta('pair_opens_total');
$('d-ep').textContent = delta('events_posted_total');
const now = new Date();
$('lastrefresh').textContent = 'updated ' + now.toLocaleTimeString() + ' ยท uptime ' + ago(s.uptime_seconds);
} catch (err) {
$('lastrefresh').textContent = 'refresh failed';
console.error(err);
}
}
refresh();
setInterval(refresh, 30000); })();
</script>
</body>
</html>