+
@@ -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);
+ }
}
});