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:
parent
f40a6df041
commit
0d580fe994
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
170
index.html
170
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 @@
|
|||
<span id="dp-health-label">接続確認</span>
|
||||
</button>
|
||||
</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 class="detail-section-label">接続</div>
|
||||
<div class="detail-connections" id="dp-conn-list"></div>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue