feat: periodic health monitoring with status visuals

This commit is contained in:
posimai 2026-03-29 18:51:38 +09:00
parent 30a43755f3
commit 173ffcffd8
1 changed files with 193 additions and 4 deletions

View File

@ -822,6 +822,44 @@
.scan-status.visible { display: block; } .scan-status.visible { display: block; }
.scan-status.ok { color: #4ADE80; } .scan-status.ok { color: #4ADE80; }
.scan-status.err { color: #F87171; } .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;
}
</style> </style>
</head> </head>
<body> <body>
@ -864,6 +902,22 @@
</button> </button>
</div> </div>
</div> </div>
<div>
<div class="settings-group-label">監視</div>
<div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:8px">
<div class="monitor-interval-row">
<span>ヘルスチェック間隔</span>
<select id="monitor-interval-sel">
<option value="0">オフ</option>
<option value="2">2分</option>
<option value="5" selected>5分</option>
<option value="10">10分</option>
<option value="30">30分</option>
</select>
</div>
<div id="monitor-last-checked" style="font-size:11px;color:var(--text3)"></div>
</div>
</div>
<div> <div>
<div class="settings-group-label">Auto-discovery</div> <div class="settings-group-label">Auto-discovery</div>
<div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:8px"> <div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:8px">
@ -903,8 +957,11 @@
<div class="header-dot" aria-hidden="true"></div> <div class="header-dot" aria-hidden="true"></div>
<span class="header-title">Atlas</span> <span class="header-title">Atlas</span>
</div> </div>
<div style="display:flex;align-items:center;gap:4px"> <div style="display:flex;align-items:center;gap:8px">
<span id="node-count" style="font-size:11px;color:var(--text3);padding:0 8px"></span> <div style="display:flex;align-items:center;gap:5px">
<div id="monitor-dot" title="監視オフ"></div>
<span id="node-count" style="font-size:11px;color:var(--text3)"></span>
</div>
<button class="icon-btn" id="settingsBtn" aria-label="設定" aria-expanded="false"> <button class="icon-btn" id="settingsBtn" aria-label="設定" aria-expanded="false">
<i data-lucide="settings" style="width:18px;height:18px;stroke-width:1.5"></i> <i data-lucide="settings" style="width:18px;height:18px;stroke-width:1.5"></i>
</button> </button>
@ -1343,9 +1400,19 @@ function initGraph() {
nodeSel = nodeGroup.append('circle') nodeSel = nodeGroup.append('circle')
.attr('class', 'graph-node-circle') .attr('class', 'graph-node-circle')
.attr('r', 20) .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]) .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') labelSel = nodeGroup.append('text')
.attr('class', 'graph-node-label') .attr('class', 'graph-node-label')
@ -1763,6 +1830,118 @@ function exportJson() {
URL.revokeObjectURL(a.href); 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 ───────────────────────────────────────────── // ── Tailscale scan ─────────────────────────────────────────────
async function runTailscaleScan() { async function runTailscaleScan() {
const token = document.getElementById('tailscale-token-input').value.trim(); const token = document.getElementById('tailscale-token-input').value.trim();
@ -1897,6 +2076,10 @@ function bindEvents() {
if (e.key === 'Enter') runTailscaleScan(); if (e.key === 'Enter') runTailscaleScan();
}); });
document.getElementById('monitor-interval-sel').addEventListener('change', e => {
applyMonitorSetting(parseInt(e.target.value, 10));
});
// File input (wizard + settings import) // File input (wizard + settings import)
document.getElementById('fileInput').addEventListener('change', e => { document.getElementById('fileInput').addEventListener('change', e => {
const isWizard = !document.getElementById('wizard-overlay').hasAttribute('hidden'); const isWizard = !document.getElementById('wizard-overlay').hasAttribute('hidden');
@ -2105,6 +2288,12 @@ loadData().then(() => {
buildFilterBar(); buildFilterBar();
initGraph(); initGraph();
setTimeout(fitGraph, 800); setTimeout(fitGraph, 800);
// Restore monitoring preference
const savedMinutes = loadMonitorPref();
if (savedMinutes > 0) {
// Delay start so graph is ready
setTimeout(() => applyMonitorSetting(savedMinutes), 1200);
}
} }
}); });
</script> </script>