secubox-openwrt/package/secubox/secubox-p2p/root/www/factory/index.html
CyberMind-FR a9130715e9 feat(p2p): Add SecuBox Factory unified dashboard with signed Merkle snapshots
Implement mesh-distributed, cryptographically-validated control center:

- Add factory.sh library with Ed25519 signing via signify-openbsd
- Add Merkle tree calculation for /etc/config validation
- Add CGI endpoints: dashboard, tools, run, snapshot, pubkey
- Add KISS Web UI (~280 lines vanilla JS, inline CSS, zero deps)
- Add gossip-based 3-peer fanout for snapshot synchronization
- Add offline operations queue with replay on reconnect
- Add LuCI iframe integration under MirrorBox > Factory tab
- Configure uhttpd alias for /factory/ on port 7331
- Bump secubox-p2p version to 0.4.0

Factory UI accessible at http://<device>:7331/factory/

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

372 lines
17 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; }
</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 id="dashboard">
<div class="card accent">
<div class="stat"><div class="spinner"></div><div class="stat-label">Loading...</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 tools = [];
let refreshInterval = null;
// API helpers
const api = {
get: async (path) => {
const r = await fetch('/api/factory/' + path);
return r.json();
},
post: async (path, body) => {
const r = await fetch('/api/factory/' + 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);
}
}
// 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 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>