posimai-root/posimai-dev/station.html

942 lines
36 KiB
HTML

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex, nofollow">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<title>posimai-station</title>
<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=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0C1221;
--surface: #131926;
--surface2: #1A2235;
--border: rgba(255,255,255,0.07);
--text: #F1F5F9;
--text2: #94A3B8;
--text3: #64748B;
--accent: #22D3EE;
--violet: #A78BFA;
--ok: #4ADE80;
--warn: #FB923C;
--crit: #F87171;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
overflow: hidden;
user-select: none;
}
/* Aurora */
.aurora {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 0;
}
.aurora-blob {
position: absolute;
border-radius: 50%;
will-change: transform;
}
.aurora-blob-1 {
width: 900px; height: 600px;
background: radial-gradient(ellipse, rgba(34,211,238,0.13) 0%, transparent 68%);
top: -150px; right: -150px;
filter: blur(100px);
animation: aurora-drift-1 22s ease-in-out infinite alternate;
}
.aurora-blob-2 {
width: 700px; height: 500px;
background: radial-gradient(ellipse, rgba(167,139,250,0.10) 0%, transparent 68%);
bottom: -120px; left: -100px;
filter: blur(90px);
animation: aurora-drift-2 28s ease-in-out infinite alternate;
}
.aurora-blob-3 {
width: 500px; height: 400px;
background: radial-gradient(ellipse, rgba(34,211,238,0.07) 0%, transparent 68%);
top: 40%; right: 20%;
filter: blur(80px);
animation: aurora-drift-3 18s ease-in-out infinite alternate;
}
@keyframes aurora-drift-1 {
from { transform: translate(0,0) scale(1); }
to { transform: translate(-80px,60px) scale(1.1); }
}
@keyframes aurora-drift-2 {
from { transform: translate(0,0) scale(1); }
to { transform: translate(60px,-40px) scale(1.08); }
}
@keyframes aurora-drift-3 {
from { transform: translate(0,0) scale(1); }
to { transform: translate(-40px,50px) scale(0.95); }
}
/* ── Alert banner ──────────────────────────────────────── */
#alert-bar {
display: none;
position: relative;
z-index: 2;
align-items: center;
gap: 10px;
padding: 8px 16px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
margin-bottom: 12px;
animation: alert-appear 0.3s ease;
}
#alert-bar.visible { display: flex; }
#alert-bar.warn {
background: rgba(251,146,60,0.12);
border: 1px solid rgba(251,146,60,0.3);
color: var(--warn);
}
#alert-bar.crit {
background: rgba(248,113,113,0.12);
border: 1px solid rgba(248,113,113,0.35);
color: var(--crit);
animation: alert-appear 0.3s ease, alert-pulse 2s ease-in-out infinite;
}
@keyframes alert-appear {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes alert-pulse {
0%, 100% { border-color: rgba(248,113,113,0.35); }
50% { border-color: rgba(248,113,113,0.7); }
}
#alert-bar svg { width: 14px; height: 14px; flex-shrink: 0; }
#alert-messages { flex: 1; }
/* ── Layout ────────────────────────────────────────────── */
#app {
position: relative;
z-index: 1;
height: 100vh;
display: grid;
grid-template-rows: auto auto 1fr auto;
padding: 24px;
gap: 0;
}
/* Top row */
#top {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding-bottom: 16px;
}
#hostname-area {
display: flex;
align-items: center;
gap: 10px;
}
#hostname {
font-size: 13px;
font-weight: 500;
color: var(--text3);
letter-spacing: 0.08em;
text-transform: uppercase;
}
#status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--text3);
flex-shrink: 0;
transition: background 0.4s;
}
#status-dot.ok { background: var(--ok); box-shadow: 0 0 8px var(--ok); animation: pulse-dot 2.4s ease-in-out infinite; }
#status-dot.warn { background: var(--warn); box-shadow: 0 0 8px var(--warn); }
#status-dot.crit { background: var(--crit); box-shadow: 0 0 8px var(--crit); animation: pulse-dot 1s ease-in-out infinite; }
#status-dot.off { background: var(--text3); box-shadow: none; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Clock */
#clock-area { text-align: center; }
#clock {
font-family: 'JetBrains Mono', monospace;
font-size: clamp(52px, 6vw, 88px);
font-weight: 300;
letter-spacing: -0.03em;
line-height: 1;
}
#date {
font-size: 13px;
color: var(--text3);
margin-top: 4px;
letter-spacing: 0.06em;
}
#last-checked {
text-align: right;
font-size: 12px;
color: var(--text3);
}
/* ── Middle ─────────────────────────────────────────────── */
#middle {
display: grid;
grid-template-columns: 340px 1fr 220px;
gap: 16px;
min-height: 0;
}
/* ── Panels ─────────────────────────────────────────────── */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.panel-title {
font-size: 11px;
font-weight: 600;
color: var(--text3);
letter-spacing: 0.1em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.panel-title svg { width: 14px; height: 14px; }
/* ── Metric bars ─────────────────────────────────────────── */
.metric-item { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; }
.metric-header-row {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.metric-label { font-size: 12px; color: var(--text2); }
.metric-val {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
}
.bar-track {
height: 5px;
border-radius: 3px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
transition: width 0.8s cubic-bezier(0.4,0,0.2,1), background 0.4s;
}
.bar-fill.warn { background: var(--warn); }
.bar-fill.crit { background: var(--crit); }
/* ── Load average row ────────────────────────────────────── */
.load-row {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.load-chip {
flex: 1;
background: var(--surface2);
border-radius: 8px;
padding: 8px;
text-align: center;
}
.load-chip-label { font-size: 10px; color: var(--text3); margin-bottom: 3px; }
.load-chip-val {
font-family: 'JetBrains Mono', monospace;
font-size: 15px;
font-weight: 500;
}
.load-chip-val.warn { color: var(--warn); }
.load-chip-val.crit { color: var(--crit); }
/* ── Stat grid ───────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
flex-shrink: 0;
}
.stat-card {
background: var(--surface2);
border-radius: 10px;
padding: 10px 12px;
}
.stat-label { font-size: 10px; color: var(--text3); margin-bottom: 3px; }
.stat-val {
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.02em;
}
/* ── Open button ─────────────────────────────────────────── */
.open-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px;
background: rgba(34,211,238,0.08);
border: 1px solid rgba(34,211,238,0.2);
border-radius: 10px;
color: var(--accent);
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: background 0.2s, border-color 0.2s;
margin-top: auto;
flex-shrink: 0;
}
.open-btn:hover { background: rgba(34,211,238,0.14); border-color: rgba(34,211,238,0.35); }
.open-btn svg { width: 14px; height: 14px; }
/* ── Service grid ────────────────────────────────────────── */
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
overflow-y: auto;
flex: 1;
}
.service-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.service-card-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.service-name { font-size: 13px; font-weight: 500; }
.service-badge {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
padding: 2px 7px;
border-radius: 20px;
}
.service-badge.ok { background: rgba(74,222,128,0.15); color: var(--ok); }
.service-badge.warn { background: rgba(251,146,60,0.15); color: var(--warn); }
.service-badge.crit { background: rgba(248,113,113,0.15); color: var(--crit); }
.service-badge.off { background: rgba(100,116,139,0.15); color: var(--text3); }
.service-badge.checking { background: rgba(34,211,238,0.10); color: var(--accent); }
.service-desc { font-size: 11px; color: var(--text3); line-height: 1.4; }
.service-latency {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text3);
}
/* ── Bottom bar ──────────────────────────────────────────── */
#bottom {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.bottom-brand { font-size: 12px; color: var(--text3); font-weight: 500; letter-spacing: 0.04em; }
.bottom-brand span { color: var(--accent); }
.bottom-links { display: flex; gap: 8px; }
.bottom-link {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text2);
text-decoration: none;
font-size: 12px;
transition: color 0.2s, border-color 0.2s;
}
.bottom-link:hover { color: var(--accent); border-color: rgba(34,211,238,0.3); }
.bottom-link svg { width: 12px; height: 12px; }
#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>
</head>
<body>
<div class="aurora">
<div class="aurora-blob aurora-blob-1"></div>
<div class="aurora-blob aurora-blob-2"></div>
<div class="aurora-blob aurora-blob-3"></div>
</div>
<div id="app">
<!-- Top -->
<div id="top">
<div id="hostname-area">
<div id="status-dot" class="off"></div>
<span id="hostname"></span>
</div>
<div id="clock-area">
<div id="clock">00:00:00</div>
<div id="date"></div>
</div>
<div id="last-checked"></div>
</div>
<!-- Alert banner -->
<div id="alert-bar">
<i data-lucide="alert-triangle"></i>
<span id="alert-messages"></span>
</div>
<!-- Middle -->
<div id="middle">
<!-- System metrics -->
<div class="panel" id="metrics-panel">
<div class="panel-title"><i data-lucide="cpu"></i>Ubuntu PC</div>
<div class="metric-item">
<div class="metric-header-row">
<span class="metric-label">CPU</span>
<span class="metric-val" id="cpu-val"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="cpu-bar" style="width:0%"></div></div>
</div>
<div class="metric-item">
<div class="metric-header-row">
<span class="metric-label">Memory</span>
<span class="metric-val" id="mem-val"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="mem-bar" style="width:0%"></div></div>
</div>
<div class="metric-item">
<div class="metric-header-row">
<span class="metric-label">Disk (/)</span>
<span class="metric-val" id="disk-val"></span>
</div>
<div class="bar-track"><div class="bar-fill" id="disk-bar" style="width:0%"></div></div>
</div>
<div>
<div class="metric-label" style="margin-bottom:8px">Load Average <span id="cpu-count-label" style="color:var(--text3);font-size:10px"></span></div>
<div class="load-row">
<div class="load-chip">
<div class="load-chip-label">1 min</div>
<div class="load-chip-val" id="load-1"></div>
</div>
<div class="load-chip">
<div class="load-chip-label">5 min</div>
<div class="load-chip-val" id="load-5"></div>
</div>
<div class="load-chip">
<div class="load-chip-label">15 min</div>
<div class="load-chip-val" id="load-15"></div>
</div>
</div>
</div>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-label">Uptime</div>
<div class="stat-val" id="uptime-val"></div>
</div>
<div class="stat-card">
<div class="stat-label">Sessions</div>
<div class="stat-val" id="sessions-val"></div>
</div>
<div class="stat-card">
<div class="stat-label">Node.js</div>
<div class="stat-val" id="node-val"></div>
</div>
<div class="stat-card">
<div class="stat-label">Platform</div>
<div class="stat-val" id="platform-val"></div>
</div>
</div>
<a class="open-btn" href="/" target="_blank" rel="noopener">
<i data-lucide="terminal"></i>
posimai-dev を開く
</a>
</div>
<!-- Services -->
<div class="panel">
<div class="panel-title"><i data-lucide="radio"></i>Services</div>
<div class="service-grid" id="service-grid"></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>
<!-- Bottom -->
<div id="bottom">
<div class="bottom-brand">posimai<span>-station</span></div>
<div class="bottom-links">
<a class="bottom-link" href="/" target="_blank" rel="noopener">
<i data-lucide="terminal"></i> dev
</a>
<a class="bottom-link" href="https://posimai-atlas.vercel.app" target="_blank" rel="noopener">
<i data-lucide="network"></i> atlas
</a>
<a class="bottom-link" href="https://posimai-dashboard.vercel.app" target="_blank" rel="noopener">
<i data-lucide="layout-dashboard"></i> dashboard
</a>
</div>
<div id="refresh-countdown">次の更新まで <span id="countdown">30</span>s</div>
</div>
</div>
<script>
'use strict';
const HEALTH_URL = '/api/health';
const REFRESH_SEC = 30;
const SERVICES = [
{ id: 'posimai-dev', name: 'posimai-dev', desc: 'ブラウザターミナル + Claude Code', url: HEALTH_URL, isHealth: true },
{ id: 'posimai-api', name: 'Posimai API', desc: 'Node.js / Express — VPS 本番', url: 'https://api.soar-enrich.com', isHealth: false },
{ id: 'gitea', name: 'Gitea', desc: 'ローカル Git バックアップ', url: 'http://100.76.7.3:3000', isHealth: false },
{ id: 'syncthing', name: 'Syncthing', desc: 'ファイル同期 GUI', url: 'http://100.77.11.43:8384', isHealth: false },
{ id: 'vercel', name: 'Vercel', desc: 'PWA ホスティング (27 本)', url: 'https://vercel.com', isHealth: false },
{ id: 'github', name: 'GitHub', desc: 'ソースコード管理', url: 'https://github.com/posimai', isHealth: false },
];
// ── Clock ──────────────────────────────────────────────────────────
function updateClock() {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
document.getElementById('clock').textContent =
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
const days = ['日','月','火','水','木','金','土'];
const y = now.getFullYear();
const mo = pad(now.getMonth() + 1);
const d = pad(now.getDate());
document.getElementById('date').textContent = `${y}.${mo}.${d} (${days[now.getDay()]})`;
}
setInterval(updateClock, 1000);
updateClock();
// ── Uptime ────────────────────────────────────────────────────────
function formatUptime(s) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
// ── Alert banner ──────────────────────────────────────────────────
function setAlerts(alerts) {
const bar = document.getElementById('alert-bar');
const msgs = document.getElementById('alert-messages');
if (alerts.length === 0) {
bar.className = '';
bar.classList.remove('visible');
return;
}
const hasCrit = alerts.some(a => a.level === 'crit');
bar.className = hasCrit ? 'crit visible' : 'warn visible';
msgs.textContent = alerts.map(a => a.msg).join(' / ');
if (window.lucide) lucide.createIcons({ nodes: [bar] });
}
// ── Health fetch ──────────────────────────────────────────────────
async function fetchHealth() {
try {
const res = await fetch(HEALTH_URL);
if (!res.ok) throw new Error();
const data = await res.json();
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 cpuCount = data.cpu_count || 1;
const loadAvg = data.load_avg || [0, 0, 0];
// CPU
document.getElementById('cpu-val').textContent = `${cpuPct}%`;
const cpuBar = document.getElementById('cpu-bar');
cpuBar.style.width = `${cpuPct}%`;
cpuBar.className = 'bar-fill' + (cpuPct > 80 ? ' crit' : cpuPct > 60 ? ' warn' : '');
// Memory
document.getElementById('mem-val').textContent =
`${data.mem_used_mb} / ${data.mem_total_mb} MB (${memPct}%)`;
const memBar = document.getElementById('mem-bar');
memBar.style.width = `${memPct}%`;
memBar.className = 'bar-fill' + (memPct > 85 ? ' crit' : memPct > 65 ? ' warn' : '');
// Disk
if (diskPct !== null) {
document.getElementById('disk-val').textContent =
`${data.disk.used_gb} / ${data.disk.total_gb} GB (${diskPct}%)`;
const diskBar = document.getElementById('disk-bar');
diskBar.style.width = `${diskPct}%`;
diskBar.className = 'bar-fill' + (diskPct > 90 ? ' crit' : diskPct > 75 ? ' warn' : '');
}
// Load average — warn if > cpu count, crit if > cpu count * 1.5
document.getElementById('cpu-count-label').textContent = `(コア数: ${cpuCount})`;
['load-1','load-5','load-15'].forEach((id, i) => {
const el = document.getElementById(id);
const val = loadAvg[i] || 0;
el.textContent = val.toFixed(2);
el.className = 'load-chip-val'
+ (val > cpuCount * 1.5 ? ' crit' : val > cpuCount ? ' warn' : '');
});
// Stats
document.getElementById('uptime-val').textContent = formatUptime(data.uptime_s || 0);
document.getElementById('sessions-val').textContent = String(data.active_sessions || 0);
document.getElementById('node-val').textContent = (data.node_version || '—').replace('v','');
document.getElementById('platform-val').textContent = data.platform || '—';
document.getElementById('hostname').textContent = data.hostname || 'ubuntu-pc';
// Alerts
const alerts = [];
if (cpuPct > 80) alerts.push({ level:'crit', msg:`CPU ${cpuPct}% — 高負荷` });
else if (cpuPct > 60) alerts.push({ level:'warn', msg:`CPU ${cpuPct}%` });
if (memPct > 85) alerts.push({ level:'crit', msg:`メモリ ${memPct}% — OOM に注意` });
else if (memPct > 65) alerts.push({ level:'warn', msg:`メモリ ${memPct}%` });
if (diskPct > 90) alerts.push({ level:'crit', msg:`ディスク ${diskPct}% — 残りわずか` });
else if (diskPct > 75) alerts.push({ level:'warn', msg:`ディスク ${diskPct}%` });
if (loadAvg[0] > cpuCount * 1.5)
alerts.push({ level:'crit', msg:`Load ${loadAvg[0].toFixed(2)} — コア数超過` });
else if (loadAvg[0] > cpuCount)
alerts.push({ level:'warn', msg:`Load ${loadAvg[0].toFixed(2)}` });
setAlerts(alerts);
// Status dot
const hasCrit = alerts.some(a => a.level === 'crit');
const hasWarn = alerts.some(a => a.level === 'warn');
document.getElementById('status-dot').className =
hasCrit ? 'crit' : hasWarn ? 'warn' : 'ok';
updateStream(data);
return true;
} catch (e) {
document.getElementById('status-dot').className = 'off';
setAlerts([{ level:'crit', msg:'Ubuntu PC に接続できません' }]);
return false;
}
}
// ── Service cards ─────────────────────────────────────────────────
function buildServiceCards() {
const grid = document.getElementById('service-grid');
grid.innerHTML = '';
SERVICES.forEach(svc => {
const card = document.createElement('div');
card.className = 'service-card';
card.id = `svc-${svc.id}`;
card.innerHTML = `
<div class="service-card-top">
<span class="service-name">${svc.name}</span>
<span class="service-badge checking" id="badge-${svc.id}">...</span>
</div>
<span class="service-desc">${svc.desc}</span>
<span class="service-latency" id="lat-${svc.id}"></span>`;
grid.appendChild(card);
});
}
async function checkService(svc) {
const badge = document.getElementById(`badge-${svc.id}`);
const latEl = document.getElementById(`lat-${svc.id}`);
if (!badge) return;
const t0 = Date.now();
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 7000);
await fetch(svc.url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal });
clearTimeout(timer);
badge.className = 'service-badge ok';
badge.textContent = 'OK';
latEl.textContent = `${Date.now() - t0}ms`;
} catch (e) {
badge.className = 'service-badge crit';
badge.textContent = 'DOWN';
latEl.textContent = '';
}
}
async function checkAllServices(devOk) {
for (const svc of SERVICES) {
if (svc.isHealth) {
const badge = document.getElementById(`badge-${svc.id}`);
if (badge) {
badge.className = devOk ? 'service-badge ok' : 'service-badge crit';
badge.textContent = devOk ? 'OK' : 'DOWN';
}
} else {
checkService(svc);
}
}
}
// ── Countdown ─────────────────────────────────────────────────────
let countdownVal = REFRESH_SEC;
let refreshTimer = null;
function startCountdown() {
countdownVal = REFRESH_SEC;
document.getElementById('countdown').textContent = countdownVal;
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
countdownVal--;
document.getElementById('countdown').textContent = countdownVal;
if (countdownVal <= 0) {
clearInterval(refreshTimer);
runRefresh();
}
}, 1000);
}
async function runRefresh() {
const devOk = await fetchHealth();
checkAllServices(devOk);
const now = new Date().toLocaleTimeString('ja-JP', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
document.getElementById('last-checked').textContent = `最終更新: ${now}`;
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 ──────────────────────────────────────────────────────────
buildServiceCards();
if (window.lucide) lucide.createIcons();
runRefresh();
</script>
</body>
</html>