feat(atlas): metrics panel for nodes with health_url

- 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 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-03-31 07:48:10 +09:00
parent f40a6df041
commit 0d580fe994
2 changed files with 165 additions and 13 deletions

View File

@ -17,7 +17,9 @@
"id": "ubuntu-pc", "id": "ubuntu-pc",
"label": "Ubuntu PC", "label": "Ubuntu PC",
"type": "device", "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" "status": "active"
}, },
{ {
@ -136,6 +138,8 @@
{ "from": "posimai-apps", "to": "posimai-api", "type": "calls", "label": "REST API" }, { "from": "posimai-apps", "to": "posimai-api", "type": "calls", "label": "REST API" },
{ "from": "posimai-apps", "to": "supabase", "type": "calls", "label": "realtime" }, { "from": "posimai-apps", "to": "supabase", "type": "calls", "label": "realtime" },
{ "from": "posimai-apps", "to": "stripe", "type": "calls", "label": "payment" }, { "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" }
] ]
} }

View File

@ -812,6 +812,61 @@
opacity: 0.65; 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 ───────────────────────────────── */ /* ── Tailscale scan ───────────────────────────────── */
.scan-status { .scan-status {
font-size: 11px; font-size: 11px;
@ -1030,6 +1085,39 @@
<span id="dp-health-label">接続確認</span> <span id="dp-health-label">接続確認</span>
</button> </button>
</div> </div>
<!-- メトリクス (ubuntu-pc など /api/health 対応ノード用) -->
<div id="dp-metrics">
<div class="detail-section-label">リアルタイム状態</div>
<div class="metric-row" id="metric-cpu-row">
<div class="metric-header">
<span>CPU</span>
<span class="metric-value" id="metric-cpu-val"></span>
</div>
<div class="metric-bar-track"><div class="metric-bar-fill" id="metric-cpu-bar" style="width:0%"></div></div>
</div>
<div class="metric-row" id="metric-mem-row">
<div class="metric-header">
<span>メモリ</span>
<span class="metric-value" id="metric-mem-val"></span>
</div>
<div class="metric-bar-track"><div class="metric-bar-fill" id="metric-mem-bar" style="width:0%"></div></div>
</div>
<div class="metric-stat-grid">
<div class="metric-stat">
<div class="metric-stat-label">稼働時間</div>
<div class="metric-stat-val" id="metric-uptime"></div>
</div>
<div class="metric-stat">
<div class="metric-stat-label">セッション</div>
<div class="metric-stat-val" id="metric-sessions"></div>
</div>
</div>
<a class="metric-open-btn" id="metric-open-link" href="#" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:12px;height:12px;stroke-width:1.75"></i>
posimai-dev を開く
</a>
</div>
<div id="dp-connections"> <div id="dp-connections">
<div class="detail-section-label">接続</div> <div class="detail-section-label">接続</div>
<div class="detail-connections" id="dp-conn-list"></div> <div class="detail-connections" id="dp-conn-list"></div>
@ -2293,6 +2381,15 @@ if ('serviceWorker' in navigator) {
} }
// ── Health check ─────────────────────────────────────────────── // ── 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) { async function checkNodeHealth(nodeId, url) {
const btn = document.getElementById('dp-health-btn'); const btn = document.getElementById('dp-health-btn');
const dot = document.getElementById('dp-health-dot'); const dot = document.getElementById('dp-health-dot');
@ -2301,25 +2398,76 @@ async function checkNodeHealth(nodeId, url) {
label.textContent = '確認中...'; label.textContent = '確認中...';
dot.className = 'health-dot'; 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'; let 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 { try {
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 6000); const timer = setTimeout(() => ctrl.abort(), 6000);
const res = await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal }); const res = await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal });
clearTimeout(timer); clearTimeout(timer);
// no-cors returns opaque (type='opaque', status=0) — server was reached
result = res.type === 'opaque' ? 'limited' : (res.ok ? 'online' : 'offline'); result = res.type === 'opaque' ? 'limited' : (res.ok ? 'online' : 'offline');
} catch (e) { } catch (e) {
result = 'offline'; result = 'offline';
} }
}
const labelMap = { online: 'online', limited: '到達可能', offline: '到達不可' }; const labelMap = { online: 'online', limited: '到達可能', offline: '到達不可' };
dot.className = `health-dot ${result}`; dot.className = `health-dot ${result}`;
label.textContent = labelMap[result]; label.textContent = labelMap[result];
btn.classList.remove('checking'); btn.classList.remove('checking');
// Update node status in data
const node = atlasData.nodes.find(n => n.id === nodeId);
if (node) { if (node) {
node.status = result === 'offline' ? 'inactive' : 'active'; node.status = result === 'offline' ? 'inactive' : 'active';
saveData(); saveData();