posimai-root/posimai-dev/station.html

561 lines
35 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; }
.bar-track { height:4px;border-radius:2px;background:rgba(255,255,255,0.05);overflow:hidden; }
.bar-fill { height:100%;border-radius:2px;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-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; }
/* 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:var(--text3);opacity:0.35; }
.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 30s 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="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: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="/" 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 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 = {};
SERVICES.forEach(s => svcHist[s.id] = []);
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}%`;
const cb=document.getElementById('cpu-bar');cb.style.width=`${cpuPct}%`;cb.className='bar-fill'+(cpuPct>80?' crit':cpuPct>60?' warn':'');
document.getElementById('mem-val').textContent=`${data.mem_used_mb}/${data.mem_total_mb}MB (${memPct}%)`;
const mb=document.getElementById('mem-bar');mb.style.width=`${memPct}%`;mb.className='bar-fill'+(memPct>85?' crit':memPct>65?' warn':'');
if(data.disk){
document.getElementById('disk-val').textContent=`${data.disk.used_gb}/${data.disk.total_gb}GB (${diskPct}%)`;
const db=document.getElementById('disk-bar');db.style.width=`${diskPct}%`;db.className='bar-fill'+(diskPct>90?' crit':diskPct>75?' warn':'');
}
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="service-footer"><div class="service-dots">${dots}</div><span class="service-latency" id="lat-${svc.id}"></span></div>`;
grid.appendChild(card);
});
}
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');
}
}
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){
// サーバー経由プロキシチェックmixed-content 回避)
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);
badge.className='service-badge '+(ok?'ok':'crit');
badge.textContent=ok?'OK':'DOWN';
latEl.textContent=data.latency_ms?`${data.latency_ms}ms`:'';
pushSvcHistory(svc.id,!!ok);
}else{
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`;
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 ipBin=ip.split('.').map(o=>toBin(parseInt(o),8)).join(' ');
const base=`${ipBin} // ${data.hostname||'ubuntu-pc'} // ${data.node_version||''} // `;
const el=document.getElementById('stream-ticker-inner');
if(el)el.textContent=base+base;
}
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>