From 0d580fe99473cda2f43498849c6c06d41d806c70 Mon Sep 17 00:00:00 2001 From: posimai Date: Tue, 31 Mar 2026 07:48:10 +0900 Subject: [PATCH] feat(atlas): metrics panel for nodes with health_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ubuntu-pc node: add url + health_url pointing to posimai-dev /api/health - Detail panel: CPU/memory bars, uptime, active sessions, "posimai-dev を開く" link - checkNodeHealth: fetch /api/health (JSON) when health_url present, fallback to HEAD for others - CSS: .metric-row, .metric-bar-track/fill (warn/crit variants), .metric-stat-grid Co-Authored-By: Claude Sonnet 4.6 --- atlas.json | 8 ++- index.html | 170 +++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 165 insertions(+), 13 deletions(-) diff --git a/atlas.json b/atlas.json index 1e97626..922c2a8 100644 --- a/atlas.json +++ b/atlas.json @@ -17,7 +17,9 @@ "id": "ubuntu-pc", "label": "Ubuntu PC", "type": "device", - "description": "Linux 開発・検証機。Tailscale 経由でアクセス", + "description": "常時起動の開発サーバー。posimai-dev(ブラウザターミナル)稼働中。Claude Code インストール済み。Tailscale HTTPS でどこからでもアクセス可能。", + "url": "https://100.77.11.43:3333", + "health_url": "https://100.77.11.43:3333/api/health", "status": "active" }, { @@ -136,6 +138,8 @@ { "from": "posimai-apps", "to": "posimai-api", "type": "calls", "label": "REST API" }, { "from": "posimai-apps", "to": "supabase", "type": "calls", "label": "realtime" }, { "from": "posimai-apps", "to": "stripe", "type": "calls", "label": "payment" }, - { "from": "windows-pc", "to": "vps-xserver", "type": "connects", "label": "SSH" } + { "from": "windows-pc", "to": "vps-xserver", "type": "connects", "label": "SSH" }, + { "from": "windows-pc", "to": "ubuntu-pc", "type": "connects", "label": "SSH / posimai-dev" }, + { "from": "android-phone", "to": "ubuntu-pc", "type": "connects", "label": "Tailscale HTTPS" } ] } diff --git a/index.html b/index.html index 950586c..18aab3f 100644 --- a/index.html +++ b/index.html @@ -812,6 +812,61 @@ opacity: 0.65; } + /* ── Metrics panel ────────────────────────────────── */ + #dp-metrics { + display: none; + flex-direction: column; + gap: 10px; + } + #dp-metrics.visible { display: flex; } + .metric-row { + display: flex; + flex-direction: column; + gap: 4px; + } + .metric-header { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text3); + } + .metric-value { font-weight: 500; color: var(--text2); } + .metric-bar-track { + height: 4px; + background: rgba(255,255,255,0.07); + border-radius: 2px; + overflow: hidden; + } + [data-theme="light"] .metric-bar-track { background: rgba(0,0,0,0.07); } + .metric-bar-fill { + height: 100%; + border-radius: 2px; + background: var(--accent); + transition: width 0.5s ease; + } + .metric-bar-fill.warn { background: #FB923C; } + .metric-bar-fill.crit { background: #F87171; } + .metric-stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + } + .metric-stat { + background: var(--surface2, rgba(255,255,255,0.04)); + border-radius: 8px; + padding: 8px 10px; + font-size: 11px; + } + .metric-stat-label { color: var(--text3); margin-bottom: 2px; } + .metric-stat-val { font-size: 14px; font-weight: 600; color: var(--text); letter-spacing: -0.02em; } + .metric-open-btn { + display: flex; align-items: center; gap: 5px; + font-size: 11px; color: var(--accent); text-decoration: none; + padding: 5px 0; border: none; background: none; cursor: pointer; + transition: opacity 0.15s; + } + .metric-open-btn:hover { opacity: 0.75; } + /* ── Tailscale scan ───────────────────────────────── */ .scan-status { font-size: 11px; @@ -1030,6 +1085,39 @@ 接続確認 + +
+ +
+
+ CPU + +
+
+
+
+
+ メモリ + +
+
+
+
+
+
稼働時間
+
+
+
+
セッション
+
+
+
+ + + posimai-dev を開く + +
+
@@ -2293,6 +2381,15 @@ if ('serviceWorker' in navigator) { } // ── Health check ─────────────────────────────────────────────── +function formatUptime(seconds) { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + async function checkNodeHealth(nodeId, url) { const btn = document.getElementById('dp-health-btn'); const dot = document.getElementById('dp-health-dot'); @@ -2301,16 +2398,69 @@ async function checkNodeHealth(nodeId, url) { label.textContent = '確認中...'; dot.className = 'health-dot'; + // メトリクスパネルを非表示にリセット + const metricsEl = document.getElementById('dp-metrics'); + metricsEl.classList.remove('visible'); + + const node = atlasData.nodes.find(n => n.id === nodeId); + const healthUrl = (node && node.health_url) ? node.health_url : null; + let result = 'offline'; - try { - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 6000); - const res = await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal }); - clearTimeout(timer); - // no-cors returns opaque (type='opaque', status=0) — server was reached - result = res.type === 'opaque' ? 'limited' : (res.ok ? 'online' : 'offline'); - } catch (e) { - result = 'offline'; + + // /api/health があればメトリクス取得を試みる + if (healthUrl) { + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 6000); + const res = await fetch(healthUrl, { signal: ctrl.signal }); + clearTimeout(timer); + if (res.ok) { + const data = await res.json(); + result = data.ok ? 'online' : 'limited'; + + // メトリクス描画 + const cpuPct = data.cpu_pct || 0; + const memPct = Math.round((data.mem_used_mb / data.mem_total_mb) * 100) || 0; + document.getElementById('metric-cpu-val').textContent = `${cpuPct}%`; + const cpuBar = document.getElementById('metric-cpu-bar'); + cpuBar.style.width = `${cpuPct}%`; + cpuBar.className = 'metric-bar-fill' + (cpuPct > 80 ? ' crit' : cpuPct > 60 ? ' warn' : ''); + + document.getElementById('metric-mem-val').textContent = + `${data.mem_used_mb} / ${data.mem_total_mb} MB (${memPct}%)`; + const memBar = document.getElementById('metric-mem-bar'); + memBar.style.width = `${memPct}%`; + memBar.className = 'metric-bar-fill' + (memPct > 85 ? ' crit' : memPct > 65 ? ' warn' : ''); + + document.getElementById('metric-uptime').textContent = + formatUptime(data.uptime_s || 0); + document.getElementById('metric-sessions').textContent = + `${data.active_sessions || 0} 本`; + + if (node.url) { + const openLink = document.getElementById('metric-open-link'); + openLink.href = node.url; + } + + metricsEl.classList.add('visible'); + if (window.lucide) lucide.createIcons({ nodes: [metricsEl] }); + } else { + result = 'offline'; + } + } catch (e) { + result = 'offline'; + } + } else { + // /api/health なし → 従来の HEAD リクエスト + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 6000); + const res = await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal }); + clearTimeout(timer); + result = res.type === 'opaque' ? 'limited' : (res.ok ? 'online' : 'offline'); + } catch (e) { + result = 'offline'; + } } const labelMap = { online: 'online', limited: '到達可能', offline: '到達不可' }; @@ -2318,8 +2468,6 @@ async function checkNodeHealth(nodeId, url) { label.textContent = labelMap[result]; btn.classList.remove('checking'); - // Update node status in data - const node = atlasData.nodes.find(n => n.id === nodeId); if (node) { node.status = result === 'offline' ? 'inactive' : 'active'; saveData();