feat(station): binary stream panel with real metrics encoded as bits

New right-column panel: each metric (CPU, memory, disk, load, uptime,
sessions, unix timestamp) shown as actual binary bits + human value +
mini bar. Rotates through rows every 4s. IP scrolls as binary ticker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-03-31 10:09:04 +09:00
parent af8707644f
commit ca765544ce
1 changed files with 201 additions and 1 deletions

View File

@ -194,7 +194,7 @@
/* ── Middle ─────────────────────────────────────────────── */ /* ── Middle ─────────────────────────────────────────────── */
#middle { #middle {
display: grid; display: grid;
grid-template-columns: 340px 1fr; grid-template-columns: 340px 1fr 220px;
gap: 16px; gap: 16px;
min-height: 0; min-height: 0;
} }
@ -384,6 +384,103 @@
.bottom-link:hover { color: var(--accent); border-color: rgba(34,211,238,0.3); } .bottom-link:hover { color: var(--accent); border-color: rgba(34,211,238,0.3); }
.bottom-link svg { width: 12px; height: 12px; } .bottom-link svg { width: 12px; height: 12px; }
#refresh-countdown { font-size: 12px; color: var(--text3); } #refresh-countdown { font-size: 12px; color: var(--text3); }
/* ── Binary stream panel ─────────────────────────────────── */
#stream-panel {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
#stream-feed {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0;
position: relative;
}
.stream-row {
display: flex;
flex-direction: column;
gap: 5px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
animation: stream-in 0.5s cubic-bezier(0.4,0,0.2,1);
flex-shrink: 0;
}
.stream-row:last-child { border-bottom: none; }
@keyframes stream-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.stream-key {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--text3);
text-transform: uppercase;
}
.stream-binary {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent);
letter-spacing: 0.08em;
opacity: 0.7;
word-break: break-all;
line-height: 1.6;
}
.stream-binary .bit-1 { color: var(--accent); opacity: 1; }
.stream-binary .bit-0 { color: var(--text3); opacity: 0.4; }
.stream-human {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
}
.stream-bar {
flex: 1;
height: 3px;
border-radius: 2px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.stream-bar-fill {
height: 100%;
border-radius: 2px;
background: var(--accent);
transition: width 0.6s ease;
}
.stream-bar-fill.warn { background: var(--warn); }
.stream-bar-fill.crit { background: var(--crit); }
/* Ticker at bottom of stream panel */
#stream-ticker {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text3);
padding-top: 8px;
overflow: hidden;
white-space: nowrap;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
#stream-ticker-inner {
display: inline-block;
animation: ticker-scroll 28s linear infinite;
}
@keyframes ticker-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
</style> </style>
</head> </head>
<body> <body>
@ -495,6 +592,15 @@
<div class="service-grid" id="service-grid"></div> <div class="service-grid" id="service-grid"></div>
</div> </div>
<!-- Binary stream -->
<div class="panel" id="stream-panel">
<div class="panel-title"><i data-lucide="binary"></i>Stream</div>
<div id="stream-feed"></div>
<div id="stream-ticker">
<span id="stream-ticker-inner"></span>
</div>
</div>
</div> </div>
<!-- Bottom --> <!-- Bottom -->
@ -645,6 +751,8 @@ async function fetchHealth() {
document.getElementById('status-dot').className = document.getElementById('status-dot').className =
hasCrit ? 'crit' : hasWarn ? 'warn' : 'ok'; hasCrit ? 'crit' : hasWarn ? 'warn' : 'ok';
updateStream(data);
return true; return true;
} catch (e) { } catch (e) {
document.getElementById('status-dot').className = 'off'; document.getElementById('status-dot').className = 'off';
@ -732,6 +840,98 @@ async function runRefresh() {
startCountdown(); startCountdown();
} }
// ── Binary stream ─────────────────────────────────────────────────
function toBin(n, bits) {
return (n >>> 0).toString(2).padStart(bits, '0').slice(-bits);
}
function renderBinSpans(binStr) {
return binStr.split('').map(b =>
`<span class="bit-${b}">${b}</span>`
).join('');
}
function formatBinDisplay(binStr) {
// group into nibbles: 0000 1010 0011 ...
return binStr.match(/.{1,4}/g).join(' ');
}
let streamData = null;
function pushStreamRow(key, label, value, binStr, pct, level) {
const feed = document.getElementById('stream-feed');
const row = document.createElement('div');
row.className = 'stream-row';
const binGrouped = formatBinDisplay(binStr);
row.innerHTML = `
<div class="stream-key">${label}</div>
<div class="stream-binary">${renderBinSpans(binGrouped.replace(/ /g,''))}&nbsp;&nbsp;<span style="opacity:0.3">//</span>&nbsp;&nbsp;${binGrouped}</div>
<div class="stream-human">
<span>${value}</span>
${pct !== null ? `<div class="stream-bar"><div class="stream-bar-fill${level ? ' '+level : ''}" style="width:${pct}%"></div></div>` : ''}
</div>`;
// Prepend so newest is at top
feed.insertBefore(row, feed.firstChild);
// Keep max 8 rows
while (feed.children.length > 8) feed.removeChild(feed.lastChild);
}
function updateStream(data) {
if (!data) return;
streamData = data;
const cpuPct = data.cpu_pct || 0;
const memPct = data.mem_total_mb
? Math.round((data.mem_used_mb / data.mem_total_mb) * 100) : 0;
const diskPct = data.disk?.use_pct ?? null;
const load1 = (data.load_avg && data.load_avg[0]) || 0;
const uptime = data.uptime_s || 0;
const sessions = data.active_sessions || 0;
// Unix timestamp (lower 16 bits)
const ts = Math.floor(Date.now() / 1000);
const rows = [
{ key:'ts', label:'UNIX TIME', value: String(ts),
bin: toBin(ts & 0xFFFF, 16), pct: null, level: null },
{ key:'cpu', label:'CPU USAGE', value: `${cpuPct}%`,
bin: toBin(cpuPct, 8), pct: cpuPct,
level: cpuPct > 80 ? 'crit' : cpuPct > 60 ? 'warn' : null },
{ key:'mem', label:'MEMORY', value: `${memPct}%`,
bin: toBin(memPct, 8), pct: memPct,
level: memPct > 85 ? 'crit' : memPct > 65 ? 'warn' : null },
{ key:'load', label:'LOAD AVG', value: load1.toFixed(2),
bin: toBin(Math.round(load1 * 100) & 0xFF, 8), pct: null, level: null },
...(diskPct !== null ? [{ key:'disk', label:'DISK /', value: `${diskPct}%`,
bin: toBin(diskPct, 8), pct: diskPct,
level: diskPct > 90 ? 'crit' : diskPct > 75 ? 'warn' : null }] : []),
{ key:'ses', label:'SESSIONS', value: String(sessions),
bin: toBin(sessions, 8), pct: null, level: null },
{ key:'upt', label:'UPTIME', value: formatUptime(uptime),
bin: toBin(Math.floor(uptime / 60) & 0xFFFF, 16), pct: null, level: null },
];
// Add one new row each call (rotate through)
const idx = Math.floor(Date.now() / 1000) % rows.length;
const r = rows[idx];
pushStreamRow(r.key, r.label, r.value, r.bin, r.pct, r.level);
// Ticker: IP + hostname + node version in binary
updateTicker(data);
}
function updateTicker(data) {
const ip = '100.77.11.43';
const ipBin = ip.split('.').map(o => toBin(parseInt(o), 8)).join(' ');
const base = `${ipBin} // ${data.hostname || 'ubuntu-pc'} // ${data.node_version || ''} // platform:${data.platform || ''} // `;
const doubled = base + base; // loop seamlessly
const el = document.getElementById('stream-ticker-inner');
if (el) el.textContent = doubled;
}
// Tick stream every 4 seconds even between full refreshes
setInterval(() => { if (streamData) updateStream(streamData); }, 4000);
// ── Init ────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────
buildServiceCards(); buildServiceCards();
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();