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:
parent
af8707644f
commit
ca765544ce
|
|
@ -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,''))} <span style="opacity:0.3">//</span> ${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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue