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:
CyberMind-FR 2026-01-31 08:56:22 +01:00
parent f9a26f1351
commit cf115b346a
3 changed files with 345 additions and 6 deletions

View File

@ -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

View 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

View File

@ -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 = {};