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",
|
"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" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
154
index.html
154
index.html
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue