Separate static files from CGI scripts in uhttpd configuration: - Static files (index.html) served from /www - CGI scripts executed from /www/api/* - API base changed from /factory/ to /api/factory/ This fixes HAProxy routing where /factory/ serves the UI and /api/factory/* routes to the P2P API backend. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
995 lines
49 KiB
HTML
995 lines
49 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; }
|
|
|
|
/* Emoji nav */
|
|
.emoji-nav { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
|
.emoji-btn { font-size: 1.5rem; padding: 0.5rem; background: var(--card); border: 2px solid transparent; border-radius: 0.5rem; cursor: pointer; transition: all 0.2s; filter: grayscale(0.5); opacity: 0.7; }
|
|
.emoji-btn:hover { filter: grayscale(0); opacity: 1; transform: scale(1.1); }
|
|
.emoji-btn.active { filter: grayscale(0); opacity: 1; border-color: var(--accent); background: rgba(99, 102, 241, 0.2); }
|
|
.emoji-btn[title]:hover::after { content: attr(title); position: absolute; bottom: -1.5rem; left: 50%; transform: translateX(-50%); font-size: 0.65rem; background: var(--card); padding: 0.2rem 0.4rem; border-radius: 0.2rem; white-space: nowrap; }
|
|
.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; }
|
|
|
|
/* Health emoji status */
|
|
.health-emoji { cursor: pointer; font-size: 1rem; transition: transform 0.2s; }
|
|
.health-emoji:hover { transform: scale(1.3); }
|
|
.health-emoji.clickable { cursor: pointer; }
|
|
|
|
/* Service URL display */
|
|
.service-url { font-size: 0.65rem; color: var(--accent); font-family: ui-monospace, monospace; word-break: break-all; }
|
|
.service-url a { color: var(--accent); text-decoration: none; }
|
|
.service-url a:hover { text-decoration: underline; }
|
|
|
|
/* QR code modal */
|
|
.qr-modal { text-align: center; }
|
|
.qr-modal canvas { margin: 1rem auto; display: block; background: white; padding: 1rem; border-radius: 0.5rem; }
|
|
.qr-url { font-family: ui-monospace, monospace; font-size: 0.8rem; color: var(--accent); margin-top: 0.5rem; word-break: break-all; }
|
|
|
|
/* Stat emoji clickable */
|
|
.stat-emoji { font-size: 1.5rem; cursor: pointer; transition: transform 0.2s; }
|
|
.stat-emoji:hover { transform: scale(1.2); }
|
|
|
|
/* Catalog styles */
|
|
.catalog-service { background: var(--card); border-radius: 0.5rem; overflow: hidden; margin-bottom: 0.75rem; }
|
|
.catalog-service-header { padding: 0.75rem 1rem; background: rgba(0,0,0,0.2); display: flex; justify-content: space-between; align-items: center; cursor: pointer; border-bottom: 1px solid var(--border); }
|
|
.catalog-service-header:hover { background: rgba(0,0,0,0.3); }
|
|
.catalog-service-name { font-size: 1rem; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; }
|
|
.catalog-service-meta { display: flex; gap: 0.5rem; align-items: center; }
|
|
.catalog-endpoints { padding: 0; max-height: 0; overflow: hidden; transition: max-height 0.3s ease, padding 0.3s ease; }
|
|
.catalog-endpoints.open { padding: 0.75rem 1rem; max-height: 500px; }
|
|
.endpoint-item { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; background: rgba(0,0,0,0.15); border-radius: 0.25rem; border-left: 3px solid var(--border); }
|
|
.endpoint-item.primary { border-left-color: var(--accent); }
|
|
.endpoint-item.haproxy { border-left-color: var(--success); }
|
|
.endpoint-item.mesh { border-left-color: #818cf8; }
|
|
.endpoint-item.local { border-left-color: var(--muted); }
|
|
.endpoint-type { font-size: 0.65rem; text-transform: uppercase; padding: 0.15rem 0.4rem; border-radius: 0.15rem; background: var(--border); margin-right: 0.5rem; }
|
|
.endpoint-type.haproxy { background: var(--success); color: #000; }
|
|
.endpoint-type.mesh { background: #818cf8; }
|
|
.endpoint-type.local { background: var(--muted); }
|
|
.endpoint-url { flex: 1; font-family: ui-monospace, monospace; font-size: 0.8rem; color: var(--accent); word-break: break-all; }
|
|
.endpoint-url a { color: inherit; text-decoration: none; }
|
|
.endpoint-url a:hover { text-decoration: underline; }
|
|
.endpoint-actions { display: flex; gap: 0.35rem; }
|
|
.endpoint-badge { font-size: 0.6rem; padding: 0.1rem 0.3rem; border-radius: 0.15rem; background: var(--accent); }
|
|
.endpoint-badge.ssl { background: var(--success); color: #000; }
|
|
|
|
/* Catalog node grouping */
|
|
.catalog-node-group { margin-bottom: 1.5rem; }
|
|
.catalog-node-header { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 2px solid var(--accent); margin-bottom: 0.75rem; }
|
|
.catalog-node-name { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 0.5rem; }
|
|
.catalog-node-stats { font-size: 0.75rem; color: var(--muted); }
|
|
|
|
/* URL tabs in QR modal */
|
|
.url-tabs { display: flex; gap: 0.25rem; margin-bottom: 0.75rem; flex-wrap: wrap; justify-content: center; }
|
|
.url-tab { padding: 0.35rem 0.6rem; border-radius: 0.25rem; font-size: 0.7rem; cursor: pointer; background: var(--border); border: none; color: var(--text); }
|
|
.url-tab.active { background: var(--accent); }
|
|
.url-tab.haproxy { border-bottom: 2px solid var(--success); }
|
|
.url-tab.mesh { border-bottom: 2px solid #818cf8; }
|
|
.url-tab.local { border-bottom: 2px solid var(--muted); }
|
|
|
|
/* Linked peers / mesh navigation */
|
|
.peer-links { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; padding: 0.75rem; background: var(--card); border-radius: 0.5rem; border-left: 3px solid #818cf8; }
|
|
.peer-links-title { font-size: 0.75rem; color: var(--muted); width: 100%; margin-bottom: 0.25rem; }
|
|
.peer-link { display: flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.6rem; background: rgba(0,0,0,0.2); border-radius: 0.25rem; font-size: 0.75rem; cursor: pointer; transition: all 0.2s; text-decoration: none; color: var(--text); }
|
|
.peer-link:hover { background: var(--accent); transform: translateY(-1px); }
|
|
.peer-link.online { border-left: 2px solid var(--success); }
|
|
.peer-link.offline { border-left: 2px solid var(--danger); opacity: 0.6; }
|
|
.peer-link-name { font-weight: 500; }
|
|
.peer-link-dns { font-family: ui-monospace, monospace; font-size: 0.65rem; color: var(--accent); }
|
|
|
|
/* DNS info badge */
|
|
.dns-info { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.25rem 0.5rem; background: rgba(129, 140, 248, 0.2); border-radius: 0.25rem; font-size: 0.7rem; font-family: ui-monospace, monospace; color: #818cf8; }
|
|
.dns-enabled { color: var(--success); }
|
|
.dns-disabled { color: var(--muted); }
|
|
</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="emoji-nav">
|
|
<button class="emoji-btn active" onclick="switchTab('dashboard')" title="Dashboard">🏭</button>
|
|
<button class="emoji-btn" onclick="switchTab('services')" title="Mesh Services">🔗</button>
|
|
<button class="emoji-btn" onclick="switchTab('catalog')" title="Distributed Catalog">📚</button>
|
|
</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">🧰</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main style="padding: 1rem;">
|
|
|
|
<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>
|
|
|
|
<div id="catalog" class="tab-content">
|
|
<div class="card accent">
|
|
<div class="stat"><div class="spinner"></div><div class="stat-label">Loading catalog...</div></div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<div class="overlay" id="overlay" onclick="toggleTools()"></div>
|
|
|
|
<aside class="tools-panel" id="tools">
|
|
<h2>Tools <button onclick="toggleTools()">×</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;">×</button></h3>
|
|
<pre id="modal-output"></pre>
|
|
<div class="actions">
|
|
<button onclick="closeModal()">Close</button>
|
|
</div>
|
|
</dialog>
|
|
|
|
<dialog id="qr-modal" class="qr-modal">
|
|
<h3><span id="qr-title">Service Access</span><button onclick="closeQR()" style="background:transparent;font-size:1.25rem;padding:0.25rem;">×</button></h3>
|
|
<canvas id="qr-canvas" width="200" height="200"></canvas>
|
|
<div class="qr-url" id="qr-url"></div>
|
|
<div class="actions">
|
|
<button onclick="copyUrl()">📋 Copy URL</button>
|
|
<button onclick="closeQR()">Close</button>
|
|
</div>
|
|
</dialog>
|
|
|
|
<script>
|
|
// State
|
|
let data = { local: {}, peers: [], snapshot: {}, services: {}, system: {} };
|
|
let meshServices = { nodes: [], summary: {} };
|
|
let catalogData = { services: [], node_name: '', updated: '' };
|
|
let tools = [];
|
|
let refreshInterval = null;
|
|
let activeTab = 'dashboard';
|
|
let serviceFilter = { search: '', status: 'all', node: 'all' };
|
|
let catalogFilter = { search: '', type: 'all', status: 'all' };
|
|
let expandedServices = new Set();
|
|
|
|
// API helpers - use same origin (HAProxy routes /api/factory/* to P2P API backend)
|
|
const apiBase = '/api/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);
|
|
}
|
|
}
|
|
|
|
// Refresh catalog data
|
|
async function refreshCatalog() {
|
|
try {
|
|
catalogData = await api.get('catalog');
|
|
renderCatalog();
|
|
} catch (e) {
|
|
console.error('Catalog refresh failed:', e);
|
|
}
|
|
}
|
|
|
|
// Switch tabs
|
|
function switchTab(tab) {
|
|
activeTab = tab;
|
|
document.querySelectorAll('.emoji-btn').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
document.querySelector(`.emoji-btn[onclick*="${tab}"]`).classList.add('active');
|
|
document.getElementById(tab).classList.add('active');
|
|
|
|
if (tab === 'services') {
|
|
refreshMeshServices();
|
|
} else if (tab === 'catalog') {
|
|
refreshCatalog();
|
|
}
|
|
}
|
|
|
|
// Filter catalog
|
|
function filterCatalog(type, value) {
|
|
catalogFilter[type] = value;
|
|
renderCatalog();
|
|
}
|
|
|
|
// Toggle service expansion
|
|
function toggleServiceExpand(name) {
|
|
if (expandedServices.has(name)) {
|
|
expandedServices.delete(name);
|
|
} else {
|
|
expandedServices.add(name);
|
|
}
|
|
renderCatalog();
|
|
}
|
|
|
|
// 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 hasPort = svc.port && svc.port !== '' && svc.port !== '0';
|
|
const accessUrl = hasPort ? (node.is_local ? `http://${location.hostname}:${svc.port}` : `http://${nodeAddr}:${svc.port}`) : null;
|
|
const healthEmoji = svc.status === 'running' ? '🟢' : (svc.enabled ? '🔴' : '⚫');
|
|
const healthTitle = svc.status === 'running' ? 'Running' : (svc.enabled ? 'Stopped' : 'Disabled');
|
|
|
|
html += `
|
|
<div class="service-item">
|
|
<div class="service-info">
|
|
<div class="service-name">${svc.name}</div>
|
|
${accessUrl ? `<div class="service-url"><a href="${accessUrl}" target="_blank">${accessUrl}</a></div>` : (hasPort ? `<div class="service-port">:${svc.port}</div>` : '')}
|
|
</div>
|
|
<div class="service-status">
|
|
${accessUrl ? `<span class="health-emoji clickable" onclick="showQR('${svc.name}', '${accessUrl}')" title="Show QR">📱</span>` : ''}
|
|
<span class="health-emoji" title="${healthTitle}" onclick="toggleService('${svc.name}', '${svc.status}')">${healthEmoji}</span>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
}
|
|
|
|
html += `
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
html += `</div>`;
|
|
|
|
document.getElementById('services').innerHTML = html;
|
|
}
|
|
|
|
// Render catalog panel
|
|
function renderCatalog() {
|
|
const services = catalogData.services || [];
|
|
const nodeName = catalogData.node_name || 'Local';
|
|
const nodeId = catalogData.node_id || '';
|
|
const updated = catalogData.updated || '';
|
|
const dns = catalogData.dns || { enabled: false, domain: 'mesh.local', hostname: '', fqdn: '' };
|
|
const linkedPeers = catalogData.linked_peers || [];
|
|
|
|
// Count stats
|
|
let totalServices = services.length;
|
|
let runningServices = services.filter(s => s.status === 'running').length;
|
|
let haproxyCount = 0;
|
|
let meshCount = 0;
|
|
let localCount = 0;
|
|
|
|
services.forEach(s => {
|
|
(s.endpoints || []).forEach(ep => {
|
|
if (ep.type === 'haproxy') haproxyCount++;
|
|
else if (ep.type === 'mesh') meshCount++;
|
|
else if (ep.type === 'local') localCount++;
|
|
});
|
|
});
|
|
|
|
// Filter services
|
|
let filteredServices = services;
|
|
if (catalogFilter.search) {
|
|
const search = catalogFilter.search.toLowerCase();
|
|
filteredServices = filteredServices.filter(s => s.name.toLowerCase().includes(search));
|
|
}
|
|
if (catalogFilter.status !== 'all') {
|
|
filteredServices = filteredServices.filter(s => s.status === catalogFilter.status);
|
|
}
|
|
if (catalogFilter.type !== 'all') {
|
|
filteredServices = filteredServices.filter(s =>
|
|
(s.endpoints || []).some(ep => ep.type === catalogFilter.type)
|
|
);
|
|
}
|
|
|
|
// Build linked peers section
|
|
let peersHtml = '';
|
|
if (linkedPeers.length > 0) {
|
|
peersHtml = `
|
|
<div class="peer-links">
|
|
<div class="peer-links-title">🔗 Linked Mesh Nodes</div>
|
|
${linkedPeers.map(peer => `
|
|
<a href="${peer.factory_url}" target="_blank" class="peer-link ${peer.status}" title="Open ${peer.name} Factory Dashboard">
|
|
<span>${peer.status === 'online' ? '🟢' : '🔴'}</span>
|
|
<span class="peer-link-name">${peer.name}</span>
|
|
${dns.enabled && peer.dns_fqdn ? `<span class="peer-link-dns">${peer.dns_fqdn}</span>` : ''}
|
|
</a>
|
|
`).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
let html = `
|
|
<div class="services-summary">
|
|
<div class="summary-item">
|
|
<span class="summary-count">${runningServices}/${totalServices}</span>
|
|
<span class="summary-label">Services</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-count" style="color: var(--success);">${haproxyCount}</span>
|
|
<span class="summary-label">HAProxy URLs</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-count" style="color: #818cf8;">${meshCount}</span>
|
|
<span class="summary-label">Mesh URLs</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-count" style="color: var(--muted);">${localCount}</span>
|
|
<span class="summary-label">Local URLs</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="dns-info ${dns.enabled ? 'dns-enabled' : 'dns-disabled'}">
|
|
${dns.enabled ? '🌐' : '⚫'} ${dns.enabled ? dns.fqdn : 'DNS off'}
|
|
</span>
|
|
</div>
|
|
<button class="sm" onclick="refreshCatalog()">Refresh</button>
|
|
<button class="sm primary" onclick="syncCatalog()">Sync Peers</button>
|
|
</div>
|
|
|
|
${peersHtml}
|
|
|
|
<div class="filter-bar">
|
|
<input type="text" class="filter-input" placeholder="Search services..."
|
|
value="${catalogFilter.search}"
|
|
oninput="filterCatalog('search', this.value)">
|
|
<select class="filter-select" onchange="filterCatalog('status', this.value)">
|
|
<option value="all" ${catalogFilter.status === 'all' ? 'selected' : ''}>All Status</option>
|
|
<option value="running" ${catalogFilter.status === 'running' ? 'selected' : ''}>Running</option>
|
|
<option value="stopped" ${catalogFilter.status === 'stopped' ? 'selected' : ''}>Stopped</option>
|
|
</select>
|
|
<select class="filter-select" onchange="filterCatalog('type', this.value)">
|
|
<option value="all" ${catalogFilter.type === 'all' ? 'selected' : ''}>All Endpoints</option>
|
|
<option value="haproxy" ${catalogFilter.type === 'haproxy' ? 'selected' : ''}>HAProxy (WAN)</option>
|
|
<option value="mesh" ${catalogFilter.type === 'mesh' ? 'selected' : ''}>Mesh (WG)</option>
|
|
<option value="local" ${catalogFilter.type === 'local' ? 'selected' : ''}>Local (LAN)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="catalog-node-group">
|
|
<div class="catalog-node-header">
|
|
<div class="catalog-node-name">
|
|
<span>📍</span> ${nodeName}
|
|
</div>
|
|
<div class="catalog-node-stats">
|
|
${updated ? `Updated: ${new Date(updated).toLocaleString()}` : ''}
|
|
</div>
|
|
</div>`;
|
|
|
|
if (filteredServices.length === 0) {
|
|
html += `<div class="card"><div style="text-align: center; padding: 2rem; color: var(--muted);">No services found matching filters</div></div>`;
|
|
} else {
|
|
filteredServices.forEach(svc => {
|
|
const isExpanded = expandedServices.has(svc.name);
|
|
const healthEmoji = svc.status === 'running' ? '🟢' : (svc.enabled ? '🔴' : '⚫');
|
|
const endpoints = svc.endpoints || [];
|
|
const primaryEndpoint = endpoints.find(ep => ep.primary) || endpoints[0];
|
|
const endpointCount = endpoints.length;
|
|
|
|
html += `
|
|
<div class="catalog-service">
|
|
<div class="catalog-service-header" onclick="toggleServiceExpand('${svc.name}')">
|
|
<div class="catalog-service-name">
|
|
<span>${healthEmoji}</span>
|
|
<span>${svc.name}</span>
|
|
${svc.port ? `<span class="badge badge-muted">:${svc.port}</span>` : ''}
|
|
</div>
|
|
<div class="catalog-service-meta">
|
|
<span class="badge badge-muted">${endpointCount} URL${endpointCount !== 1 ? 's' : ''}</span>
|
|
${primaryEndpoint ? `<span class="badge ${primaryEndpoint.ssl ? 'badge-ok' : 'badge-muted'}">${primaryEndpoint.type}</span>` : ''}
|
|
<span style="font-size: 0.8rem;">${isExpanded ? '▼' : '▶'}</span>
|
|
</div>
|
|
</div>
|
|
<div class="catalog-endpoints ${isExpanded ? 'open' : ''}">`;
|
|
|
|
// Sort endpoints: haproxy first, then mesh, then local
|
|
const sortOrder = { haproxy: 0, mesh: 1, local: 2 };
|
|
const sortedEndpoints = [...endpoints].sort((a, b) =>
|
|
(sortOrder[a.type] || 99) - (sortOrder[b.type] || 99)
|
|
);
|
|
|
|
sortedEndpoints.forEach(ep => {
|
|
const isPrimary = ep.primary === true;
|
|
html += `
|
|
<div class="endpoint-item ${ep.type} ${isPrimary ? 'primary' : ''}">
|
|
<span class="endpoint-type ${ep.type}">${ep.type}</span>
|
|
<div class="endpoint-url">
|
|
<a href="${ep.url}" target="_blank">${ep.url}</a>
|
|
</div>
|
|
<div class="endpoint-actions">
|
|
${ep.ssl ? '<span class="endpoint-badge ssl">SSL</span>' : ''}
|
|
${isPrimary ? '<span class="endpoint-badge">Primary</span>' : ''}
|
|
<button class="sm" onclick="event.stopPropagation(); showQRMulti('${svc.name}', ${JSON.stringify(endpoints).replace(/"/g, '"')}, '${ep.url}')" title="QR Code">📱</button>
|
|
<button class="sm" onclick="event.stopPropagation(); copyToClipboard('${ep.url}')" title="Copy URL">📋</button>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
</div>`;
|
|
});
|
|
}
|
|
|
|
html += `</div>`;
|
|
|
|
document.getElementById('catalog').innerHTML = html;
|
|
}
|
|
|
|
// Sync catalog with peers
|
|
async function syncCatalog() {
|
|
const modal = document.getElementById('modal');
|
|
document.getElementById('modal-title').textContent = 'Catalog Sync';
|
|
document.getElementById('modal-output').innerHTML = '<span class="spinner"></span> Syncing catalogs with peers...';
|
|
modal.showModal();
|
|
|
|
try {
|
|
const result = await api.post('run', { tool: 'catalog-sync' });
|
|
if (result.success) {
|
|
document.getElementById('modal-output').textContent = result.output || 'Sync completed';
|
|
refreshCatalog();
|
|
} else {
|
|
document.getElementById('modal-output').textContent = 'Error: ' + (result.error || 'Sync failed');
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('modal-output').textContent = 'Error: ' + e.message;
|
|
}
|
|
}
|
|
|
|
// Show QR with multiple URL options
|
|
function showQRMulti(name, endpoints, selectedUrl) {
|
|
currentQRUrl = selectedUrl;
|
|
document.getElementById('qr-title').textContent = name;
|
|
|
|
// Build URL tabs
|
|
let tabsHtml = '<div class="url-tabs">';
|
|
endpoints.forEach(ep => {
|
|
const isActive = ep.url === selectedUrl;
|
|
tabsHtml += `<button class="url-tab ${ep.type} ${isActive ? 'active' : ''}" onclick="switchQRUrl('${name}', '${ep.url}', '${ep.type}')">${ep.type}${ep.ssl ? ' 🔒' : ''}</button>`;
|
|
});
|
|
tabsHtml += '</div>';
|
|
|
|
// Insert tabs before URL display
|
|
const qrUrlEl = document.getElementById('qr-url');
|
|
qrUrlEl.innerHTML = tabsHtml + `<div style="margin-top: 0.5rem;">${selectedUrl}</div>`;
|
|
|
|
// Generate QR
|
|
const canvas = document.getElementById('qr-canvas');
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.onload = () => {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = 'white';
|
|
ctx.fillRect(0, 0, 200, 200);
|
|
ctx.drawImage(img, 0, 0, 200, 200);
|
|
};
|
|
img.src = `https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl=${encodeURIComponent(selectedUrl)}&choe=UTF-8`;
|
|
|
|
document.getElementById('qr-modal').showModal();
|
|
}
|
|
|
|
// Switch QR URL tab
|
|
function switchQRUrl(name, url, type) {
|
|
currentQRUrl = url;
|
|
|
|
// Update active tab
|
|
document.querySelectorAll('.url-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelector(`.url-tab.${type}`).classList.add('active');
|
|
|
|
// Update URL display
|
|
const qrUrlEl = document.getElementById('qr-url');
|
|
const tabsHtml = qrUrlEl.querySelector('.url-tabs').outerHTML;
|
|
qrUrlEl.innerHTML = tabsHtml + `<div style="margin-top: 0.5rem;">${url}</div>`;
|
|
|
|
// Regenerate QR
|
|
const canvas = document.getElementById('qr-canvas');
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.onload = () => {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = 'white';
|
|
ctx.fillRect(0, 0, 200, 200);
|
|
ctx.drawImage(img, 0, 0, 200, 200);
|
|
};
|
|
img.src = `https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl=${encodeURIComponent(url)}&choe=UTF-8`;
|
|
}
|
|
|
|
// Copy to clipboard helper
|
|
function copyToClipboard(text) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
// Brief visual feedback could be added here
|
|
});
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// QR Code functions
|
|
let currentQRUrl = '';
|
|
|
|
function showQR(name, url) {
|
|
currentQRUrl = url;
|
|
document.getElementById('qr-title').textContent = name;
|
|
document.getElementById('qr-url').textContent = url;
|
|
|
|
// Simple QR code using Google Charts API (zero deps)
|
|
const canvas = document.getElementById('qr-canvas');
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.onload = () => {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = 'white';
|
|
ctx.fillRect(0, 0, 200, 200);
|
|
ctx.drawImage(img, 0, 0, 200, 200);
|
|
};
|
|
img.src = `https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl=${encodeURIComponent(url)}&choe=UTF-8`;
|
|
|
|
document.getElementById('qr-modal').showModal();
|
|
}
|
|
|
|
function closeQR() {
|
|
document.getElementById('qr-modal').close();
|
|
}
|
|
|
|
function copyUrl() {
|
|
navigator.clipboard.writeText(currentQRUrl).then(() => {
|
|
const btn = event.target;
|
|
btn.textContent = '✓ Copied!';
|
|
setTimeout(() => btn.textContent = '📋 Copy URL', 2000);
|
|
});
|
|
}
|
|
|
|
function toggleService(name, status) {
|
|
// Could be extended to start/stop services
|
|
console.log(`Toggle service ${name} (currently ${status})`);
|
|
}
|
|
|
|
// Handle ESC key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
if (document.getElementById('qr-modal').open) closeQR();
|
|
else 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>
|