posimai-root/posimai-dev/station-b.html

838 lines
48 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 B</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<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; }
#binary-canvas { position:fixed;inset:0;z-index:0;pointer-events:none; }
.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:320px 1fr 460px 180px;gap:10px;min-height:0; }
.machines-panel { gap:8px;overflow-y:auto;scrollbar-width:none; }
.machines-panel::-webkit-scrollbar { display:none; }
.machines-section { font-size:9px;font-weight:600;color:var(--text3);letter-spacing:0.14em;text-transform:uppercase;margin-bottom:-2px; }
.machines-divider { border-top:1px solid var(--border);margin:2px 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;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(2,1fr);grid-auto-rows: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;flex-direction:column;gap:6px;padding-top:10px;border-top:1px solid var(--border); }
#ecosystem-bar { display:flex;align-items:center;gap:14px;font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text3);overflow:hidden; }
.eco-item { display:flex;align-items:center;gap:5px;flex-shrink:0; }
.eco-dot { width:5px;height:5px;border-radius:50%;background:var(--text3); }
.eco-dot.ok { background:var(--ok); }
.eco-dot.crit { background:var(--crit); }
#bottom-row { display:flex;align-items:center;justify-content:space-between; }
.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>
<canvas id="binary-canvas"></canvas>
<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: MACHINES (Ubuntu PC + VPS stacked) -->
<div class="panel machines-panel">
<div class="panel-title"><i data-lucide="server"></i>Machines</div>
<!-- Ubuntu PC section -->
<div class="machines-section">Ubuntu PC</div>
<div class="metric-item">
<div class="metric-header-row"><span class="metric-label">CPU Usage</span><span class="metric-val" id="cpu-val"></span></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>
<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>
<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="metric-item" id="net-row" style="display:none">
<div class="metric-header-row"><span class="metric-label">Network</span><span class="metric-val" id="net-val"></span></div>
</div>
<div class="stat-grid" style="grid-template-columns:1fr 1fr 1fr">
<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" id="temp-card" style="display:none"><div class="stat-label">CPU Temp</div><div class="stat-val" id="temp-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>
<!-- Divider -->
<div class="machines-divider"></div>
<!-- VPS section -->
<div class="machines-section">VPS</div>
<div class="metric-item">
<div class="metric-header-row"><span class="metric-label">Load Avg <span style="font-size:9px;color:var(--text3)">(1m/5m)</span></span><span class="metric-val" id="vps-load"></span></div>
</div>
<div class="metric-item">
<div class="metric-header-row"><span class="metric-label">Memory</span><span class="metric-val" id="vps-mem"></span></div>
</div>
<div class="metric-item">
<div class="metric-header-row"><span class="metric-label">Disk (/)</span><span class="metric-val" id="vps-disk"></span></div>
</div>
<div class="stat-grid" style="grid-template-columns:1fr 1fr;gap:6px">
<div class="stat-card"><div class="stat-label">Uptime</div><div class="stat-val" id="vps-uptime"></div></div>
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-val" id="vps-users"></div></div>
<div class="stat-card"><div class="stat-label">Gemini</div><div class="stat-val" id="vps-gemini"></div></div>
<div class="stat-card"><div class="stat-label">Node</div><div class="stat-val" id="vps-node"></div></div>
</div>
</div>
<!-- Col 3: 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:152px;height:152px;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:98px;height:98px;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:98px;height:98px;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 id="ecosystem-bar">
<div class="eco-item"><div class="eco-dot" id="eco-gitea-dot"></div><span id="eco-commit">commit: —</span></div>
<div class="eco-item" style="color:var(--border);font-size:8px">|</div>
<div class="eco-item"><div class="eco-dot" id="eco-vercel-dot"></div><span id="eco-vercel">deploy: —</span></div>
<div class="eco-item" style="color:var(--border);font-size:8px">|</div>
<div class="eco-item" id="eco-temp-item" style="display:none"><span id="eco-temp-label">temp: —</span></div>
</div>
<div id="bottom-row">
<div class="bottom-brand">posimai<span>-station</span> <span style="font-size:10px;color:var(--violet);margin-left:4px">B</span></div>
<div class="bottom-links">
<a class="bottom-link" href="/station" rel="noopener"><i data-lucide="monitor"></i>Design A</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>
</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:'/api/vps-health', isHealth:false, proxy:true},
{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';
// Network I/O
if(data.net){
const netRow=document.getElementById('net-row');
if(netRow) netRow.style.display='';
const rx=data.net.rx_kbps,tx=data.net.tx_kbps;
const fmt=v=>v>=1024?`${(v/1024).toFixed(1)}MB/s`:`${v}KB/s`;
document.getElementById('net-val').textContent=`${fmt(rx)}${fmt(tx)}`;
}
// CPU Temp
if(data.cpu_temp_c!=null){
const tc=document.getElementById('temp-card');
const ti=document.getElementById('eco-temp-item');
if(tc) tc.style.display='';
if(ti) ti.style.display='';
const t=data.cpu_temp_c;
const tcol=t>=80?'var(--crit)':t>=70?'var(--warn)':'var(--ok)';
document.getElementById('temp-val').textContent=`${t}°C`;
document.getElementById('temp-val').style.color=tcol;
document.getElementById('eco-temp-label').textContent=`temp: ${t}°C`;
document.getElementById('eco-temp-label').style.color=tcol;
}
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');
window._systemCrit = hasCrit; // canvas aurora hue shift
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);
// ── Gitea 最新コミット ─────────────────────────────────────────
async function fetchGiteaCommit(){
try{
const r=await fetch('/api/gitea-commit',{signal:AbortSignal.timeout(4000)});
const d=await r.json();
if(d.error||!d.sha) return;
const dot=document.getElementById('eco-gitea-dot');
const el=document.getElementById('eco-commit');
if(dot) dot.className='eco-dot ok';
if(el){
const ago=timeAgo(d.date);
el.textContent=`${d.sha} ${d.message} (${ago})`;
}
}catch(_){}
}
function timeAgo(iso){
const diff=Math.floor((Date.now()-new Date(iso))/60000);
if(diff<1)return 'just now';
if(diff<60)return `${diff}m ago`;
if(diff<1440)return `${Math.floor(diff/60)}h ago`;
return `${Math.floor(diff/1440)}d ago`;
}
fetchGiteaCommit();
setInterval(fetchGiteaCommit, 120000);
// ── Vercel 最新デプロイ ─────────────────────────────────────────
async function fetchVercelDeploy(){
try{
const r=await fetch('/api/vercel-deploys',{signal:AbortSignal.timeout(8000)});
const d=await r.json();
if(d.error) return;
const dot=document.getElementById('eco-vercel-dot');
const el=document.getElementById('eco-vercel');
const stateMap={READY:'var(--ok)',ERROR:'var(--crit)',BUILDING:'var(--warn)',CANCELED:'var(--text3)'};
const color=stateMap[d.state]||'var(--text3)';
if(dot) dot.style.background=color;
const ago=timeAgo(d.created);
const label=(d.name||'').replace(/^posimai-/,'');
if(el) el.textContent=`deploy: ${label} ${d.state?.toLowerCase()} ${ago}`;
}catch(_){}
}
fetchVercelDeploy();
setInterval(fetchVercelDeploy, 300000);
// ── VPS メトリクス ─────────────────────────────────────────────
async function fetchVpsHealth(){
try{
const r=await fetch('/api/vps-health',{signal:AbortSignal.timeout(8000)});
const d=await r.json();
const memPct=d.mem_total_mb?Math.round(d.mem_used_mb/d.mem_total_mb*100):0;
const diskPct=d.disk?.use_pct??0;
document.getElementById('vps-load').textContent=d.load_avg?`${d.load_avg[0]} / ${d.load_avg[1]}`:'—';
document.getElementById('vps-mem').textContent=d.mem_total_mb?`${d.mem_used_mb}/${d.mem_total_mb}MB (${memPct}%)`:'—';
renderBinBar('vps-mem-bar',memPct,65,85);
if(d.disk){
document.getElementById('vps-disk').textContent=`${d.disk.used_gb}/${d.disk.total_gb}GB (${diskPct}%)`;
renderBinBar('vps-disk-bar',diskPct,75,90);
}
document.getElementById('vps-uptime').textContent=formatUptime(d.uptime_s||0);
document.getElementById('vps-users').textContent=String(d.users??'—');
const gemEl=document.getElementById('vps-gemini');
gemEl.textContent=d.gemini?'ON':'OFF';
gemEl.style.color=d.gemini?'var(--ok)':'var(--crit)';
document.getElementById('vps-node').textContent=(d.node_version||'—').replace('v','');
}catch(e){
['vps-load','vps-mem','vps-disk','vps-uptime','vps-users','vps-gemini','vps-node']
.forEach(id=>{ const el=document.getElementById(id); if(el) el.textContent='ERR'; });
}
}
fetchVpsHealth();
setInterval(fetchVpsHealth, 30000);
// ── キーボードショートカット ────────────────────────────────────
document.addEventListener('keydown', e => {
if(e.key==='r'||e.key==='R'){ runRefresh(); }
if(e.key==='b'||e.key==='B'){ window.location.href='/station'; }
if(e.key==='f'||e.key==='F'){
if(!document.fullscreenElement) document.documentElement.requestFullscreen().catch(()=>{});
else document.exitFullscreen();
}
});
// ── Binary curtain aurora canvas ─────────────────────────────────────────────
(function initBinaryCurtain(){
const canvas = document.getElementById('binary-canvas');
const ctx = canvas.getContext('2d');
const FONT_SIZE = 14;
let cols = [];
const BANDS = [
{ hue: 185, sat: 90, x: 0.15, speed: 0.00018, phase: 0 },
{ hue: 265, sat: 80, x: 0.38, speed: 0.00013, phase: 1.5 },
{ hue: 185, sat: 85, x: 0.62, speed: 0.00020, phase: 3.0 },
{ hue: 150, sat: 70, x: 0.80, speed: 0.00015, phase: 4.2 },
];
function resize(){
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const numCols = Math.ceil(canvas.width / FONT_SIZE);
if(cols.length < numCols){
for(let i = cols.length; i < numCols; i++){
cols.push({ y: Math.random()*canvas.height, speed:1.2+Math.random()*3.5, len:8+Math.floor(Math.random()*20), chars:[], opacity:0.3+Math.random()*0.5 });
}
} else { cols.length = numCols; }
}
resize();
window.addEventListener('resize', resize);
let t = 0;
// CRIT hue shift: 0=normal, 1=full red. Transitions gradually.
let _critProgress = 0;
function getBandColor(x, t){
const xf = x / canvas.width;
let best = BANDS[0], bestDist = Infinity;
BANDS.forEach(b => { const bx=b.x+Math.sin(t*b.speed+b.phase)*0.12; const dist=Math.abs(xf-bx); if(dist<bestDist){bestDist=dist;best=b;} });
const bx = best.x + Math.sin(t*best.speed+best.phase)*0.12;
const alpha = Math.max(0, 1 - Math.abs(xf-bx)/0.22);
// Lerp hue toward red (hue=10) when CRIT — subtle, takes ~3s to fully shift
const hue = Math.round(best.hue + (10 - best.hue) * _critProgress);
const sat = Math.round(best.sat + (100 - best.sat) * _critProgress * 0.5);
return { hue, sat, alpha };
}
function draw(){
t++;
// Gradually shift critProgress toward target (0 or 1)
const critTarget = window._systemCrit ? 1 : 0;
_critProgress += (critTarget - _critProgress) * 0.008; // ~3s transition
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = `${FONT_SIZE}px 'JetBrains Mono', monospace`;
cols.forEach((col, i) => {
const x = i * FONT_SIZE;
const band = getBandColor(x, t);
for(let j = 0; j < col.len; j++){
const cy = col.y - j * FONT_SIZE;
if(cy < -FONT_SIZE || cy > canvas.height + FONT_SIZE) continue;
if(!col.chars[j] || (t%8===0 && Math.random()<0.05)) col.chars[j] = Math.random()<0.5?'1':'0';
const ch = col.chars[j];
const trailAlpha = (1 - j/col.len) * col.opacity;
const finalAlpha = trailAlpha * (band.alpha*0.7+0.15);
if(j===0){ ctx.fillStyle=`hsla(${band.hue},${band.sat}%,92%,${Math.min(1,finalAlpha*2.2)})`; }
else if(ch==='1'){ ctx.fillStyle=`hsla(${band.hue},${band.sat}%,65%,${finalAlpha})`; }
else { ctx.fillStyle=`hsla(${(band.hue+30)%360},${Math.round(band.sat*0.6)}%,45%,${finalAlpha*0.55})`; }
ctx.fillText(ch, x, cy);
}
col.y += col.speed;
if(col.y - col.len*FONT_SIZE > canvas.height){ col.y=-FONT_SIZE*2; col.speed=1.2+Math.random()*3.5; col.len=8+Math.floor(Math.random()*20); col.chars=[]; col.opacity=0.3+Math.random()*0.5; }
});
requestAnimationFrame(draw);
}
draw();
})();
</script>
</body>
</html>