secubox-openwrt/package/secubox/secubox-p2p/root/www/factory/index.html
CyberMind-FR cf115b346a feat(p2p): Add distributed mesh services panel
- Add mesh-services CGI endpoint for aggregated service discovery
  across all mesh peers
- Update Factory UI with tabbed interface: Dashboard and Mesh Services
- Mesh Services panel features:
  - Real-time service discovery from all nodes
  - Filter by search, status, or node
  - Direct access links for services with ports
  - Status indicators (running/stopped/disabled)
  - Summary stats (nodes online, running/total services)
- Bump version to 0.5.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 08:56:22 +01:00

577 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox Factory</title>
<style>
:root {
--bg: #0f172a; --card: #1e293b; --text: #f1f5f9; --muted: #94a3b8;
--accent: #6366f1; --success: #22c55e; --warn: #f59e0b; --danger: #ef4444;
--border: #334155;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; line-height: 1.5; }
/* Header */
header { position: sticky; top: 0; z-index: 100; background: var(--card); padding: 0.75rem 1rem; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); gap: 1rem; flex-wrap: wrap; }
.logo { display: flex; align-items: center; gap: 0.5rem; font-size: 1.1rem; font-weight: 600; }
.logo svg { width: 24px; height: 24px; }
.header-right { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.badge { padding: 0.25rem 0.6rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 500; white-space: nowrap; }
.badge-accent { background: var(--accent); }
.badge-ok { background: var(--success); }
.badge-warn { background: var(--warn); color: #000; }
.badge-err { background: var(--danger); }
.badge-muted { background: var(--border); color: var(--muted); }
/* Main grid */
main { padding: 1rem; display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
@media (max-width: 640px) { main { grid-template-columns: 1fr; } }
/* Cards */
.card { background: var(--card); padding: 1rem; border-radius: 0.5rem; border-left: 3px solid var(--border); }
.card.accent { border-color: var(--accent); }
.card.online { border-color: var(--success); }
.card.offline { border-color: var(--danger); }
.card.warn { border-color: var(--warn); }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; gap: 0.5rem; }
.card-title { font-size: 0.95rem; font-weight: 600; }
.card-subtitle { font-size: 0.75rem; color: var(--muted); margin-top: 0.25rem; }
/* Stats */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 0.75rem; }
.stat { text-align: center; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 0.375rem; }
.stat-value { font-size: 1.5rem; font-weight: 700; color: var(--accent); }
.stat-label { font-size: 0.65rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
/* Progress bar */
.progress { margin-top: 0.5rem; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); transition: width 0.3s; }
.progress-fill.warn { background: var(--warn); }
.progress-fill.danger { background: var(--danger); }
/* Buttons */
button { padding: 0.4rem 0.75rem; border: none; border-radius: 0.25rem; cursor: pointer; background: var(--border); color: var(--text); font-size: 0.75rem; font-weight: 500; transition: background 0.2s; display: inline-flex; align-items: center; gap: 0.35rem; }
button:hover { background: var(--accent); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.primary { background: var(--accent); }
button.danger { background: var(--danger); }
button.sm { padding: 0.25rem 0.5rem; font-size: 0.7rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; flex-wrap: wrap; }
/* Tools panel */
.tools-panel { position: fixed; right: 0; top: 0; width: min(320px, 90vw); height: 100%; background: var(--card); transform: translateX(100%); transition: transform 0.3s; padding: 1rem; border-left: 1px solid var(--border); overflow-y: auto; z-index: 200; }
.tools-panel.open { transform: translateX(0); }
.tools-panel h2 { font-size: 1rem; margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; }
.tools-panel h2 button { background: transparent; font-size: 1.25rem; padding: 0.25rem; }
.tool-category { margin-bottom: 1rem; }
.tool-category-title { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; padding-bottom: 0.25rem; border-bottom: 1px solid var(--border); }
.tool-btn { width: 100%; text-align: left; margin-bottom: 0.35rem; padding: 0.6rem 0.75rem; }
.tool-btn .tool-name { font-weight: 500; }
.tool-btn .tool-desc { font-size: 0.65rem; color: var(--muted); margin-top: 0.15rem; }
.tool-btn.dangerous { border-left: 2px solid var(--warn); }
/* Overlay */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 150; display: none; }
.overlay.active { display: block; }
/* Modal */
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 0.5rem; padding: 1.25rem; max-width: min(500px, 90vw); width: 100%; }
dialog::backdrop { background: rgba(0,0,0,0.7); }
dialog h3 { margin-bottom: 0.75rem; font-size: 1rem; display: flex; justify-content: space-between; align-items: center; }
dialog pre { background: var(--bg); padding: 0.75rem; border-radius: 0.375rem; overflow: auto; max-height: 300px; font-size: 0.75rem; font-family: ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; }
dialog .actions { justify-content: flex-end; margin-top: 1rem; }
/* Nodes list */
.node-item { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid var(--border); }
.node-item:last-child { border-bottom: none; }
.node-info { flex: 1; }
.node-name { font-weight: 500; font-size: 0.85rem; }
.node-addr { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; }
.node-merkle { font-size: 0.65rem; color: var(--muted); font-family: ui-monospace, monospace; }
/* Loading spinner */
.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Fingerprint display */
.fingerprint { font-family: ui-monospace, monospace; font-size: 0.8rem; background: var(--bg); padding: 0.35rem 0.5rem; border-radius: 0.25rem; letter-spacing: 0.05em; }
/* Tab navigation */
.tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
.tab { padding: 0.5rem 1rem; background: transparent; border: none; color: var(--muted); cursor: pointer; font-weight: 500; position: relative; }
.tab.active { color: var(--accent); }
.tab.active::after { content: ''; position: absolute; bottom: -0.5rem; left: 0; right: 0; height: 2px; background: var(--accent); }
.tab:hover { color: var(--text); }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Services mesh panel */
.services-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.node-services { background: var(--card); border-radius: 0.5rem; overflow: hidden; }
.node-services-header { padding: 0.75rem 1rem; background: rgba(0,0,0,0.2); display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); }
.node-services-header h4 { font-size: 0.9rem; font-weight: 600; }
.services-list { padding: 0.5rem; max-height: 300px; overflow-y: auto; }
.service-item { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-radius: 0.25rem; margin-bottom: 0.25rem; background: rgba(0,0,0,0.15); }
.service-item:hover { background: rgba(0,0,0,0.3); }
.service-info { flex: 1; min-width: 0; }
.service-name { font-size: 0.85rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.service-port { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; }
.service-status { display: flex; align-items: center; gap: 0.5rem; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
.status-dot.running { background: var(--success); }
.status-dot.stopped { background: var(--danger); }
.status-dot.disabled { background: var(--muted); }
.service-link { font-size: 0.7rem; padding: 0.2rem 0.4rem; background: var(--accent); border-radius: 0.15rem; text-decoration: none; color: var(--text); white-space: nowrap; }
.service-link:hover { background: #818cf8; }
/* Services summary bar */
.services-summary { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; padding: 1rem; background: var(--card); border-radius: 0.5rem; }
.summary-item { display: flex; align-items: center; gap: 0.5rem; }
.summary-count { font-size: 1.25rem; font-weight: 700; color: var(--accent); }
.summary-label { font-size: 0.75rem; color: var(--muted); }
/* Search/filter */
.filter-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.filter-input { flex: 1; min-width: 200px; padding: 0.5rem 0.75rem; background: var(--card); border: 1px solid var(--border); border-radius: 0.25rem; color: var(--text); font-size: 0.85rem; }
.filter-input:focus { outline: none; border-color: var(--accent); }
.filter-select { padding: 0.5rem 0.75rem; background: var(--card); border: 1px solid var(--border); border-radius: 0.25rem; color: var(--text); font-size: 0.85rem; }
</style>
</head>
<body>
<header>
<div class="logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M9 8h6M9 12h6M9 16h6M5 21V5a2 2 0 012-2h10a2 2 0 012 2v16"/></svg>
SecuBox Factory
</div>
<div class="header-right">
<span class="badge badge-muted" id="node-count">- nodes</span>
<span class="badge" id="snapshot-status">...</span>
<span class="fingerprint" id="fingerprint" title="Node Fingerprint">...</span>
<button onclick="toggleTools()" title="Tools">Tools</button>
</div>
</header>
<main style="padding: 1rem;">
<div class="tabs">
<button class="tab active" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" onclick="switchTab('services')">Mesh Services</button>
</div>
<div id="dashboard" class="tab-content active" style="display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));">
<div class="card accent">
<div class="stat"><div class="spinner"></div><div class="stat-label">Loading...</div></div>
</div>
</div>
<div id="services" class="tab-content">
<div class="card accent">
<div class="stat"><div class="spinner"></div><div class="stat-label">Loading services...</div></div>
</div>
</div>
</main>
<div class="overlay" id="overlay" onclick="toggleTools()"></div>
<aside class="tools-panel" id="tools">
<h2>Tools <button onclick="toggleTools()">&times;</button></h2>
<div id="tool-list"></div>
</aside>
<dialog id="modal">
<h3><span id="modal-title">Result</span><button onclick="closeModal()" style="background:transparent;font-size:1.25rem;padding:0.25rem;">&times;</button></h3>
<pre id="modal-output"></pre>
<div class="actions">
<button onclick="closeModal()">Close</button>
</div>
</dialog>
<script>
// State
let data = { local: {}, peers: [], snapshot: {}, services: {}, system: {} };
let meshServices = { nodes: [], summary: {} };
let tools = [];
let refreshInterval = null;
let activeTab = 'dashboard';
let serviceFilter = { search: '', status: 'all', node: 'all' };
// API helpers - use same origin (HAProxy routes /factory/* to API)
const apiBase = '/factory/';
const api = {
get: async (path) => {
const r = await fetch(apiBase + path);
return r.json();
},
post: async (path, body) => {
const r = await fetch(apiBase + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return r.json();
}
};
// Refresh dashboard data
async function refresh() {
try {
data = await api.get('dashboard');
render();
} catch (e) {
console.error('Refresh failed:', e);
}
}
// Refresh mesh services
async function refreshMeshServices() {
try {
meshServices = await api.get('mesh-services');
renderServices();
} catch (e) {
console.error('Mesh services refresh failed:', e);
}
}
// Switch tabs
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`.tab[onclick*="${tab}"]`).classList.add('active');
document.getElementById(tab).classList.add('active');
if (tab === 'services') {
refreshMeshServices();
}
}
// Filter services
function filterServices(type, value) {
serviceFilter[type] = value;
renderServices();
}
// Load tools list
async function loadTools() {
try {
const r = await api.get('tools');
tools = r.tools || [];
renderTools(r.categories || []);
} catch (e) {
console.error('Load tools failed:', e);
}
}
// Render dashboard
function render() {
const local = data.local || {};
const peers = data.peers || [];
const services = data.services || {};
const system = data.system || {};
const snapshot = data.snapshot || {};
// Header badges
const totalNodes = peers.filter(p => p.status === 'online').length + 1;
document.getElementById('node-count').textContent = `${totalNodes} node${totalNodes !== 1 ? 's' : ''}`;
const snapOk = snapshot.merkle_root && data.merkle_root;
const snapEl = document.getElementById('snapshot-status');
snapEl.className = 'badge ' + (snapOk ? 'badge-ok' : 'badge-warn');
snapEl.textContent = snapOk ? 'Verified' : 'No snapshot';
const fp = data.fingerprint || '...';
document.getElementById('fingerprint').textContent = fp;
document.getElementById('fingerprint').title = 'Node Fingerprint: ' + fp;
// Build dashboard HTML
let html = '';
// Stats card
html += `
<div class="card accent" style="grid-column: 1 / -1;">
<div class="stats-grid">
<div class="stat">
<div class="stat-value">${totalNodes}</div>
<div class="stat-label">Nodes Online</div>
</div>
<div class="stat">
<div class="stat-value">${services.running || 0}/${services.total || 0}</div>
<div class="stat-label">Services</div>
</div>
<div class="stat">
<div class="stat-value">${system.mem_pct || 0}%</div>
<div class="stat-label">Memory</div>
</div>
<div class="stat">
<div class="stat-value">${system.load || '0'}</div>
<div class="stat-label">Load</div>
</div>
<div class="stat">
<div class="stat-value">${data.pending_ops || 0}</div>
<div class="stat-label">Pending Ops</div>
</div>
</div>
</div>`;
// Local node card
html += `
<div class="card online">
<div class="card-header">
<div>
<div class="card-title">${local.node_name || 'Local Node'}</div>
<div class="card-subtitle">${local.node_id || 'unknown'}</div>
</div>
<span class="badge badge-ok">local</span>
</div>
<div class="node-addr">${local.address || '-'}:${local.api_port || 7331}</div>
${data.merkle_root ? `<div class="node-merkle">Merkle: ${data.merkle_root.slice(0, 16)}...</div>` : ''}
<div class="progress">
<div class="progress-fill ${system.mem_pct > 80 ? 'danger' : system.mem_pct > 60 ? 'warn' : ''}" style="width:${system.mem_pct || 0}%"></div>
</div>
<div class="actions">
<button class="sm" onclick="runTool('snapshot')">Snapshot</button>
<button class="sm" onclick="runTool('verify')">Verify</button>
<button class="sm" onclick="runTool('gossip')">Gossip</button>
</div>
</div>`;
// Merkle snapshot card
html += `
<div class="card ${snapOk ? 'online' : 'warn'}">
<div class="card-header">
<div class="card-title">Snapshot</div>
<span class="badge ${snapOk ? 'badge-ok' : 'badge-warn'}">${snapOk ? 'valid' : 'missing'}</span>
</div>
${snapshot.merkle_root ? `
<div class="node-merkle">Root: ${snapshot.merkle_root}</div>
<div class="card-subtitle">Created: ${snapshot.timestamp || '-'}</div>
<div class="card-subtitle">Hash: ${(snapshot.hash || '').slice(0, 24)}...</div>
` : '<div class="card-subtitle">No snapshot created yet</div>'}
<div class="actions">
<button class="sm primary" onclick="runTool('snapshot')">Create New</button>
<button class="sm" onclick="runTool('merkle')">Calc Merkle</button>
</div>
</div>`;
// Peer nodes card
html += `
<div class="card">
<div class="card-header">
<div class="card-title">Mesh Peers</div>
<span class="badge badge-muted">${peers.length} peer${peers.length !== 1 ? 's' : ''}</span>
</div>
${peers.length === 0 ? '<div class="card-subtitle">No peers discovered</div>' : ''}
${peers.map(p => `
<div class="node-item">
<div class="node-info">
<div class="node-name">${p.name || p.address}</div>
<div class="node-addr">${p.address}</div>
${p.merkle_root ? `<div class="node-merkle">Merkle: ${p.merkle_root.slice(0, 12)}...</div>` : ''}
</div>
<span class="badge ${p.status === 'online' ? 'badge-ok' : 'badge-err'}">${p.status || 'unknown'}</span>
</div>
`).join('')}
<div class="actions">
<button class="sm" onclick="runTool('discover')">Discover</button>
<button class="sm" onclick="runTool('gossip')">Sync All</button>
</div>
</div>`;
document.getElementById('dashboard').innerHTML = html;
}
// Render mesh services panel
function renderServices() {
const nodes = meshServices.nodes || [];
// Calculate summary
let totalNodes = nodes.length;
let onlineNodes = nodes.filter(n => n.status === 'online').length;
let totalServices = 0;
let runningServices = 0;
nodes.forEach(n => {
(n.services || []).forEach(s => {
totalServices++;
if (s.status === 'running') runningServices++;
});
});
// Get unique node names for filter
const nodeNames = nodes.map(n => ({ id: n.node_id, name: n.node_name }));
let html = `
<div class="services-summary">
<div class="summary-item">
<span class="summary-count">${onlineNodes}/${totalNodes}</span>
<span class="summary-label">Nodes Online</span>
</div>
<div class="summary-item">
<span class="summary-count">${runningServices}</span>
<span class="summary-label">Running Services</span>
</div>
<div class="summary-item">
<span class="summary-count">${totalServices}</span>
<span class="summary-label">Total Services</span>
</div>
<button class="sm" onclick="refreshMeshServices()">Refresh</button>
</div>
<div class="filter-bar">
<input type="text" class="filter-input" placeholder="Search services..."
value="${serviceFilter.search}"
oninput="filterServices('search', this.value)">
<select class="filter-select" onchange="filterServices('status', this.value)">
<option value="all" ${serviceFilter.status === 'all' ? 'selected' : ''}>All Status</option>
<option value="running" ${serviceFilter.status === 'running' ? 'selected' : ''}>Running</option>
<option value="stopped" ${serviceFilter.status === 'stopped' ? 'selected' : ''}>Stopped</option>
</select>
<select class="filter-select" onchange="filterServices('node', this.value)">
<option value="all" ${serviceFilter.node === 'all' ? 'selected' : ''}>All Nodes</option>
${nodeNames.map(n => `<option value="${n.id}" ${serviceFilter.node === n.id ? 'selected' : ''}>${n.name}</option>`).join('')}
</select>
</div>
<div class="services-grid">`;
// Render each node's services
nodes.forEach(node => {
// Skip if node filter doesn't match
if (serviceFilter.node !== 'all' && serviceFilter.node !== node.node_id) return;
let services = node.services || [];
// Apply filters
if (serviceFilter.search) {
const search = serviceFilter.search.toLowerCase();
services = services.filter(s => s.name.toLowerCase().includes(search));
}
if (serviceFilter.status !== 'all') {
services = services.filter(s => s.status === serviceFilter.status);
}
// Skip node if no matching services
if (services.length === 0 && serviceFilter.search) return;
const nodeStatus = node.status || 'offline';
const nodeAddr = node.address || '';
html += `
<div class="node-services">
<div class="node-services-header">
<h4>${node.node_name || node.node_id}${node.is_local ? ' (local)' : ''}</h4>
<span class="badge ${nodeStatus === 'online' ? 'badge-ok' : 'badge-err'}">${nodeStatus}</span>
</div>
<div class="services-list">`;
if (nodeStatus !== 'online' && !node.is_local) {
html += `<div style="padding: 1rem; text-align: center; color: var(--muted);">Node offline</div>`;
} else if (services.length === 0) {
html += `<div style="padding: 1rem; text-align: center; color: var(--muted);">No matching services</div>`;
} else {
services.forEach(svc => {
const statusClass = svc.status === 'running' ? 'running' : (svc.enabled ? 'stopped' : 'disabled');
const hasPort = svc.port && svc.port !== '' && svc.port !== '0';
const accessUrl = hasPort ? (node.is_local ? `http://${location.hostname}:${svc.port}` : `http://${nodeAddr}:${svc.port}`) : null;
html += `
<div class="service-item">
<div class="service-info">
<div class="service-name">${svc.name}</div>
${hasPort ? `<div class="service-port">Port: ${svc.port}</div>` : ''}
</div>
<div class="service-status">
${accessUrl ? `<a href="${accessUrl}" target="_blank" class="service-link">Open</a>` : ''}
<span class="status-dot ${statusClass}" title="${svc.status}"></span>
</div>
</div>`;
});
}
html += `
</div>
</div>`;
});
html += `</div>`;
document.getElementById('services').innerHTML = html;
}
// Render tools panel
function renderTools(categories) {
const grouped = {};
tools.forEach(t => {
if (!grouped[t.category]) grouped[t.category] = [];
grouped[t.category].push(t);
});
const catOrder = {};
categories.forEach((c, i) => catOrder[c.id] = { name: c.name, order: c.order || i });
const sortedCats = Object.keys(grouped).sort((a, b) => (catOrder[a]?.order || 99) - (catOrder[b]?.order || 99));
document.getElementById('tool-list').innerHTML = sortedCats.map(cat => `
<div class="tool-category">
<div class="tool-category-title">${catOrder[cat]?.name || cat}</div>
${grouped[cat].map(t => `
<button class="tool-btn ${t.dangerous ? 'dangerous' : ''}" onclick="runTool('${t.id}')">
<div class="tool-name">${t.name}</div>
<div class="tool-desc">${t.description}</div>
</button>
`).join('')}
</div>
`).join('');
}
// Toggle tools panel
function toggleTools() {
document.getElementById('tools').classList.toggle('open');
document.getElementById('overlay').classList.toggle('active');
}
// Run a tool
async function runTool(toolId) {
const modal = document.getElementById('modal');
const tool = tools.find(t => t.id === toolId) || { name: toolId };
document.getElementById('modal-title').textContent = 'Running: ' + tool.name;
document.getElementById('modal-output').innerHTML = '<span class="spinner"></span> Executing...';
modal.showModal();
try {
const result = await api.post('run', { tool: toolId });
if (result.success) {
document.getElementById('modal-output').textContent = result.output || 'Success';
} else {
document.getElementById('modal-output').textContent = 'Error: ' + (result.error || 'Unknown error');
}
refresh(); // Refresh dashboard after tool run
} catch (e) {
document.getElementById('modal-output').textContent = 'Error: ' + e.message;
}
}
// Close modal
function closeModal() {
document.getElementById('modal').close();
}
// Handle ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (document.getElementById('modal').open) closeModal();
else if (document.getElementById('tools').classList.contains('open')) toggleTools();
}
});
// Initialize
loadTools();
refresh();
refreshInterval = setInterval(refresh, 5000); // Poll every 5 seconds
</script>
</body>
</html>