posimai-root/posimai-dev/station.html

634 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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: rgba(15,22,40,0.70);
--surface2: rgba(26,34,53,0.80);
--border: rgba(255,255,255,0.08);
--border2: rgba(255,255,255,0.04);
--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 { 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.12) 0%,transparent 68%);top:-150px;right:-150px;filter:blur(100px);animation:adrift-1 22s ease-in-out infinite alternate; }
.aurora-blob-2 { width:700px;height:500px;background:radial-gradient(ellipse,rgba(167,139,250,0.09) 0%,transparent 68%);bottom:-120px;left:-100px;filter:blur(90px);animation:adrift-2 28s ease-in-out infinite alternate; }
.aurora-blob-3 { width:500px;height:400px;background:radial-gradient(ellipse,rgba(34,211,238,0.06) 0%,transparent 68%);top:40%;right:20%;filter:blur(80px);animation:adrift-3 18s ease-in-out infinite alternate; }
@keyframes adrift-1 { from{transform:translate(0,0) scale(1)} to{transform:translate(-80px,60px) scale(1.1)} }
@keyframes adrift-2 { from{transform:translate(0,0) scale(1)} to{transform:translate(60px,-40px) scale(1.08)} }
@keyframes adrift-3 { from{transform:translate(0,0) scale(1)} to{transform:translate(-40px,50px) scale(0.95)} }
.panel {
background: var(--surface);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
border: 1px solid var(--border);
border-radius: 16px; padding: 18px;
display: flex; flex-direction: column; gap: 14px;
overflow: hidden; position: relative;
}
.panel::before {
content:''; position:absolute; inset:0; border-radius:16px;
background: linear-gradient(145deg,rgba(255,255,255,0.035) 0%,transparent 55%);
pointer-events:none;
}
.panel-title { font-size:11px;font-weight:600;color:var(--text3);letter-spacing:0.12em;text-transform:uppercase;display:flex;align-items:center;gap:6px;flex-shrink:0; }
.panel-title svg { width:13px;height:13px; }
#alert-bar { display:none;position:relative;z-index:2;align-items:center;gap:10px;padding:7px 14px;border-radius:10px;font-size:12px;font-weight:500;margin-bottom:10px; }
#alert-bar.visible { display:flex; }
#alert-bar.warn { background:rgba(251,146,60,0.10);border:1px solid rgba(251,146,60,0.28);color:var(--warn); }
#alert-bar.crit { background:rgba(248,113,113,0.10);border:1px solid rgba(248,113,113,0.3);color:var(--crit);animation:alert-pulse 2s ease-in-out infinite; }
@keyframes alert-pulse { 0%,100%{border-color:rgba(248,113,113,0.3)} 50%{border-color:rgba(248,113,113,0.65)} }
#alert-bar svg { width:13px;height:13px;flex-shrink:0; }
#app { position:relative;z-index:1;height:100vh;display:grid;grid-template-rows:auto auto 1fr auto;padding:20px;gap:0; }
#top { display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding-bottom:14px; }
#hostname-area { display:flex;align-items:center;gap:8px; }
#hostname { font-size:12px;font-weight:500;color:var(--text3);letter-spacing:0.08em;text-transform:uppercase; }
#status-dot { width:7px;height:7px;border-radius:50%;background:var(--text3);flex-shrink:0;transition:background 0.4s; }
#status-dot.ok { background:var(--ok); box-shadow:0 0 7px var(--ok); animation:pdot 2.4s ease-in-out infinite; }
#status-dot.warn { background:var(--warn); box-shadow:0 0 7px var(--warn); }
#status-dot.crit { background:var(--crit); box-shadow:0 0 7px var(--crit);animation:pdot 1s ease-in-out infinite; }
#status-dot.off { background:var(--text3);box-shadow:none; }
@keyframes pdot { 0%,100%{opacity:1} 50%{opacity:0.35} }
#clock-area { text-align:center; }
#clock { font-family:'JetBrains Mono',monospace;font-size:clamp(48px,5.5vw,84px);font-weight:300;letter-spacing:-0.03em;line-height:1; }
#date { font-size:12px;color:var(--text3);margin-top:3px;letter-spacing:0.06em; }
#last-checked { text-align:right;font-size:11px;color:var(--text3); }
#middle { display:grid;grid-template-columns:270px 280px 1fr 196px;gap:12px;min-height:0; }
.metric-item { display:flex;flex-direction:column;gap:5px;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; }
.bin-bar { font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.04em;display:flex;gap:0px;margin-top:2px; }
.bin-bar .b1 { color:var(--accent);transition:color 0.5s; }
.bin-bar .b0 { color:rgba(255,255,255,0.1); }
.bin-bar.warn .b1 { color:var(--warn); }
.bin-bar.crit .b1 { color:var(--crit); }
.load-row { display:flex;gap:6px;flex-shrink:0; }
.load-chip { flex:1;background:var(--surface2);border-radius:7px;padding:7px;text-align:center; }
.load-chip-label { font-size:10px;color:var(--text3);margin-bottom:2px; }
.load-chip-val { font-family:'JetBrains Mono',monospace;font-size:14px;font-weight:500; }
.load-chip-val.warn { color:var(--warn); }
.load-chip-val.crit { color:var(--crit); }
.stat-grid { display:grid;grid-template-columns:1fr 1fr;gap:7px;flex-shrink:0; }
.stat-card { background:var(--surface2);border-radius:9px;padding:9px 11px; }
.stat-label { font-size:11px;color:var(--text3);margin-bottom:2px; }
.stat-val { font-family:'JetBrains Mono',monospace;font-size:15px;font-weight:500;letter-spacing:-0.02em; }
.open-btn { display:flex;align-items:center;justify-content:center;gap:6px;padding:8px;background:rgba(34,211,238,0.07);border:1px solid rgba(34,211,238,0.18);border-radius:9px;color:var(--accent);text-decoration:none;font-size:12px;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.13);border-color:rgba(34,211,238,0.35); }
.open-btn svg { width:13px;height:13px; }
/* rings panel */
.rings-panel { display:flex;flex-direction:column;gap:12px; }
.ring-group { display:flex;flex-direction:column;align-items:center;gap:3px; }
.ring-track { fill:none;stroke:rgba(255,255,255,0.06);stroke-width:8;stroke-linecap:round; }
.ring-fill { fill:none;stroke-width:8;stroke-linecap:round;transition:stroke-dashoffset 1s cubic-bezier(0.4,0,0.2,1),stroke 0.4s; }
.ring-center { font-family:'JetBrains Mono',monospace;font-size:15px;font-weight:500;fill:var(--text);dominant-baseline:middle;text-anchor:middle; }
.ring-sublabel { font-size:9px;fill:var(--text3);dominant-baseline:middle;text-anchor:middle; }
.ring-label-text { font-size:9px;color:var(--text3);letter-spacing:0.06em;text-transform:uppercase; }
.small-rings { display:flex;gap:8px;justify-content:center; }
/* sparkline */
.spark-section { flex:1;display:flex;flex-direction:column;gap:6px;min-height:0; }
.spark-header { display:flex;justify-content:space-between;align-items:center; }
.spark-legend { display:flex;gap:8px; }
.spark-legend-item { display:flex;align-items:center;gap:3px;font-size:9px;color:var(--text3); }
.spark-legend-dot { width:6px;height:6px;border-radius:50%; }
.spark-wrap { flex:1;background:var(--surface2);border-radius:8px;padding:8px;min-height:55px;position:relative; }
.spark-svg { width:100%;height:100%;overflow:visible; }
/* services */
.service-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:8px;overflow-y:auto;flex:1; }
.service-card { background:var(--surface2);border:1px solid var(--border2);border-radius:11px;padding:12px;display:flex;flex-direction:column;gap:6px; }
.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.14); color:var(--ok); }
.service-badge.crit { background:rgba(248,113,113,0.14);color:var(--crit); }
.service-badge.checking { background:rgba(34,211,238,0.09); color:var(--accent); }
.service-desc { font-size:11px;color:var(--text3);line-height:1.4; }
.service-footer { display:flex;align-items:center;justify-content:space-between;margin-top:2px; }
.service-latency { font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text3); }
.service-dots { display:flex;gap:3px; }
.svc-dot { width:5px;height:5px;border-radius:50%;background:var(--text3);opacity:0.25; }
.svc-dot.ok { background:var(--ok); opacity:0.85; }
.svc-dot.crit { background:var(--crit); opacity:0.85; }
.service-uptime { font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:600; }
.service-uptime.full { color:var(--ok); }
.service-uptime.partial { color:#FB923C; }
.service-uptime.down { color:var(--crit); }
.svc-spark-wrap { height:28px;margin:2px 0; }
.svc-spark { width:100%;height:100%;overflow:visible; }
/* stream */
#stream-feed { flex:1;overflow:hidden;display:flex;flex-direction:column;gap:0; }
.stream-row { display:flex;flex-direction:column;gap:4px;padding:8px 0;border-bottom:1px solid var(--border2);animation:stream-in 0.45s 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(-5px)} to{opacity:1;transform:translateY(0)} }
.stream-key { font-size:9px;font-weight:600;letter-spacing:0.1em;color:var(--text3);text-transform:uppercase; }
.stream-binary { font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.05em;line-height:1.5;word-break:break-all; }
.bit-1 { color:var(--accent);opacity:0.9; }
.bit-0 { color:rgba(100,180,220,0.32); }
.stream-human { font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:500;display:flex;align-items:center;gap:5px; }
.s-bar { flex:1;height:2px;border-radius:1px;background:rgba(255,255,255,0.05);overflow:hidden; }
.s-bar-fill { height:100%;border-radius:1px;background:var(--accent);transition:width 0.6s ease; }
.s-bar-fill.warn { background:var(--warn); }
.s-bar-fill.crit { background:var(--crit); }
#stream-ticker { font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text3);padding-top:7px;overflow:hidden;white-space:nowrap;border-top:1px solid var(--border2);flex-shrink:0; }
#stream-ticker-inner { display:inline-block;animation:ticker 60s linear infinite; }
@keyframes ticker { from{transform:translateX(0)} to{transform:translateX(-50%)} }
#bottom { display:flex;align-items:center;justify-content:space-between;padding-top:12px;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:7px; }
.bottom-link { display:flex;align-items:center;gap:4px;padding:4px 9px;background:var(--surface);backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:7px;color:var(--text2);text-decoration:none;font-size:11px;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:11px;height:11px; }
#refresh-countdown { font-size:11px;color:var(--text3); }
</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">
<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>
<div id="alert-bar"><i data-lucide="alert-triangle"></i><span id="alert-messages"></span></div>
<div id="middle">
<!-- Col 1: text metrics -->
<div class="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="bin-bar" id="cpu-bar"></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="bin-bar" id="mem-bar"></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="bin-bar" id="disk-bar"></div>
</div>
<div>
<div class="metric-label" style="margin-bottom:6px">Load Avg <span id="cpu-count-label" style="color:var(--text3);font-size:9px"></span></div>
<div class="load-row">
<div class="load-chip"><div class="load-chip-label">1m</div><div class="load-chip-val" id="load-1"></div></div>
<div class="load-chip"><div class="load-chip-label">5m</div><div class="load-chip-val" id="load-5"></div></div>
<div class="load-chip"><div class="load-chip-label">15m</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>
<!-- Col 2: rings + sparkline -->
<div class="panel rings-panel">
<div class="panel-title"><i data-lucide="activity"></i>Vitals</div>
<div class="ring-group">
<svg viewBox="0 0 120 120" style="width:120px;height:120px;overflow:visible">
<defs>
<filter id="glow-c"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>
<circle class="ring-track" cx="60" cy="60" r="48"/>
<circle class="ring-fill" cx="60" cy="60" r="48" id="ring-cpu-fill"
stroke="#22D3EE" filter="url(#glow-c)"
stroke-dasharray="301.6" stroke-dashoffset="301.6"
style="transform:rotate(-90deg);transform-origin:60px 60px"/>
<text class="ring-center" id="ring-cpu-val" x="60" y="56"></text>
<text class="ring-sublabel" x="60" y="70">CPU</text>
</svg>
</div>
<div class="small-rings">
<div class="ring-group">
<svg viewBox="0 0 80 80" style="width:78px;height:78px;overflow:visible">
<defs><filter id="glow-m"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
<circle class="ring-track" cx="40" cy="40" r="30"/>
<circle class="ring-fill" cx="40" cy="40" r="30" id="ring-mem-fill"
stroke="#A78BFA" filter="url(#glow-m)"
stroke-dasharray="188.5" stroke-dashoffset="188.5"
style="transform:rotate(-90deg);transform-origin:40px 40px"/>
<text class="ring-center" id="ring-mem-val" x="40" y="37" style="font-size:11px"></text>
<text class="ring-sublabel" x="40" y="48" style="font-size:8px">MEM</text>
</svg>
<span class="ring-label-text">Memory</span>
</div>
<div class="ring-group">
<svg viewBox="0 0 80 80" style="width:78px;height:78px;overflow:visible">
<defs><filter id="glow-d"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
<circle class="ring-track" cx="40" cy="40" r="30"/>
<circle class="ring-fill" cx="40" cy="40" r="30" id="ring-disk-fill"
stroke="#4ADE80" filter="url(#glow-d)"
stroke-dasharray="188.5" stroke-dashoffset="188.5"
style="transform:rotate(-90deg);transform-origin:40px 40px"/>
<text class="ring-center" id="ring-disk-val" x="40" y="37" style="font-size:11px"></text>
<text class="ring-sublabel" x="40" y="48" style="font-size:8px">DISK</text>
</svg>
<span class="ring-label-text">Disk</span>
</div>
</div>
<div class="spark-section">
<div class="spark-header">
<span style="font-size:9px;color:var(--text3);letter-spacing:0.08em;text-transform:uppercase">History</span>
<div class="spark-legend">
<div class="spark-legend-item"><div class="spark-legend-dot" style="background:#22D3EE"></div>CPU</div>
<div class="spark-legend-item"><div class="spark-legend-dot" style="background:#A78BFA"></div>Load</div>
</div>
</div>
<div class="spark-wrap" id="spark-wrap">
<svg class="spark-svg" id="spark-svg" preserveAspectRatio="none">
<path id="spark-cpu-area" fill="#22D3EE" opacity="0.10" d=""/>
<polyline id="spark-cpu-line" fill="none" stroke="#22D3EE" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points=""/>
<polyline id="spark-load-line" fill="none" stroke="#A78BFA" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="3 2" points=""/>
</svg>
</div>
</div>
</div>
<!-- Col 3: services -->
<div class="panel">
<div class="panel-title"><i data-lucide="radio"></i>Services</div>
<div class="service-grid" id="service-grid"></div>
</div>
<!-- Col 4: binary stream -->
<div class="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 id="bottom">
<div class="bottom-brand">posimai<span>-station</span></div>
<div class="bottom-links">
<a class="bottom-link" href="/station-b" rel="noopener"><i data-lucide="monitor"></i>Design B</a>
<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.soar-enrich.com" 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 HISTORY_MAX = 20;
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:'/api/check?url=http://100.76.7.3:3000', isHealth:false, proxy:true},
{id:'syncthing', name:'Syncthing', desc:'ファイル同期 GUI', url:'/api/check?url=http://100.77.11.43:8384', isHealth:false, proxy:true},
{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},
];
const hist = {cpu:[], load:[]};
const svcHist = {};
const svcLatHist = {};
SERVICES.forEach(s => { svcHist[s.id] = []; svcLatHist[s.id] = []; });
// メトリクスのグローバルキャッシュupdateStream の setInterval から参照)
let _metrics = { cpuPct:0, memPct:0, diskPct:0, loadAvg:[0,0,0], uptimeS:0, sessions:0, hostname:'ubuntu-pc', nodeVer:'' };
let streamData = null;
function p(n){ return String(n).padStart(2,'0'); }
function updateClock(){
const now=new Date();
document.getElementById('clock').textContent=`${p(now.getHours())}:${p(now.getMinutes())}:${p(now.getSeconds())}`;
const days=['日','月','火','水','木','金','土'];
document.getElementById('date').textContent=`${now.getFullYear()}.${p(now.getMonth()+1)}.${p(now.getDate())} (${days[now.getDay()]})`;
}
setInterval(updateClock,1000); updateClock();
function formatUptime(s){
const d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),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`;
}
function setAlerts(alerts){
const bar=document.getElementById('alert-bar'),msgs=document.getElementById('alert-messages');
if(!alerts.length){bar.className='';return;}
bar.className=alerts.some(a=>a.level==='crit')?'crit visible':'warn visible';
msgs.textContent=alerts.map(a=>a.msg).join(' / ');
if(window.lucide)lucide.createIcons({nodes:[bar]});
}
function updateRing(fillId,valId,pct,circ,base,warn,crit){
const fill=document.getElementById(fillId),valEl=document.getElementById(valId);
if(!fill||!valEl)return;
fill.style.strokeDashoffset=circ*(1-Math.min(pct,100)/100);
const color=pct>80?(crit||'#F87171'):pct>60?(warn||'#FB923C'):(base||'#22D3EE');
fill.style.stroke=color;
valEl.textContent=pct>0?`${pct}%`:'—';
valEl.style.fill=pct>80?'#F87171':pct>60?'#FB923C':'var(--text)';
}
function renderSparklines(){
const wrap=document.getElementById('spark-wrap');
if(!wrap||hist.cpu.length<2)return;
const W=wrap.clientWidth||160,H=wrap.clientHeight||55,pad=4;
function pts(arr,max){
return arr.map((v,i)=>{
const x=pad+(i/(HISTORY_MAX-1))*(W-pad*2);
const y=H-pad-(v/max)*(H-pad*2);
return`${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
}
const cpuMax=100,loadMax=Math.max((window._cpuCount||4)*1.5,...hist.load,1);
document.getElementById('spark-cpu-line').setAttribute('points',pts(hist.cpu,cpuMax));
document.getElementById('spark-load-line').setAttribute('points',pts(hist.load,loadMax));
if(hist.cpu.length>=2){
const arr=hist.cpu.map((v,i)=>[pad+(i/(HISTORY_MAX-1))*(W-pad*2),H-pad-(v/cpuMax)*(H-pad*2)]);
const d=`M${arr[0][0].toFixed(1)},${H-pad} L${arr.map(p=>p.map(n=>n.toFixed(1)).join(',')).join(' L')} L${arr[arr.length-1][0].toFixed(1)},${H-pad} Z`;
document.getElementById('spark-cpu-area').setAttribute('d',d);
}
}
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??0;
const cpuCount=data.cpu_count||1;
const loadAvg=data.load_avg||[0,0,0];
window._cpuCount=cpuCount;
document.getElementById('cpu-val').textContent=`${cpuPct}%`;
renderBinBar('cpu-bar',cpuPct,60,80);
document.getElementById('mem-val').textContent=`${data.mem_used_mb}/${data.mem_total_mb}MB (${memPct}%)`;
renderBinBar('mem-bar',memPct,65,85);
if(data.disk){
document.getElementById('disk-val').textContent=`${data.disk.used_gb}/${data.disk.total_gb}GB (${diskPct}%)`;
renderBinBar('disk-bar',diskPct,75,90);
}
document.getElementById('cpu-count-label').textContent=`(core:${cpuCount})`;
['load-1','load-5','load-15'].forEach((id,i)=>{
const el=document.getElementById(id),val=loadAvg[i]||0;
el.textContent=val.toFixed(2);
el.className='load-chip-val'+(val>cpuCount*1.5?' crit':val>cpuCount?' warn':'');
});
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';
updateRing('ring-cpu-fill','ring-cpu-val', cpuPct, 301.6,'#22D3EE','#FB923C','#F87171');
updateRing('ring-mem-fill','ring-mem-val', memPct, 188.5,'#A78BFA','#FB923C','#F87171');
updateRing('ring-disk-fill','ring-disk-val',diskPct,188.5,'#4ADE80','#FB923C','#F87171');
hist.cpu.push(cpuPct); if(hist.cpu.length>HISTORY_MAX)hist.cpu.shift();
hist.load.push(loadAvg[0]||0); if(hist.load.length>HISTORY_MAX)hist.load.shift();
renderSparklines();
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);
const hasCrit=alerts.some(a=>a.level==='crit'),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;
}
}
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}`;
const dots=Array(5).fill(0).map((_,i)=>`<div class="svc-dot" id="dot-${svc.id}-${i}"></div>`).join('');
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><div class="svc-spark-wrap"><svg class="svc-spark" id="spark-${svc.id}" viewBox="0 0 100 24" preserveAspectRatio="none"></svg></div><div class="service-footer"><div class="service-dots">${dots}</div><span class="service-uptime" id="upt-${svc.id}"></span><span class="service-latency" id="lat-${svc.id}"></span></div>`;
grid.appendChild(card);
});
}
function renderBinBar(id,pct,warnTh,critTh){
const el=document.getElementById(id); if(!el)return;
const cells=20,filled=Math.round(pct/100*cells);
const cls=pct>critTh?'crit':pct>warnTh?'warn':'';
el.className='bin-bar'+(cls?' '+cls:'');
el.innerHTML=Array.from({length:cells},(_,i)=>
`<span class="${i<filled?'b1':'b0'}">${i<filled?'1':'0'}</span>`
).join('');
}
function updateSparkline(id, ms, ok){
const h = svcLatHist[id];
h.push(ok ? (ms||0) : null);
if(h.length > 12) h.shift();
const svg = document.getElementById(`spark-${id}`);
if(!svg || h.length < 2) return;
const valid = h.filter(v => v !== null);
if(valid.length < 2){ svg.innerHTML=''; return; }
const maxV = Math.max(...valid, 1);
const W=100, H=24, pad=2;
const pts = h.map((v,i) => {
const x = (i/(h.length-1))*W;
const y = v===null ? H-pad : H-pad - ((v/maxV)*(H-pad*2));
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
const color = !ok ? 'rgba(248,113,113,0.7)' : maxV > 500 ? 'rgba(251,146,60,0.7)' : 'rgba(34,211,238,0.6)';
const area = h.map((v,i) => {
const x=(i/(h.length-1))*W;
const y=v===null?H-pad:H-pad-((v/maxV)*(H-pad*2));
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
area.push(`${W},${H}`, `0,${H}`);
svg.innerHTML = `
<defs><linearGradient id="sg-${id}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${color}" stop-opacity="0.25"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0.03"/>
</linearGradient></defs>
<polygon points="${area.join(' ')}" fill="url(#sg-${id})"/>
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`;
}
function pushSvcHistory(id,ok){
svcHist[id].push(ok); if(svcHist[id].length>5)svcHist[id].shift();
const h=svcHist[id];
for(let i=0;i<5;i++){
const dot=document.getElementById(`dot-${id}-${i}`); if(!dot)continue;
const idx=i-(5-h.length); if(idx<0){dot.className='svc-dot';continue;}
dot.className='svc-dot '+(h[idx]?'ok':'crit');
}
// 稼働率表示
const uptEl=document.getElementById(`upt-${id}`);
if(uptEl&&h.length>0){
const pct=Math.round(h.filter(Boolean).length/h.length*100);
uptEl.textContent=`UP:${pct}%`;
uptEl.className='service-uptime '+(pct===100?'full':pct>=60?'partial':'down');
}
}
async function checkService(svc){
const badge=document.getElementById(`badge-${svc.id}`),latEl=document.getElementById(`lat-${svc.id}`);
if(!badge)return;
const t0=Date.now();
try{
const ctrl=new AbortController(),timer=setTimeout(()=>ctrl.abort(),7000);
if(svc.proxy){
const r=await fetch(svc.url,{signal:ctrl.signal});
clearTimeout(timer);
const data=await r.json();
const ok=data.ok||(data.status&&data.status<500);
const ms=data.latency_ms||0;
badge.className='service-badge '+(ok?'ok':'crit');
badge.textContent=ok?'OK':'DOWN';
latEl.textContent=ms?`${ms}ms`:'';
updateSparkline(svc.id,ms,!!ok);
pushSvcHistory(svc.id,!!ok);
}else{
await fetch(svc.url,{method:'HEAD',mode:'no-cors',signal:ctrl.signal});
clearTimeout(timer);
const ms=Date.now()-t0;
badge.className='service-badge ok'; badge.textContent='OK';
latEl.textContent=`${ms}ms`;
updateSparkline(svc.id,ms,true);
pushSvcHistory(svc.id,true);
}
}catch(e){
badge.className='service-badge crit'; badge.textContent='DOWN'; latEl.textContent='';
pushSvcHistory(svc.id,false);
}
}
function checkAllServices(devOk){
SERVICES.forEach(svc=>{
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';}
pushSvcHistory(svc.id,devOk);
}else{
checkService(svc);
}
});
}
function toBin(n,bits){return(n>>>0).toString(2).padStart(bits,'0').slice(-bits);}
function renderBits(b){return b.split('').map(c=>`<span class="bit-${c}">${c}</span>`).join('');}
function pushStreamRow(label,value,bin,pct,level){
const feed=document.getElementById('stream-feed');
const row=document.createElement('div'); row.className='stream-row';
row.innerHTML=`<div class="stream-key">${label}</div><div class="stream-binary">${renderBits(bin)}</div><div class="stream-human"><span>${value}</span>${pct!==null?`<div class="s-bar"><div class="s-bar-fill${level?' '+level:''}" style="width:${pct}%"></div></div>`:''}</div>`;
feed.insertBefore(row,feed.firstChild);
while(feed.children.length>8)feed.removeChild(feed.lastChild);
}
function updateStream(data){
streamData=data;
const cpuPct=data.cpu_pct||0,memPct=data.mem_total_mb?Math.round((data.mem_used_mb/data.mem_total_mb)*100):0;
const diskPct=data.disk?.use_pct??null,load1=(data.load_avg&&data.load_avg[0])||0;
const ts=Math.floor(Date.now()/1000);
const rows=[
{label:'UNIX TIME',value:String(ts), bin:toBin(ts&0xFFFF,16), pct:null, level:null},
{label:'CPU USAGE',value:`${cpuPct}%`, bin:toBin(cpuPct,8), pct:cpuPct, level:cpuPct>80?'crit':cpuPct>60?'warn':null},
{label:'MEMORY', value:`${memPct}%`, bin:toBin(memPct,8), pct:memPct, level:memPct>85?'crit':memPct>65?'warn':null},
{label:'LOAD AVG', value:load1.toFixed(2), bin:toBin(Math.round(load1*100)&0xFF,8),pct:null, level:null},
...(diskPct!==null?[{label:'DISK /',value:`${diskPct}%`,bin:toBin(diskPct,8),pct:diskPct,level:diskPct>90?'crit':diskPct>75?'warn':null}]:[]),
{label:'SESSIONS', value:String(data.active_sessions||0),bin:toBin(data.active_sessions||0,8),pct:null,level:null},
{label:'UPTIME', value:formatUptime(data.uptime_s||0), bin:toBin(Math.floor((data.uptime_s||0)/60)&0xFFFF,16),pct:null,level:null},
];
const r=rows[Math.floor(Date.now()/1000)%rows.length];
pushStreamRow(r.label,r.value,r.bin,r.pct,r.level);
const ip='100.77.11.43';
const b8 = n => n.toString(2).padStart(8,'0');
const segments = [
`CPU:${b8(cpuPct??0)}`,
`MEM:${b8(memPct??0)}`,
`DISK:${b8(diskPct??0)}`,
`LOAD:${b8(Math.min(255,Math.round(((data.load_avg&&data.load_avg[0])||0)*64)))}`,
`UP:${(data.uptime_s||0).toString(2)}`,
`SESSION:${b8(data.active_sessions||0)}`,
`TIME:${(Math.floor(Date.now()/1000)).toString(2).slice(-20)}`,
`IP:${ip.split('.').map(o=>b8(parseInt(o))).join('.')}`,
`HOST:${(data.hostname||'ubuntu-pc').split('').map(c=>b8(c.charCodeAt(0))).join(' ')}`,
];
const tape = segments.join(' // ') + ' // ';
const el=document.getElementById('stream-ticker-inner');
if(el){ el.textContent=tape+tape; el.style.animation='none'; void el.offsetWidth; el.style.animation=''; }
}
setInterval(()=>{if(streamData)updateStream(streamData);},4000);
let countdownVal=REFRESH_SEC,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();
}
buildServiceCards();
if(window.lucide)lucide.createIcons();
runRefresh();
window.addEventListener('resize',renderSparklines);
</script>
</body>
</html>