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>
This commit is contained in:
parent
f9a26f1351
commit
cf115b346a
@ -1,7 +1,7 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=secubox-p2p
|
||||
PKG_VERSION:=0.4.0
|
||||
PKG_VERSION:=0.5.0
|
||||
PKG_RELEASE:=1
|
||||
|
||||
PKG_MAINTAINER:=SecuBox Team
|
||||
@ -21,8 +21,9 @@ define Package/secubox-p2p/description
|
||||
SecuBox P2P Hub backend providing peer discovery, mesh networking,
|
||||
DNS federation, and distributed service management. Includes mDNS
|
||||
service announcement, REST API on port 7331 for mesh visibility,
|
||||
and SecuBox Factory unified dashboard with Ed25519 signed Merkle
|
||||
snapshots for cryptographic configuration validation.
|
||||
SecuBox Factory unified dashboard with Ed25519 signed Merkle
|
||||
snapshots for cryptographic configuration validation, and distributed
|
||||
mesh services panel for aggregated service discovery across all nodes.
|
||||
endef
|
||||
|
||||
define Package/secubox-p2p/conffiles
|
||||
@ -64,6 +65,7 @@ define Package/secubox-p2p/install
|
||||
$(INSTALL_BIN) ./root/www/api/factory/run $(1)/www/api/factory/
|
||||
$(INSTALL_BIN) ./root/www/api/factory/snapshot $(1)/www/api/factory/
|
||||
$(INSTALL_BIN) ./root/www/api/factory/pubkey $(1)/www/api/factory/
|
||||
$(INSTALL_BIN) ./root/www/api/factory/mesh-services $(1)/www/api/factory/
|
||||
|
||||
# Factory Web UI
|
||||
$(INSTALL_DIR) $(1)/www/factory
|
||||
|
||||
134
package/secubox/secubox-p2p/root/www/api/factory/mesh-services
Normal file
134
package/secubox/secubox-p2p/root/www/api/factory/mesh-services
Normal file
@ -0,0 +1,134 @@
|
||||
#!/bin/sh
|
||||
# Factory Mesh Services - Aggregated service discovery across mesh
|
||||
# CGI endpoint for distributed service panel
|
||||
|
||||
echo "Content-Type: application/json"
|
||||
echo "Access-Control-Allow-Origin: *"
|
||||
echo "Access-Control-Allow-Methods: GET, OPTIONS"
|
||||
echo ""
|
||||
|
||||
# Handle CORS preflight
|
||||
if [ "$REQUEST_METHOD" = "OPTIONS" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get local node ID
|
||||
P2P_STATE_DIR="/var/run/secubox-p2p"
|
||||
LOCAL_NODE_ID=$(cat "$P2P_STATE_DIR/node.id" 2>/dev/null || hostname)
|
||||
LOCAL_NODE_NAME=$(uci -q get system.@system[0].hostname || hostname)
|
||||
|
||||
# Get local services
|
||||
get_local_services() {
|
||||
local services_json=$(/usr/sbin/secubox-p2p services 2>/dev/null)
|
||||
if [ -z "$services_json" ]; then
|
||||
echo '{"services":[]}'
|
||||
return
|
||||
fi
|
||||
echo "$services_json"
|
||||
}
|
||||
|
||||
# Get services from a peer
|
||||
get_peer_services() {
|
||||
local peer_addr="$1"
|
||||
local peer_name="$2"
|
||||
|
||||
# Query peer's services endpoint with timeout
|
||||
local peer_services=$(curl -s --connect-timeout 3 --max-time 5 "http://$peer_addr:7331/api/services" 2>/dev/null)
|
||||
|
||||
if [ -n "$peer_services" ] && echo "$peer_services" | grep -q "services"; then
|
||||
echo "$peer_services"
|
||||
else
|
||||
echo '{"services":[],"error":"unreachable"}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Build aggregated mesh services
|
||||
build_mesh_services() {
|
||||
local result='{"nodes":['
|
||||
local first_node=1
|
||||
|
||||
# Add local node
|
||||
local local_services=$(get_local_services)
|
||||
local local_svc_list=$(echo "$local_services" | jsonfilter -e '@.services[*]' 2>/dev/null)
|
||||
local local_svc_count=$(echo "$local_services" | jsonfilter -e '@.services[*]' 2>/dev/null | wc -l)
|
||||
|
||||
result="$result{\"node_id\":\"$LOCAL_NODE_ID\",\"node_name\":\"$LOCAL_NODE_NAME\",\"is_local\":true,\"status\":\"online\","
|
||||
result="$result\"services\":["
|
||||
|
||||
local i=0
|
||||
while [ $i -lt $local_svc_count ]; do
|
||||
local svc_name=$(echo "$local_services" | jsonfilter -e "@.services[$i].name" 2>/dev/null)
|
||||
local svc_status=$(echo "$local_services" | jsonfilter -e "@.services[$i].status" 2>/dev/null)
|
||||
local svc_enabled=$(echo "$local_services" | jsonfilter -e "@.services[$i].enabled" 2>/dev/null)
|
||||
local svc_port=$(echo "$local_services" | jsonfilter -e "@.services[$i].port" 2>/dev/null)
|
||||
|
||||
[ $i -gt 0 ] && result="$result,"
|
||||
result="$result{\"name\":\"$svc_name\",\"status\":\"$svc_status\",\"enabled\":$svc_enabled,\"port\":\"$svc_port\"}"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
result="$result]}"
|
||||
first_node=0
|
||||
|
||||
# Get peer services
|
||||
local peers_file="/tmp/secubox-p2p-peers.json"
|
||||
if [ -f "$peers_file" ]; then
|
||||
local peer_count=$(jsonfilter -i "$peers_file" -e '@.peers[*]' 2>/dev/null | wc -l)
|
||||
local p=0
|
||||
|
||||
while [ $p -lt $peer_count ]; do
|
||||
local peer_addr=$(jsonfilter -i "$peers_file" -e "@.peers[$p].address" 2>/dev/null)
|
||||
local peer_name=$(jsonfilter -i "$peers_file" -e "@.peers[$p].name" 2>/dev/null)
|
||||
local peer_id=$(jsonfilter -i "$peers_file" -e "@.peers[$p].id" 2>/dev/null)
|
||||
local is_local=$(jsonfilter -i "$peers_file" -e "@.peers[$p].is_local" 2>/dev/null)
|
||||
|
||||
# Skip local node in peers list
|
||||
if [ "$is_local" != "true" ] && [ -n "$peer_addr" ]; then
|
||||
local peer_services=$(get_peer_services "$peer_addr" "$peer_name")
|
||||
local peer_status="offline"
|
||||
local peer_error=""
|
||||
|
||||
if echo "$peer_services" | grep -q '"error"'; then
|
||||
peer_error=$(echo "$peer_services" | jsonfilter -e '@.error' 2>/dev/null)
|
||||
else
|
||||
peer_status="online"
|
||||
fi
|
||||
|
||||
result="$result,{\"node_id\":\"${peer_id:-$peer_addr}\",\"node_name\":\"${peer_name:-$peer_addr}\",\"address\":\"$peer_addr\",\"is_local\":false,\"status\":\"$peer_status\","
|
||||
|
||||
if [ "$peer_status" = "online" ]; then
|
||||
# Parse peer services
|
||||
local peer_svc_count=$(echo "$peer_services" | jsonfilter -e '@.services[*]' 2>/dev/null | wc -l)
|
||||
result="$result\"services\":["
|
||||
|
||||
local j=0
|
||||
while [ $j -lt $peer_svc_count ]; do
|
||||
local svc_name=$(echo "$peer_services" | jsonfilter -e "@.services[$j].name" 2>/dev/null)
|
||||
local svc_status=$(echo "$peer_services" | jsonfilter -e "@.services[$j].status" 2>/dev/null)
|
||||
local svc_enabled=$(echo "$peer_services" | jsonfilter -e "@.services[$j].enabled" 2>/dev/null)
|
||||
local svc_port=$(echo "$peer_services" | jsonfilter -e "@.services[$j].port" 2>/dev/null)
|
||||
|
||||
[ $j -gt 0 ] && result="$result,"
|
||||
result="$result{\"name\":\"$svc_name\",\"status\":\"$svc_status\",\"enabled\":${svc_enabled:-0},\"port\":\"$svc_port\"}"
|
||||
j=$((j + 1))
|
||||
done
|
||||
|
||||
result="$result]}"
|
||||
else
|
||||
result="$result\"services\":[],\"error\":\"$peer_error\"}"
|
||||
fi
|
||||
fi
|
||||
p=$((p + 1))
|
||||
done
|
||||
fi
|
||||
|
||||
result="$result],"
|
||||
|
||||
# Add summary stats
|
||||
result="$result\"summary\":{\"total_nodes\":0,\"online_nodes\":0,\"total_services\":0,\"running_services\":0}}"
|
||||
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
# Output aggregated mesh services
|
||||
build_mesh_services
|
||||
@ -97,6 +97,46 @@
|
||||
|
||||
/* 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>
|
||||
@ -113,9 +153,22 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="dashboard">
|
||||
<div class="card accent">
|
||||
<div class="stat"><div class="spinner"></div><div class="stat-label">Loading...</div></div>
|
||||
<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>
|
||||
|
||||
@ -137,8 +190,11 @@
|
||||
<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/';
|
||||
@ -168,6 +224,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@ -297,6 +382,124 @@
|
||||
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 = {};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user