feat(station): add disk, load average, alert banner to system monitor

server.js: /api/health now includes disk usage (df -B1 /) and load_avg
(os.loadavg) + cpu_count. station.html: disk bar, load average chips
with warn/crit coloring vs cpu count, alert banner highlights issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-03-31 10:02:55 +09:00
parent 7ca153546d
commit af8707644f
2 changed files with 260 additions and 184 deletions

View File

@ -7,6 +7,7 @@ const https = require('https');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const { execSync } = require('child_process');
const app = express(); const app = express();
const PORT = process.env.PORT || 3333; const PORT = process.env.PORT || 3333;
@ -63,13 +64,34 @@ app.get('/api/health', (req, res) => {
return sum + (dTotal > 0 ? (1 - dIdle / dTotal) * 100 : 0); return sum + (dTotal > 0 ? (1 - dIdle / dTotal) * 100 : 0);
}, 0) / s1.length; }, 0) / s1.length;
// Disk usage
let disk = null;
try {
const dfOut = execSync('df -B1 / 2>/dev/null', { timeout: 2000 }).toString();
const parts = dfOut.trim().split('\n')[1].split(/\s+/);
const totalB = parseInt(parts[1]);
const usedB = parseInt(parts[2]);
disk = {
total_gb: Math.round(totalB / 1e9 * 10) / 10,
used_gb: Math.round(usedB / 1e9 * 10) / 10,
use_pct: Math.round(usedB / totalB * 100),
};
} catch (_) {}
// Load average (1 / 5 / 15 min) and CPU count
const loadAvg = os.loadavg();
const cpuCount = os.cpus().length;
res.json({ res.json({
ok: true, ok: true,
hostname: os.hostname(), hostname: os.hostname(),
uptime_s: Math.floor(os.uptime()), uptime_s: Math.floor(os.uptime()),
cpu_pct: Math.round(cpuPct), cpu_pct: Math.round(cpuPct),
cpu_count: cpuCount,
load_avg: loadAvg.map(l => Math.round(l * 100) / 100),
mem_used_mb: Math.round((total - mem) / 1024 / 1024), mem_used_mb: Math.round((total - mem) / 1024 / 1024),
mem_total_mb: Math.round(total / 1024 / 1024), mem_total_mb: Math.round(total / 1024 / 1024),
disk,
active_sessions: wss.clients ? wss.clients.size : 0, active_sessions: wss.clients ? wss.clients.size : 0,
node_version: process.version, node_version: process.version,
platform: os.platform(), platform: os.platform(),

View File

@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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" integrity="sha384-JSBb8BPlCp7GjHPXAy+RrDJ7T8YWLF5Gx8FvyYME4Jx0G7E5H7d2x9K4N6Q8P2" crossorigin="anonymous"></script> <script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@ -85,16 +85,52 @@
to { transform: translate(-40px,50px) scale(0.95); } to { transform: translate(-40px,50px) scale(0.95); }
} }
/* Layout */ /* ── 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 { #app {
position: relative; position: relative;
z-index: 1; z-index: 1;
height: 100vh; height: 100vh;
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto auto 1fr auto;
grid-template-columns: 1fr;
gap: 0;
padding: 24px; padding: 24px;
gap: 0;
} }
/* Top row */ /* Top row */
@ -102,7 +138,7 @@
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
padding-bottom: 20px; padding-bottom: 16px;
} }
#hostname-area { #hostname-area {
@ -118,8 +154,7 @@
text-transform: uppercase; text-transform: uppercase;
} }
#status-dot { #status-dot {
width: 8px; width: 8px; height: 8px;
height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--text3); background: var(--text3);
flex-shrink: 0; flex-shrink: 0;
@ -141,7 +176,6 @@
font-size: clamp(52px, 6vw, 88px); font-size: clamp(52px, 6vw, 88px);
font-weight: 300; font-weight: 300;
letter-spacing: -0.03em; letter-spacing: -0.03em;
color: var(--text);
line-height: 1; line-height: 1;
} }
#date { #date {
@ -151,14 +185,13 @@
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
/* Last checked */
#last-checked { #last-checked {
text-align: right; text-align: right;
font-size: 12px; font-size: 12px;
color: var(--text3); color: var(--text3);
} }
/* Middle: metrics + services */ /* ── Middle ─────────────────────────────────────────────── */
#middle { #middle {
display: grid; display: grid;
grid-template-columns: 340px 1fr; grid-template-columns: 340px 1fr;
@ -166,17 +199,18 @@
min-height: 0; min-height: 0;
} }
/* System metrics panel */ /* ── Panels ─────────────────────────────────────────────── */
#metrics-panel { .panel {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 16px; border-radius: 16px;
padding: 20px; padding: 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 16px;
overflow: hidden; overflow: hidden;
} }
.panel-title { .panel-title {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@ -186,11 +220,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
flex-shrink: 0;
} }
.panel-title svg { width: 14px; height: 14px; } .panel-title svg { width: 14px; height: 14px; }
/* Metric bars */ /* ── Metric bars ─────────────────────────────────────────── */
.metric-item { display: flex; flex-direction: column; gap: 6px; } .metric-item { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; }
.metric-header-row { .metric-header-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -199,12 +234,11 @@
.metric-label { font-size: 12px; color: var(--text2); } .metric-label { font-size: 12px; color: var(--text2); }
.metric-val { .metric-val {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 13px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--text);
} }
.bar-track { .bar-track {
height: 6px; height: 5px;
border-radius: 3px; border-radius: 3px;
background: rgba(255,255,255,0.06); background: rgba(255,255,255,0.06);
overflow: hidden; overflow: hidden;
@ -218,27 +252,49 @@
.bar-fill.warn { background: var(--warn); } .bar-fill.warn { background: var(--warn); }
.bar-fill.crit { background: var(--crit); } .bar-fill.crit { background: var(--crit); }
/* Stat grid */ /* ── 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 { .stat-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 10px; gap: 8px;
flex-shrink: 0;
} }
.stat-card { .stat-card {
background: var(--surface2); background: var(--surface2);
border-radius: 10px; border-radius: 10px;
padding: 12px; padding: 10px 12px;
} }
.stat-label { font-size: 11px; color: var(--text3); margin-bottom: 4px; } .stat-label { font-size: 10px; color: var(--text3); margin-bottom: 3px; }
.stat-val { .stat-val {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 18px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: var(--text);
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
/* Open link */ /* ── Open button ─────────────────────────────────────────── */
.open-btn { .open-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@ -254,22 +310,12 @@
font-weight: 500; font-weight: 500;
transition: background 0.2s, border-color 0.2s; transition: background 0.2s, border-color 0.2s;
margin-top: auto; 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:hover { background: rgba(34,211,238,0.14); border-color: rgba(34,211,238,0.35); }
.open-btn svg { width: 14px; height: 14px; } .open-btn svg { width: 14px; height: 14px; }
/* Services panel */ /* ── Service grid ────────────────────────────────────────── */
#services-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
overflow: hidden;
}
.service-grid { .service-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
@ -285,18 +331,13 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
cursor: default;
} }
.service-card-top { .service-card-top {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.service-name { .service-name { font-size: 13px; font-weight: 500; }
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.service-badge { .service-badge {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@ -309,37 +350,24 @@
.service-badge.crit { background: rgba(248,113,113,0.15); color: var(--crit); } .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.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-badge.checking { background: rgba(34,211,238,0.10); color: var(--accent); }
.service-desc { .service-desc { font-size: 11px; color: var(--text3); line-height: 1.4; }
font-size: 11px;
color: var(--text3);
line-height: 1.4;
}
.service-latency { .service-latency {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 11px; font-size: 11px;
color: var(--text3); color: var(--text3);
} }
/* Bottom bar */ /* ── Bottom bar ──────────────────────────────────────────── */
#bottom { #bottom {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding-top: 16px; padding-top: 14px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.bottom-brand { .bottom-brand { font-size: 12px; color: var(--text3); font-weight: 500; letter-spacing: 0.04em; }
font-size: 12px;
color: var(--text3);
font-weight: 500;
letter-spacing: 0.04em;
}
.bottom-brand span { color: var(--accent); } .bottom-brand span { color: var(--accent); }
.bottom-links { display: flex; gap: 8px; }
.bottom-links {
display: flex;
gap: 8px;
}
.bottom-link { .bottom-link {
display: flex; display: flex;
align-items: center; align-items: center;
@ -355,11 +383,7 @@
} }
.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);
}
</style> </style>
</head> </head>
<body> <body>
@ -371,35 +395,37 @@
</div> </div>
<div id="app"> <div id="app">
<!-- Top row -->
<!-- Top -->
<div id="top"> <div id="top">
<div id="hostname-area"> <div id="hostname-area">
<div id="status-dot" class="off"></div> <div id="status-dot" class="off"></div>
<span id="hostname"></span> <span id="hostname"></span>
</div> </div>
<div id="clock-area"> <div id="clock-area">
<div id="clock">00:00:00</div> <div id="clock">00:00:00</div>
<div id="date"></div> <div id="date"></div>
</div> </div>
<div id="last-checked"></div> <div id="last-checked"></div>
</div> </div>
<!-- Alert banner -->
<div id="alert-bar">
<i data-lucide="alert-triangle"></i>
<span id="alert-messages"></span>
</div>
<!-- Middle --> <!-- Middle -->
<div id="middle"> <div id="middle">
<!-- System metrics --> <!-- System metrics -->
<div id="metrics-panel"> <div class="panel" id="metrics-panel">
<div class="panel-title"> <div class="panel-title"><i data-lucide="cpu"></i>Ubuntu PC</div>
<i data-lucide="cpu"></i>
Ubuntu PC
</div>
<div class="metric-item"> <div class="metric-item">
<div class="metric-header-row"> <div class="metric-header-row">
<span class="metric-label">CPU</span> <span class="metric-label">CPU</span>
<span class="metric-val" id="cpu-val"> %</span> <span class="metric-val" id="cpu-val"></span>
</div> </div>
<div class="bar-track"><div class="bar-fill" id="cpu-bar" style="width:0%"></div></div> <div class="bar-track"><div class="bar-fill" id="cpu-bar" style="width:0%"></div></div>
</div> </div>
@ -407,11 +433,37 @@
<div class="metric-item"> <div class="metric-item">
<div class="metric-header-row"> <div class="metric-header-row">
<span class="metric-label">Memory</span> <span class="metric-label">Memory</span>
<span class="metric-val" id="mem-val"> MB</span> <span class="metric-val" id="mem-val"></span>
</div> </div>
<div class="bar-track"><div class="bar-fill" id="mem-bar" style="width:0%"></div></div> <div class="bar-track"><div class="bar-fill" id="mem-bar" style="width:0%"></div></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-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Uptime</div> <div class="stat-label">Uptime</div>
@ -438,19 +490,16 @@
</div> </div>
<!-- Services --> <!-- Services -->
<div id="services-panel"> <div class="panel">
<div class="panel-title"> <div class="panel-title"><i data-lucide="radio"></i>Services</div>
<i data-lucide="radio"></i>
Services
</div>
<div class="service-grid" id="service-grid"></div> <div class="service-grid" id="service-grid"></div>
</div> </div>
</div> </div>
<!-- Bottom bar --> <!-- Bottom -->
<div id="bottom"> <div id="bottom">
<div class="bottom-brand">posimai<span>-station</span></div> <div class="bottom-brand">posimai<span>-station</span></div>
<div class="bottom-links"> <div class="bottom-links">
<a class="bottom-link" href="/" target="_blank" rel="noopener"> <a class="bottom-link" href="/" target="_blank" rel="noopener">
<i data-lucide="terminal"></i> dev <i data-lucide="terminal"></i> dev
@ -462,9 +511,9 @@
<i data-lucide="layout-dashboard"></i> dashboard <i data-lucide="layout-dashboard"></i> dashboard
</a> </a>
</div> </div>
<div id="refresh-countdown">次の更新まで <span id="countdown">30</span>s</div> <div id="refresh-countdown">次の更新まで <span id="countdown">30</span>s</div>
</div> </div>
</div> </div>
<script> <script>
@ -473,71 +522,32 @@
const HEALTH_URL = '/api/health'; const HEALTH_URL = '/api/health';
const REFRESH_SEC = 30; const REFRESH_SEC = 30;
// Services to monitor
const SERVICES = [ const SERVICES = [
{ { id: 'posimai-dev', name: 'posimai-dev', desc: 'ブラウザターミナル + Claude Code', url: HEALTH_URL, isHealth: true },
id: 'posimai-dev', { id: 'posimai-api', name: 'Posimai API', desc: 'Node.js / Express — VPS 本番', url: 'https://api.soar-enrich.com', isHealth: false },
name: 'posimai-dev', { id: 'gitea', name: 'Gitea', desc: 'ローカル Git バックアップ', url: 'http://100.76.7.3:3000', isHealth: false },
desc: 'ブラウザターミナル + Claude Code', { id: 'syncthing', name: 'Syncthing', desc: 'ファイル同期 GUI', url: 'http://100.77.11.43:8384', isHealth: false },
url: HEALTH_URL, { id: 'vercel', name: 'Vercel', desc: 'PWA ホスティング (27 本)', url: 'https://vercel.com', isHealth: false },
isHealth: true, { id: 'github', name: 'GitHub', desc: 'ソースコード管理', url: 'https://github.com/posimai', isHealth: false },
},
{
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 ───────────────────────────────────────────────────────── // ── Clock ──────────────────────────────────────────────────────────
function updateClock() { function updateClock() {
const now = new Date(); const now = new Date();
const hh = String(now.getHours()).padStart(2, '0'); const pad = n => String(n).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0'); document.getElementById('clock').textContent =
const ss = String(now.getSeconds()).padStart(2, '0'); `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
document.getElementById('clock').textContent = `${hh}:${mm}:${ss}`;
const days = ['日','月','火','水','木','金','土']; const days = ['日','月','火','水','木','金','土'];
const y = now.getFullYear(); const y = now.getFullYear();
const mo = String(now.getMonth() + 1).padStart(2, '0'); const mo = pad(now.getMonth() + 1);
const d = String(now.getDate()).padStart(2, '0'); const d = pad(now.getDate());
const dow = days[now.getDay()]; document.getElementById('date').textContent = `${y}.${mo}.${d} (${days[now.getDay()]})`;
document.getElementById('date').textContent = `${y}.${mo}.${d} (${dow})`;
} }
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
updateClock(); updateClock();
// ── Uptime formatter ────────────────────────────────────────────── // ── Uptime ────────────────────────────────────────────────────────
function formatUptime(s) { function formatUptime(s) {
const d = Math.floor(s / 86400); const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600); const h = Math.floor((s % 86400) / 3600);
@ -547,42 +557,98 @@ function formatUptime(s) {
return `${m}m`; return `${m}m`;
} }
// ── System metrics ──────────────────────────────────────────────── // ── 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() { async function fetchHealth() {
try { try {
const res = await fetch(HEALTH_URL); const res = await fetch(HEALTH_URL);
if (!res.ok) throw new Error('not ok'); if (!res.ok) throw new Error();
const data = await res.json(); const data = await res.json();
const cpuPct = data.cpu_pct || 0; const cpuPct = data.cpu_pct || 0;
const memPct = data.mem_total_mb const memPct = data.mem_total_mb
? Math.round((data.mem_used_mb / data.mem_total_mb) * 100) : 0; ? 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}%`; document.getElementById('cpu-val').textContent = `${cpuPct}%`;
const cpuBar = document.getElementById('cpu-bar'); const cpuBar = document.getElementById('cpu-bar');
cpuBar.style.width = `${cpuPct}%`; cpuBar.style.width = `${cpuPct}%`;
cpuBar.className = 'bar-fill' + (cpuPct > 80 ? ' crit' : cpuPct > 60 ? ' warn' : ''); cpuBar.className = 'bar-fill' + (cpuPct > 80 ? ' crit' : cpuPct > 60 ? ' warn' : '');
// Memory
document.getElementById('mem-val').textContent = document.getElementById('mem-val').textContent =
`${data.mem_used_mb} / ${data.mem_total_mb} MB (${memPct}%)`; `${data.mem_used_mb} / ${data.mem_total_mb} MB (${memPct}%)`;
const memBar = document.getElementById('mem-bar'); const memBar = document.getElementById('mem-bar');
memBar.style.width = `${memPct}%`; memBar.style.width = `${memPct}%`;
memBar.className = 'bar-fill' + (memPct > 85 ? ' crit' : memPct > 65 ? ' warn' : ''); 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('uptime-val').textContent = formatUptime(data.uptime_s || 0);
document.getElementById('sessions-val').textContent = String(data.active_sessions || 0); document.getElementById('sessions-val').textContent = String(data.active_sessions || 0);
document.getElementById('node-val').textContent = (data.node_version || '—').replace('v',''); document.getElementById('node-val').textContent = (data.node_version || '—').replace('v','');
document.getElementById('platform-val').textContent = data.platform || '—'; document.getElementById('platform-val').textContent = data.platform || '—';
document.getElementById('hostname').textContent = data.hostname || 'ubuntu-pc'; 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 // Status dot
const dot = document.getElementById('status-dot'); const hasCrit = alerts.some(a => a.level === 'crit');
dot.className = cpuPct > 80 || memPct > 85 ? 'crit' const hasWarn = alerts.some(a => a.level === 'warn');
: cpuPct > 60 || memPct > 65 ? 'warn' : 'ok'; document.getElementById('status-dot').className =
hasCrit ? 'crit' : hasWarn ? 'warn' : 'ok';
return true; return true;
} catch (e) { } catch (e) {
document.getElementById('status-dot').className = 'off'; document.getElementById('status-dot').className = 'off';
setAlerts([{ level:'crit', msg:'Ubuntu PC に接続できません' }]);
return false; return false;
} }
} }
@ -610,25 +676,15 @@ async function checkService(svc) {
const badge = document.getElementById(`badge-${svc.id}`); const badge = document.getElementById(`badge-${svc.id}`);
const latEl = document.getElementById(`lat-${svc.id}`); const latEl = document.getElementById(`lat-${svc.id}`);
if (!badge) return; if (!badge) return;
if (svc.isHealth) {
// Already done via fetchHealth — mark as ok
badge.className = 'service-badge ok';
badge.textContent = 'OK';
latEl.textContent = '';
return;
}
const t0 = Date.now(); const t0 = Date.now();
try { try {
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 7000); const timer = setTimeout(() => ctrl.abort(), 7000);
const res = await fetch(svc.url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal }); await fetch(svc.url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal });
clearTimeout(timer); clearTimeout(timer);
const ms = Date.now() - t0;
badge.className = 'service-badge ok'; badge.className = 'service-badge ok';
badge.textContent = 'OK'; badge.textContent = 'OK';
latEl.textContent = `${ms}ms`; latEl.textContent = `${Date.now() - t0}ms`;
} catch (e) { } catch (e) {
badge.className = 'service-badge crit'; badge.className = 'service-badge crit';
badge.textContent = 'DOWN'; badge.textContent = 'DOWN';
@ -645,7 +701,7 @@ async function checkAllServices(devOk) {
badge.textContent = devOk ? 'OK' : 'DOWN'; badge.textContent = devOk ? 'OK' : 'DOWN';
} }
} else { } else {
checkService(svc); // fire-and-forget (parallel) checkService(svc);
} }
} }
} }
@ -670,11 +726,9 @@ function startCountdown() {
async function runRefresh() { async function runRefresh() {
const devOk = await fetchHealth(); const devOk = await fetchHealth();
await checkAllServices(devOk); checkAllServices(devOk);
const now = new Date().toLocaleTimeString('ja-JP', { hour:'2-digit', minute:'2-digit', second:'2-digit' }); const now = new Date().toLocaleTimeString('ja-JP', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
document.getElementById('last-checked').textContent = `最終更新: ${now}`; document.getElementById('last-checked').textContent = `最終更新: ${now}`;
startCountdown(); startCountdown();
} }