diff --git a/index.html b/index.html index 0f9c631..843bd4d 100644 --- a/index.html +++ b/index.html @@ -822,6 +822,44 @@ .scan-status.visible { display: block; } .scan-status.ok { color: #4ADE80; } .scan-status.err { color: #F87171; } + + /* ── Monitoring ───────────────────────────────────── */ + #monitor-dot { + width: 7px; height: 7px; + border-radius: 50%; + background: var(--text3); + flex-shrink: 0; + transition: background 0.3s; + } + #monitor-dot.active { + background: #4ADE80; + box-shadow: 0 0 6px #4ADE8088; + animation: mon-pulse 2.4s ease-in-out infinite; + } + #monitor-dot.checking { + background: var(--accent); + animation: mon-pulse 0.6s ease-in-out infinite; + } + @keyframes mon-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } + } + .monitor-interval-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text2); + } + .monitor-interval-row select { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 12px; + padding: 3px 6px; + outline: none; + } @@ -864,6 +902,22 @@ +
+
監視
+
+
+ ヘルスチェック間隔 + +
+
+
+
Auto-discovery
@@ -903,8 +957,11 @@ Atlas
-
- +
+
+
+ +
@@ -1343,9 +1400,19 @@ function initGraph() { nodeSel = nodeGroup.append('circle') .attr('class', 'graph-node-circle') .attr('r', 20) - .attr('fill', d => TYPE_COLORS[d.type] + '18') + .attr('fill', d => { + const node = atlasData.nodes.find(n => n.id === d.id); + return TYPE_COLORS[d.type] + (node?.status === 'inactive' ? '0A' : '18'); + }) .attr('stroke', d => TYPE_COLORS[d.type]) - .style('filter', d => `drop-shadow(0 0 8px ${TYPE_COLORS[d.type]}55)`); + .style('filter', d => { + const node = atlasData.nodes.find(n => n.id === d.id); + return node?.status === 'inactive' ? 'none' : `drop-shadow(0 0 8px ${TYPE_COLORS[d.type]}55)`; + }) + .style('opacity', d => { + const node = atlasData.nodes.find(n => n.id === d.id); + return node?.status === 'inactive' ? 0.45 : 1; + }); labelSel = nodeGroup.append('text') .attr('class', 'graph-node-label') @@ -1763,6 +1830,118 @@ function exportJson() { URL.revokeObjectURL(a.href); } +// ── Periodic health monitoring ───────────────────────────────── +const MONITOR_KEY = 'posimai-atlas-monitor-interval'; // value: minutes (0=off) +let monitorTimer = null; + +function loadMonitorPref() { + return parseInt(localStorage.getItem(MONITOR_KEY) || '5', 10); +} + +function saveMonitorPref(minutes) { + localStorage.setItem(MONITOR_KEY, String(minutes)); +} + +function setMonitorDot(state) { // 'off' | 'active' | 'checking' + const dot = document.getElementById('monitor-dot'); + if (!dot) return; + dot.className = state === 'off' ? '' : state; + dot.title = state === 'off' ? '監視オフ' : state === 'checking' ? '確認中...' : `${loadMonitorPref()}分ごとに監視中`; +} + +async function runHealthCheckAll() { + const targets = atlasData.nodes.filter(n => n.url); + if (targets.length === 0) return; + + setMonitorDot('checking'); + let changed = false; + + for (const node of targets) { + const prev = node.status; + const result = await checkUrlHealth(node.url); + const next = result === 'offline' ? 'inactive' : 'active'; + if (next !== prev) { + node.status = next; + changed = true; + if (next === 'inactive') { + showToast(`${node.label} が応答していません`); + } + } + } + + if (changed) { + saveData(); + // Refresh node visuals (update opacity for inactive nodes) + updateNodeStatusVisuals(); + // Refresh detail panel if open + if (selectedNodeId) showDetail(selectedNodeId, currentSimNodes, currentSimEdges); + } + + const now = new Date().toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' }); + const el = document.getElementById('monitor-last-checked'); + if (el) el.textContent = `最終確認: ${now}`; + + setMonitorDot('active'); +} + +async function checkUrlHealth(url) { + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 7000); + const res = await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal }); + clearTimeout(timer); + return (res.type === 'opaque' || res.ok) ? 'online' : 'offline'; + } catch (e) { + return 'offline'; + } +} + +function updateNodeStatusVisuals() { + if (!nodeSel) return; + nodeSel + .attr('fill', d => { + const node = atlasData.nodes.find(n => n.id === d.id); + const alpha = node?.status === 'inactive' ? '0A' : '18'; + return TYPE_COLORS[d.type] + alpha; + }) + .style('filter', d => { + const node = atlasData.nodes.find(n => n.id === d.id); + if (node?.status === 'inactive') return 'none'; + return `drop-shadow(0 0 8px ${TYPE_COLORS[d.type]}55)`; + }) + .style('opacity', d => { + const node = atlasData.nodes.find(n => n.id === d.id); + return node?.status === 'inactive' ? 0.45 : 1; + }); + if (labelSel) { + labelSel.style('opacity', d => { + const node = atlasData.nodes.find(n => n.id === d.id); + return node?.status === 'inactive' ? 0.45 : 1; + }); + } +} + +function startMonitoring(minutes) { + if (monitorTimer) { clearInterval(monitorTimer); monitorTimer = null; } + if (!minutes || minutes <= 0) { setMonitorDot('off'); return; } + saveMonitorPref(minutes); + runHealthCheckAll(); // immediate + monitorTimer = setInterval(runHealthCheckAll, minutes * 60 * 1000); + setMonitorDot('active'); +} + +function stopMonitoring() { + if (monitorTimer) { clearInterval(monitorTimer); monitorTimer = null; } + setMonitorDot('off'); +} + +function applyMonitorSetting(minutes) { + const sel = document.getElementById('monitor-interval-sel'); + if (sel) sel.value = String(minutes); + if (minutes > 0) startMonitoring(minutes); + else stopMonitoring(); +} + // ── Tailscale scan ───────────────────────────────────────────── async function runTailscaleScan() { const token = document.getElementById('tailscale-token-input').value.trim(); @@ -1897,6 +2076,10 @@ function bindEvents() { if (e.key === 'Enter') runTailscaleScan(); }); + document.getElementById('monitor-interval-sel').addEventListener('change', e => { + applyMonitorSetting(parseInt(e.target.value, 10)); + }); + // File input (wizard + settings import) document.getElementById('fileInput').addEventListener('change', e => { const isWizard = !document.getElementById('wizard-overlay').hasAttribute('hidden'); @@ -2105,6 +2288,12 @@ loadData().then(() => { buildFilterBar(); initGraph(); setTimeout(fitGraph, 800); + // Restore monitoring preference + const savedMinutes = loadMonitorPref(); + if (savedMinutes > 0) { + // Delay start so graph is ready + setTimeout(() => applyMonitorSetting(savedMinutes), 1200); + } } });