feat: periodic health monitoring with status visuals
This commit is contained in:
parent
30a43755f3
commit
173ffcffd8
197
index.html
197
index.html
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue