@@ -136,6 +147,7 @@ class NodeDetailsComponent extends Component {
this.setHTML('', html);
this.setupTabs();
+ this.setupTabRefreshButton();
// Restore last active tab from view model if available
const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null;
if (restored) {
@@ -144,6 +156,218 @@ class NodeDetailsComponent extends Component {
this.setupFirmwareUpload();
}
+ setupTabRefreshButton() {
+ const btn = this.findElement('.tab-refresh-btn');
+ if (!btn) return;
+ this.addEventListener(btn, 'click', async (e) => {
+ e.stopPropagation();
+ const original = btn.innerHTML;
+ btn.disabled = true;
+ btn.innerHTML = `
+
+ `;
+
+ try {
+ const activeTab = (this.viewModel && typeof this.viewModel.get === 'function') ? (this.viewModel.get('activeTab') || 'status') : 'status';
+ const nodeIp = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('nodeIp') : null;
+ this.suppressLoadingUI = true;
+
+ if (activeTab === 'endpoints' && typeof this.viewModel.loadEndpointsData === 'function') {
+ await this.viewModel.loadEndpointsData();
+ } else if (activeTab === 'tasks' && typeof this.viewModel.loadTasksData === 'function') {
+ await this.viewModel.loadTasksData();
+ } else if (activeTab === 'status' && typeof this.viewModel.loadMonitoringResources === 'function') {
+ // status tab: load monitoring resources
+ await this.viewModel.loadMonitoringResources();
+ } else {
+ // firmware: refresh core node details
+ if (nodeIp && typeof this.viewModel.loadNodeDetails === 'function') {
+ await this.viewModel.loadNodeDetails(nodeIp);
+ }
+ }
+ } catch (err) {
+ logger.error('Tab refresh failed:', err);
+ } finally {
+ this.suppressLoadingUI = false;
+ btn.disabled = false;
+ btn.innerHTML = original;
+ }
+ });
+ }
+
+ renderStatusTab(nodeStatus, monitoringResources) {
+ let html = '';
+
+ // Add gauges section if monitoring resources are available
+ if (monitoringResources) {
+ html += this.renderResourceGauges(monitoringResources);
+ }
+
+ html += `
+
+ `;
+
+ // Add monitoring resources if available
+ if (monitoringResources) {
+ html += `
+
`;
+ }
+
+ return html;
+ }
+
+ renderResourceGauges(monitoringResources) {
+ // Get values with fallbacks and ensure they are numbers
+ const cpuUsage = parseFloat(monitoringResources.cpu?.average_usage) || 0;
+ const heapUsage = parseFloat(monitoringResources.memory?.heap_usage_percent) || 0;
+ const filesystemUsed = parseFloat(monitoringResources.filesystem?.used_bytes) || 0;
+ const filesystemTotal = parseFloat(monitoringResources.filesystem?.total_bytes) || 0;
+ const filesystemUsage = filesystemTotal > 0 ? (filesystemUsed / filesystemTotal) * 100 : 0;
+
+ // Convert filesystem bytes to KB
+ const filesystemUsedKB = Math.round(filesystemUsed / 1024);
+ const filesystemTotalKB = Math.round(filesystemTotal / 1024);
+
+ // Helper function to get color class based on percentage
+ const getColorClass = (percentage) => {
+ const numPercentage = parseFloat(percentage);
+
+ if (numPercentage === 0 || isNaN(numPercentage)) return 'gauge-empty';
+ if (numPercentage < 50) return 'gauge-green';
+ if (numPercentage < 80) return 'gauge-yellow';
+ return 'gauge-red';
+ };
+
+ return `
+
+ `;
+ }
+
renderEndpointsTab(endpoints) {
if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) {
return `
@@ -475,9 +699,14 @@ class NodeDetailsComponent extends Component {
uploadBtn.disabled = true;
uploadBtn.textContent = 'โณ Uploading...';
- // Get the member IP from the card
+ // Get the member IP from the card if available, otherwise fallback to view model state
const memberCard = this.container.closest('.member-card');
- const memberIp = memberCard.dataset.memberIp;
+ let memberIp = null;
+ if (memberCard && memberCard.dataset && memberCard.dataset.memberIp) {
+ memberIp = memberCard.dataset.memberIp;
+ } else if (this.viewModel && typeof this.viewModel.get === 'function') {
+ memberIp = this.viewModel.get('nodeIp');
+ }
if (!memberIp) {
throw new Error('Could not determine target node IP address');
diff --git a/public/scripts/components/TopologyGraphComponent.js b/public/scripts/components/TopologyGraphComponent.js
index cf6cc4e..2c29285 100644
--- a/public/scripts/components/TopologyGraphComponent.js
+++ b/public/scripts/components/TopologyGraphComponent.js
@@ -9,6 +9,106 @@ class TopologyGraphComponent extends Component {
this.width = 0; // Will be set dynamically based on container size
this.height = 0; // Will be set dynamically based on container size
this.isInitialized = false;
+
+ // Drawer state for desktop reuse (same pattern as ClusterMembersComponent)
+ this.drawer = new DrawerComponent();
+
+ // Tooltip for labels on hover
+ this.tooltipEl = null;
+ }
+
+ // Determine desktop threshold
+ isDesktop() {
+ return this.drawer.isDesktop();
+ }
+
+
+ openDrawerForNode(nodeData) {
+ // Get display name for drawer title
+ let displayName = 'Node Details';
+ try {
+ const hostname = nodeData.hostname || '';
+ const ip = nodeData.ip || '';
+
+ if (hostname && ip) {
+ displayName = `${hostname} - ${ip}`;
+ } else if (hostname) {
+ displayName = hostname;
+ } else if (ip) {
+ displayName = ip;
+ }
+ } catch (_) {}
+
+ // Open drawer with content callback
+ this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
+ // Mount NodeDetailsComponent
+ const nodeDetailsVM = new NodeDetailsViewModel();
+ const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
+ setActiveComponent(nodeDetailsComponent);
+
+ const ip = nodeData.ip || nodeData.id;
+ nodeDetailsVM.loadNodeDetails(ip).then(() => {
+ nodeDetailsComponent.mount();
+ }).catch((error) => {
+ logger.error('Failed to load node details (topology drawer):', error);
+ contentContainer.innerHTML = `
+
+ `;
+ });
+ });
+ }
+
+ closeDrawer() {
+ this.drawer.closeDrawer();
+ }
+
+ // Tooltip helpers
+ ensureTooltip() {
+ if (this.tooltipEl) return;
+ const el = document.createElement('div');
+ el.className = 'topology-tooltip';
+ document.body.appendChild(el);
+ this.tooltipEl = el;
+ }
+
+ showTooltip(nodeData, pageX, pageY) {
+ this.ensureTooltip();
+ const labels = (nodeData && nodeData.labels) ? nodeData.labels : ((nodeData && nodeData.resources) ? nodeData.resources : null);
+ if (!labels || Object.keys(labels).length === 0) {
+ this.hideTooltip();
+ return;
+ }
+ const chips = Object.entries(labels)
+ .map(([k, v]) => `
`;
+ this.positionTooltip(pageX, pageY);
+ this.tooltipEl.classList.add('visible');
+ }
+
+ positionTooltip(pageX, pageY) {
+ if (!this.tooltipEl) return;
+ const offset = 12;
+ let left = pageX + offset;
+ let top = pageY + offset;
+ const { innerWidth, innerHeight } = window;
+ const rect = this.tooltipEl.getBoundingClientRect();
+ if (left + rect.width > innerWidth - 8) left = pageX - rect.width - offset;
+ if (top + rect.height > innerHeight - 8) top = pageY - rect.height - offset;
+ this.tooltipEl.style.left = `${Math.max(8, left)}px`;
+ this.tooltipEl.style.top = `${Math.max(8, top)}px`;
+ }
+
+ moveTooltip(pageX, pageY) {
+ if (!this.tooltipEl || !this.tooltipEl.classList.contains('visible')) return;
+ this.positionTooltip(pageX, pageY);
+ }
+
+ hideTooltip() {
+ if (this.tooltipEl) this.tooltipEl.classList.remove('visible');
}
updateDimensions(container) {
@@ -287,15 +387,25 @@ class TopologyGraphComponent extends Component {
node.append('text')
.text(d => d.ip)
.attr('x', 15)
- .attr('y', 20)
+ .attr('y', 22)
.attr('font-size', '11px')
.attr('fill', 'var(--text-secondary)');
+ // App label (between IP and Status)
+ node.append('text')
+ .text(d => (d.labels && d.labels.app) ? String(d.labels.app) : '')
+ .attr('x', 15)
+ .attr('y', 38)
+ .attr('font-size', '11px')
+ .attr('fill', 'var(--text-secondary)')
+ .attr('font-weight', '500')
+ .attr('display', d => (d.labels && d.labels.app) ? null : 'none');
+
// Status text
node.append('text')
.text(d => d.status)
.attr('x', 15)
- .attr('y', 35)
+ .attr('y', 56)
.attr('font-size', '11px')
.attr('fill', d => this.getNodeColor(d.status))
.attr('font-weight', '600');
@@ -345,19 +455,31 @@ class TopologyGraphComponent extends Component {
node.on('click', (event, d) => {
this.viewModel.selectNode(d.id);
this.updateSelection(d.id);
- this.showMemberCardOverlay(d);
+ if (this.isDesktop()) {
+ // Desktop: open slide-in drawer, reuse NodeDetailsComponent
+ this.openDrawerForNode(d);
+ } else {
+ // Mobile/low-res: keep existing overlay
+ this.showMemberCardOverlay(d);
+ }
});
node.on('mouseover', (event, d) => {
d3.select(event.currentTarget).select('circle')
.attr('r', d => this.getNodeRadius(d.status) + 4)
.attr('stroke-width', 3);
+ this.showTooltip(d, event.pageX, event.pageY);
});
node.on('mouseout', (event, d) => {
d3.select(event.currentTarget).select('circle')
.attr('r', d => this.getNodeRadius(d.status))
.attr('stroke-width', 2);
+ this.hideTooltip();
+ });
+
+ node.on('mousemove', (event, d) => {
+ this.moveTooltip(event.pageX, event.pageY);
});
link.on('mouseover', (event, d) => {
@@ -600,7 +722,7 @@ class TopologyGraphComponent extends Component {
hostname: nodeData.hostname,
status: this.normalizeStatus(nodeData.status),
latency: nodeData.latency,
- labels: nodeData.resources || {}
+ labels: (nodeData.labels && typeof nodeData.labels === 'object') ? nodeData.labels : (nodeData.resources || {})
};
this.memberOverlayComponent.show(memberData);
@@ -709,10 +831,10 @@ class MemberCardOverlayComponent extends Component {
}
renderMemberCard(member) {
- const statusClass = member.status === 'active' ? 'status-online' :
- member.status === 'inactive' ? 'status-inactive' : 'status-offline';
- const statusIcon = member.status === 'active' ? '๐ข' :
- member.status === 'inactive' ? '๐ ' : '๐ด';
+ const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' :
+ (member.status && member.status.toUpperCase() === 'INACTIVE') ? 'status-inactive' : 'status-offline';
+ const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? '๐ข' :
+ (member.status && member.status.toUpperCase() === 'INACTIVE') ? '๐ ' : '๐ด';
return `
diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js
index 44904b8..38cfba1 100644
--- a/public/scripts/view-models.js
+++ b/public/scripts/view-models.js
@@ -42,7 +42,7 @@ class ClusterViewModel extends ViewModel {
const members = response.members || [];
const onlineNodes = Array.isArray(members)
- ? members.filter(m => m && m.status === 'active').length
+ ? members.filter(m => m && m.status && m.status.toUpperCase() === 'ACTIVE').length
: 0;
// Use batch update to preserve UI state
@@ -220,7 +220,8 @@ class NodeDetailsViewModel extends ViewModel {
activeTab: 'status',
nodeIp: null,
endpoints: null,
- tasksSummary: null
+ tasksSummary: null,
+ monitoringResources: null
});
}
@@ -250,6 +251,9 @@ class NodeDetailsViewModel extends ViewModel {
// Load endpoints data
await this.loadEndpointsData();
+ // Load monitoring resources data
+ await this.loadMonitoringResources();
+
} catch (error) {
console.error('Failed to load node details:', error);
this.set('error', error.message);
@@ -277,13 +281,29 @@ class NodeDetailsViewModel extends ViewModel {
try {
const ip = this.get('nodeIp');
const response = await window.apiClient.getEndpoints(ip);
- this.set('endpoints', response || null);
+ // Handle both real API (wrapped in endpoints) and mock API (direct array)
+ const endpointsData = (response && response.endpoints) ? response : { endpoints: response };
+ this.set('endpoints', endpointsData || null);
} catch (error) {
console.error('Failed to load endpoints:', error);
this.set('endpoints', null);
}
}
+ // Load monitoring resources data with state preservation
+ async loadMonitoringResources() {
+ try {
+ const ip = this.get('nodeIp');
+ const response = await window.apiClient.getMonitoringResources(ip);
+ // Handle both real API (wrapped in data) and mock API (direct response)
+ const monitoringData = (response && response.data) ? response.data : response;
+ this.set('monitoringResources', monitoringData);
+ } catch (error) {
+ console.error('Failed to load monitoring resources:', error);
+ this.set('monitoringResources', null);
+ }
+ }
+
// Invoke an endpoint against this node
async callEndpoint(method, uri, params) {
const ip = this.get('nodeIp');
@@ -536,6 +556,8 @@ class TopologyViewModel extends ViewModel {
ip: member.ip,
status: member.status || 'UNKNOWN',
latency: member.latency || 0,
+ // Preserve both legacy 'resources' and preferred 'labels'
+ labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}),
resources: member.resources || {},
x: Math.random() * 1200 + 100, // Better spacing for 1400px width
y: Math.random() * 800 + 100 // Better spacing for 1000px height
diff --git a/public/styles/main.css b/public/styles/main.css
index 42835eb..8e1d7b8 100644
--- a/public/styles/main.css
+++ b/public/styles/main.css
@@ -391,6 +391,173 @@ p {
opacity: 1;
}
+/* Monitoring Section Styles */
+.monitoring-section {
+ margin-top: 1.5rem;
+ padding-top: 1rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.monitoring-header {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* Resource Gauges Styles */
+.resource-gauges {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ margin-bottom: 2rem;
+ padding: 1rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.gauge-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex: 1;
+ max-width: 110px;
+}
+
+.gauge {
+ position: relative;
+ width: 120px;
+ height: 120px;
+ margin-bottom: 0.3rem;
+}
+
+.gauge-circle {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ transition: all 0.3s ease;
+ padding: 6px;
+}
+
+.gauge-circle::before {
+ content: '';
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ width: calc(100% - 12px);
+ height: calc(100% - 12px);
+ border-radius: 50%;
+ background: var(--bg-primary);
+ z-index: 1;
+}
+
+.gauge-circle::after {
+ content: '';
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ width: calc(100% - 12px);
+ height: calc(100% - 12px);
+ border-radius: 50%;
+ background: conic-gradient(
+ from -90deg,
+ transparent 0deg,
+ transparent calc(var(--percentage) * 3.6deg),
+ var(--bg-primary) calc(var(--percentage) * 3.6deg),
+ var(--bg-primary) 360deg
+ );
+ z-index: 2;
+}
+
+.gauge-text {
+ position: relative;
+ z-index: 3;
+ text-align: center;
+ color: var(--text-primary);
+}
+
+.gauge-value {
+ font-size: 1.2rem;
+ font-weight: 600;
+ line-height: 1;
+ margin-bottom: 0.1rem;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+}
+
+.gauge-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ opacity: 0.7;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ margin-bottom: 0.1rem;
+}
+
+.gauge-detail {
+ font-size: 0.65rem;
+ opacity: 0.5;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-weight: 400;
+}
+
+/* Dynamic gauge colors based on percentage */
+.gauge-empty .gauge-circle {
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.gauge-green .gauge-circle {
+ background: conic-gradient(
+ from -90deg,
+ var(--accent-success) 0deg,
+ var(--accent-success) calc(var(--percentage) * 3.6deg),
+ rgba(255, 255, 255, 0.1) calc(var(--percentage) * 3.6deg),
+ rgba(255, 255, 255, 0.1) 360deg
+ );
+}
+
+.gauge-yellow .gauge-circle {
+ background: conic-gradient(
+ from -90deg,
+ var(--accent-warning) 0deg,
+ var(--accent-warning) calc(var(--percentage) * 3.6deg),
+ rgba(255, 255, 255, 0.1) calc(var(--percentage) * 3.6deg),
+ rgba(255, 255, 255, 0.1) 360deg
+ );
+}
+
+.gauge-red .gauge-circle {
+ background: conic-gradient(
+ from -90deg,
+ var(--accent-error) 0deg,
+ var(--accent-error) calc(var(--percentage) * 3.6deg),
+ rgba(255, 255, 255, 0.1) calc(var(--percentage) * 3.6deg),
+ rgba(255, 255, 255, 0.1) 360deg
+ );
+}
+
+/* Gauge value color based on usage level */
+.gauge[data-percentage] .gauge-value {
+ color: var(--text-primary);
+}
+
+/* Hover effects */
+.gauge:hover .gauge-circle {
+ transform: scale(1.05);
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
+}
+
+.gauge:hover .gauge-value {
+ color: var(--accent-primary);
+}
+
.api-endpoints {
margin-top: 1rem;
}
@@ -413,7 +580,6 @@ p {
}
.endpoint-item:hover {
- background: rgba(0, 0, 0, 0.25);
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
@@ -2185,6 +2351,29 @@ p {
border-bottom: none;
}
+/* Refresh button aligned to the right of tabs, blends with tab header */
+.tabs-header .tab-refresh-btn {
+ margin-left: auto;
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--text-secondary);
+ padding: 0.4rem;
+ border-radius: 8px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.tabs-header .tab-refresh-btn:hover {
+ background: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.12);
+ color: var(--text-primary);
+}
+.tabs-header .tab-refresh-btn:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
.tab-button {
background: transparent;
border: 1px solid transparent;
@@ -2822,6 +3011,13 @@ p {
display: none;
}
+/* Hide expand icon on desktop screens */
+@media (min-width: 1025px) {
+ .expand-icon {
+ display: none;
+ }
+}
+
/* Ensure expanded state is visually clear */
.member-overlay-body .member-card.expanded .member-details {
display: block;
@@ -2860,6 +3056,100 @@ p {
display: inline-flex;
}
+/* Desktop slide-in details drawer */
+.details-drawer-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.3);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease;
+ z-index: 999;
+}
+.details-drawer-backdrop.visible { opacity: 1; pointer-events: auto; }
+
+.details-drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ height: 100vh;
+ width: clamp(33.333vw, 650px, 90vw);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ border-left: 1px solid var(--border-primary);
+ box-shadow: var(--shadow-primary);
+ transform: translateX(100%);
+ transition: transform 0.25s ease;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+}
+.details-drawer.open { transform: translateX(0); }
+
+.details-drawer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border-secondary);
+ background: var(--bg-secondary);
+}
+.details-drawer-header .drawer-title {
+ font-weight: 600;
+}
+.drawer-close {
+ background: transparent;
+ border: 1px solid var(--border-secondary);
+ color: var(--text-secondary);
+ border-radius: 8px;
+ padding: 0.35rem;
+ cursor: pointer;
+}
+.drawer-close:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+.drawer-close svg { width: 18px; height: 18px; }
+
+.details-drawer-content {
+ padding: 1rem;
+ overflow: auto;
+}
+
+/* Only enable drawer on wider screens; on small keep inline cards */
+@media (max-width: 1023px) {
+ .details-drawer,
+ .details-drawer-backdrop { display: none; }
+}
+
+/* Topology hover tooltip for labels */
+.topology-tooltip {
+ position: fixed;
+ z-index: 1001;
+ pointer-events: none;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-primary);
+ box-shadow: var(--shadow-secondary);
+ border-radius: 10px;
+ padding: 0.5rem 0.6rem;
+ opacity: 0;
+ transform: translateY(4px);
+ transition: opacity 0.12s ease, transform 0.12s ease;
+}
+.topology-tooltip.visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+.topology-tooltip .member-labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+.topology-tooltip .label-chip {
+ font-size: 0.72rem;
+ padding: 0.2rem 0.5rem;
+}
+
/* Labels section styling in node details */
.detail-section {
margin-top: 20px;
@@ -2917,6 +3207,37 @@ p {
animation: highlight-pulse 2s ease-in-out;
}
+/* Selected member card styling */
+.member-card.selected {
+ border-color: #3b82f6 !important;
+ border-width: 2px !important;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2), 0 8px 25px rgba(0, 0, 0, 0.2) !important;
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, var(--bg-tertiary) 100%) !important;
+ z-index: 10 !important;
+}
+
+.member-card.selected::before {
+ opacity: 0.8 !important;
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%) !important;
+}
+
+.member-card.selected .member-header {
+ background: rgba(59, 130, 246, 0.05) !important;
+}
+
+.member-card.selected .member-status {
+ filter: brightness(1.2) !important;
+}
+
+.member-card.selected .member-hostname {
+ color: #3b82f6 !important;
+ font-weight: 600 !important;
+}
+
+.member-card.selected .member-ip {
+ color: #60a5fa !important;
+}
+
@keyframes highlight-pulse {
0%, 100% {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
diff --git a/public/styles/theme.css b/public/styles/theme.css
index 084e05e..6e7bd76 100644
--- a/public/styles/theme.css
+++ b/public/styles/theme.css
@@ -236,6 +236,33 @@
display: none;
}
+/* Selected member card styling for light theme */
+[data-theme="light"] .member-card.selected {
+ border-color: #3b82f6 !important;
+ border-width: 2px !important;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2), 0 8px 25px rgba(148, 163, 184, 0.15) !important;
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(255, 255, 255, 0.15) 100%) !important;
+}
+
+[data-theme="light"] .member-card.selected::before {
+ display: block !important;
+ opacity: 0.6 !important;
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.03) 100%) !important;
+}
+
+[data-theme="light"] .member-card.selected .member-header {
+ background: rgba(59, 130, 246, 0.03) !important;
+}
+
+[data-theme="light"] .member-card.selected .member-hostname {
+ color: #1d4ed8 !important;
+ font-weight: 600 !important;
+}
+
+[data-theme="light"] .member-card.selected .member-ip {
+ color: #3b82f6 !important;
+}
+
[data-theme="light"] .tabs-container {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
diff --git a/test/demo-discovery.js b/test/demo-discovery.js
deleted file mode 100644
index f96b557..0000000
--- a/test/demo-discovery.js
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Demo script for UDP discovery functionality
- * Monitors the discovery endpoints to show how nodes are discovered
- */
-
-const http = require('http');
-
-const BASE_URL = 'http://localhost:3001';
-
-function makeRequest(path, method = 'GET') {
- return new Promise((resolve, reject) => {
- const options = {
- hostname: 'localhost',
- port: 3001,
- path: path,
- method: method,
- headers: {
- 'Content-Type': 'application/json'
- }
- };
-
- const req = http.request(options, (res) => {
- let data = '';
-
- res.on('data', (chunk) => {
- data += chunk;
- });
-
- res.on('end', () => {
- try {
- const jsonData = JSON.parse(data);
- resolve({ status: res.statusCode, data: jsonData });
- } catch (error) {
- resolve({ status: res.statusCode, data: data });
- }
- });
- });
-
- req.on('error', (error) => {
- reject(error);
- });
-
- req.end();
- });
-}
-
-async function checkHealth() {
- try {
- const response = await makeRequest('/api/health');
- console.log('\n=== Health Check ===');
- console.log(`Status: ${response.data.status}`);
- console.log(`HTTP Service: ${response.data.services.http}`);
- console.log(`UDP Service: ${response.data.services.udp}`);
- console.log(`SPORE Client: ${response.data.services.sporeClient}`);
- console.log(`Total Nodes: ${response.data.discovery.totalNodes}`);
- console.log(`Primary Node: ${response.data.discovery.primaryNode || 'None'}`);
-
- if (response.data.message) {
- console.log(`Message: ${response.data.message}`);
- }
- } catch (error) {
- console.error('Health check failed:', error.message);
- }
-}
-
-async function checkDiscovery() {
- try {
- const response = await makeRequest('/api/discovery/nodes');
- console.log('\n=== Discovery Status ===');
- console.log(`Primary Node: ${response.data.primaryNode || 'None'}`);
- console.log(`Total Nodes: ${response.data.totalNodes}`);
- console.log(`Client Initialized: ${response.data.clientInitialized}`);
-
- if (response.data.clientBaseUrl) {
- console.log(`Client Base URL: ${response.data.clientBaseUrl}`);
- }
-
- if (response.data.nodes.length > 0) {
- console.log('\nDiscovered Nodes:');
- response.data.nodes.forEach((node, index) => {
- console.log(` ${index + 1}. ${node.ip}:${node.port} (${node.isPrimary ? 'PRIMARY' : 'secondary'})`);
- console.log(` Discovered: ${node.discoveredAt}`);
- console.log(` Last Seen: ${node.lastSeen}`);
- });
- } else {
- console.log('No nodes discovered yet.');
- }
- } catch (error) {
- console.error('Discovery check failed:', error.message);
- }
-}
-
-async function runDemo() {
- console.log('๐ SPORE UDP Discovery Demo');
- console.log('============================');
- console.log('This demo monitors the discovery endpoints to show how nodes are discovered.');
- console.log('Start the backend server with: npm start');
- console.log('Send discovery messages with: npm run test-discovery broadcast');
- console.log('');
-
- // Initial check
- await checkHealth();
- await checkDiscovery();
-
- // Set up periodic monitoring
- console.log('\n๐ก Monitoring discovery endpoints every 5 seconds...');
- console.log('Press Ctrl+C to stop\n');
-
- setInterval(async () => {
- await checkHealth();
- await checkDiscovery();
- }, 5000);
-}
-
-// Handle graceful shutdown
-process.on('SIGINT', () => {
- console.log('\n\n๐ Demo stopped. Goodbye!');
- process.exit(0);
-});
-
-// Run the demo
-runDemo().catch(console.error);
\ No newline at end of file
diff --git a/test/demo-frontend.js b/test/demo-frontend.js
deleted file mode 100644
index 2a67ac0..0000000
--- a/test/demo-frontend.js
+++ /dev/null
@@ -1,102 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Demo script for Frontend Discovery Integration
- * Shows how the frontend displays primary node information
- */
-
-const http = require('http');
-
-const BASE_URL = 'http://localhost:3001';
-
-function makeRequest(path, method = 'GET') {
- return new Promise((resolve, reject) => {
- const options = {
- hostname: 'localhost',
- port: 3001,
- path: path,
- method: method,
- headers: {
- 'Content-Type': 'application/json'
- }
- };
-
- const req = http.request(options, (res) => {
- let data = '';
-
- res.on('data', (chunk) => {
- data += chunk;
- });
-
- res.on('end', () => {
- try {
- const jsonData = JSON.parse(data);
- resolve({ status: res.statusCode, data: jsonData });
- } catch (error) {
- resolve({ status: res.statusCode, data: data });
- }
- });
- });
-
- req.on('error', (error) => {
- reject(error);
- });
-
- req.end();
- });
-}
-
-async function showFrontendIntegration() {
- console.log('๐ Frontend Discovery Integration Demo');
- console.log('=====================================');
- console.log('This demo shows how the frontend displays primary node information.');
- console.log('Open http://localhost:3001 in your browser to see the UI.');
- console.log('');
-
- try {
- // Check if backend is running
- const healthResponse = await makeRequest('/api/health');
- console.log('โ
Backend is running');
-
- // Get discovery information
- const discoveryResponse = await makeRequest('/api/discovery/nodes');
- console.log('\n๐ก Discovery Status:');
- console.log(` Primary Node: ${discoveryResponse.data.primaryNode || 'None'}`);
- console.log(` Total Nodes: ${discoveryResponse.data.totalNodes}`);
- console.log(` Client Initialized: ${discoveryResponse.data.clientInitialized}`);
-
- if (discoveryResponse.data.nodes.length > 0) {
- console.log('\n๐ Discovered Nodes:');
- discoveryResponse.data.nodes.forEach((node, index) => {
- console.log(` ${index + 1}. ${node.ip}:${node.port} (${node.isPrimary ? 'PRIMARY' : 'secondary'})`);
- console.log(` Last Seen: ${node.lastSeen}`);
- });
- }
-
- console.log('\n๐ฏ Frontend Display:');
- console.log(' The frontend will show:');
- if (discoveryResponse.data.primaryNode) {
- const status = discoveryResponse.data.clientInitialized ? 'โ
' : 'โ ๏ธ';
- const nodeCount = discoveryResponse.data.totalNodes > 1 ? ` (${discoveryResponse.data.totalNodes} nodes)` : '';
- console.log(` ${status} ${discoveryResponse.data.primaryNode}${nodeCount}`);
- } else if (discoveryResponse.data.totalNodes > 0) {
- const firstNode = discoveryResponse.data.nodes[0];
- console.log(` โ ๏ธ ${firstNode.ip} (No Primary)`);
- } else {
- console.log(' ๐ No Nodes Found');
- }
-
- console.log('\n๐ก To test the frontend:');
- console.log(' 1. Open http://localhost:3001 in your browser');
- console.log(' 2. Look at the cluster header for primary node info');
- console.log(' 3. Send discovery messages: npm run test-discovery broadcast');
- console.log(' 4. Watch the primary node display update in real-time');
-
- } catch (error) {
- console.error('โ Error:', error.message);
- console.log('\n๐ก Make sure the backend is running: npm start');
- }
-}
-
-// Run the demo
-showFrontendIntegration().catch(console.error);
\ No newline at end of file
diff --git a/test/mock-api-client.js b/test/mock-api-client.js
new file mode 100644
index 0000000..8b87edf
--- /dev/null
+++ b/test/mock-api-client.js
@@ -0,0 +1,132 @@
+// Mock API Client for communicating with the mock server
+// This replaces the original API client to use port 3002
+
+class MockApiClient {
+ constructor() {
+ // Use port 3002 for mock server
+ const currentHost = window.location.hostname;
+ this.baseUrl = `http://${currentHost}:3002`;
+
+ console.log('Mock API Client initialized with base URL:', this.baseUrl);
+ }
+
+ async request(path, { method = 'GET', headers = {}, body = undefined, query = undefined, isForm = false } = {}) {
+ const url = new URL(`${this.baseUrl}${path}`);
+ if (query && typeof query === 'object') {
+ Object.entries(query).forEach(([k, v]) => {
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
+ });
+ }
+ const finalHeaders = { 'Accept': 'application/json', ...headers };
+ const options = { method, headers: finalHeaders };
+ if (body !== undefined) {
+ if (isForm) {
+ options.body = body;
+ } else {
+ options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
+ options.body = typeof body === 'string' ? body : JSON.stringify(body);
+ }
+ }
+ const response = await fetch(url.toString(), options);
+ let data;
+ const text = await response.text();
+ try {
+ data = text ? JSON.parse(text) : null;
+ } catch (_) {
+ data = text; // Non-JSON payload
+ }
+ if (!response.ok) {
+ const message = (data && data.message) || `HTTP ${response.status}: ${response.statusText}`;
+ throw new Error(message);
+ }
+ return data;
+ }
+
+ async getClusterMembers() {
+ return this.request('/api/cluster/members', { method: 'GET' });
+ }
+
+ async getClusterMembersFromNode(ip) {
+ return this.request(`/api/cluster/members`, {
+ method: 'GET',
+ query: { ip: ip }
+ });
+ }
+
+ async getDiscoveryInfo() {
+ return this.request('/api/discovery/nodes', { method: 'GET' });
+ }
+
+ async selectRandomPrimaryNode() {
+ return this.request('/api/discovery/random-primary', {
+ method: 'POST',
+ body: { timestamp: new Date().toISOString() }
+ });
+ }
+
+ async getNodeStatus(ip) {
+ return this.request('/api/node/status', {
+ method: 'GET',
+ query: { ip: ip }
+ });
+ }
+
+ async getTasksStatus(ip) {
+ return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
+ }
+
+ async getEndpoints(ip) {
+ return this.request('/api/node/endpoints', { method: 'GET', query: ip ? { ip } : undefined });
+ }
+
+ async callEndpoint({ ip, method, uri, params }) {
+ return this.request('/api/proxy-call', {
+ method: 'POST',
+ body: { ip, method, uri, params }
+ });
+ }
+
+ async uploadFirmware(file, nodeIp) {
+ const formData = new FormData();
+ formData.append('file', file);
+ const data = await this.request(`/api/node/update`, {
+ method: 'POST',
+ query: { ip: nodeIp },
+ body: formData,
+ isForm: true,
+ headers: {},
+ });
+ // Some endpoints may return HTTP 200 with success=false on logical failure
+ if (data && data.success === false) {
+ const message = data.message || 'Firmware upload failed';
+ throw new Error(message);
+ }
+ return data;
+ }
+
+ async getMonitoringResources(ip) {
+ return this.request('/api/proxy-call', {
+ method: 'POST',
+ body: {
+ ip: ip,
+ method: 'GET',
+ uri: '/api/monitoring/resources',
+ params: []
+ }
+ });
+ }
+}
+
+// Override the global API client
+window.apiClient = new MockApiClient();
+
+// Add debugging
+console.log('Mock API Client loaded and initialized');
+console.log('API Client base URL:', window.apiClient.baseUrl);
+
+// Test API call
+window.apiClient.getDiscoveryInfo().then(data => {
+ console.log('Mock API test successful:', data);
+}).catch(error => {
+ console.error('Mock API test failed:', error);
+});
diff --git a/test/mock-cli.js b/test/mock-cli.js
new file mode 100644
index 0000000..16a2ba4
--- /dev/null
+++ b/test/mock-cli.js
@@ -0,0 +1,232 @@
+#!/usr/bin/env node
+
+/**
+ * Mock Server CLI Tool
+ *
+ * Command-line interface for managing the SPORE UI mock server
+ * with different configurations and scenarios
+ */
+
+const { spawn } = require('child_process');
+const path = require('path');
+const { getMockConfig, listMockConfigs, createCustomConfig } = require('./mock-configs');
+
+// Colors for console output
+const colors = {
+ reset: '\x1b[0m',
+ bright: '\x1b[1m',
+ red: '\x1b[31m',
+ green: '\x1b[32m',
+ yellow: '\x1b[33m',
+ blue: '\x1b[34m',
+ magenta: '\x1b[35m',
+ cyan: '\x1b[36m'
+};
+
+function colorize(text, color) {
+ return `${colors[color]}${text}${colors.reset}`;
+}
+
+function printHeader() {
+ console.log(colorize('๐ SPORE UI Mock Server CLI', 'cyan'));
+ console.log(colorize('=============================', 'cyan'));
+ console.log('');
+}
+
+function printHelp() {
+ console.log('Usage: node mock-cli.js
[options]');
+ console.log('');
+ console.log('Commands:');
+ console.log(' start [config] Start mock server with specified config');
+ console.log(' list List available configurations');
+ console.log(' info Show detailed info about a configuration');
+ console.log(' help Show this help message');
+ console.log('');
+ console.log('Available Configurations:');
+ listMockConfigs().forEach(config => {
+ console.log(` ${colorize(config.name, 'green')} - ${config.description} (${config.nodeCount} nodes)`);
+ });
+ console.log('');
+ console.log('Examples:');
+ console.log(' node mock-cli.js start healthy');
+ console.log(' node mock-cli.js start degraded');
+ console.log(' node mock-cli.js list');
+ console.log(' node mock-cli.js info large');
+}
+
+function printConfigInfo(configName) {
+ const config = getMockConfig(configName);
+
+ console.log(colorize(`๐ Configuration: ${config.name}`, 'blue'));
+ console.log(colorize('='.repeat(50), 'blue'));
+ console.log(`Description: ${config.description}`);
+ console.log(`Nodes: ${config.nodes.length}`);
+ console.log('');
+
+ if (config.nodes.length > 0) {
+ console.log(colorize('๐ Mock Nodes:', 'yellow'));
+ config.nodes.forEach((node, index) => {
+ const statusColor = node.status === 'ACTIVE' ? 'green' :
+ node.status === 'INACTIVE' ? 'yellow' : 'red';
+ console.log(` ${index + 1}. ${colorize(node.hostname, 'cyan')} (${node.ip}) - ${colorize(node.status, statusColor)}`);
+ });
+ console.log('');
+ }
+
+ console.log(colorize('โ๏ธ Simulation Settings:', 'yellow'));
+ console.log(` Time Progression: ${config.simulation.enableTimeProgression ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
+ console.log(` Random Failures: ${config.simulation.enableRandomFailures ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
+ if (config.simulation.enableRandomFailures) {
+ console.log(` Failure Rate: ${(config.simulation.failureRate * 100).toFixed(1)}%`);
+ }
+ console.log(` Update Interval: ${config.simulation.updateInterval}ms`);
+ console.log(` Primary Rotation: ${config.simulation.primaryNodeRotation ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
+ if (config.simulation.primaryNodeRotation) {
+ console.log(` Rotation Interval: ${config.simulation.rotationInterval}ms`);
+ }
+ console.log('');
+}
+
+function startMockServer(configName) {
+ const config = getMockConfig(configName);
+
+ console.log(colorize(`๐ Starting mock server with '${config.name}' configuration...`, 'green'));
+ console.log('');
+
+ // Set environment variables for the mock server
+ const env = {
+ ...process.env,
+ MOCK_CONFIG: configName,
+ MOCK_PORT: process.env.MOCK_PORT || '3002'
+ };
+
+ // Start the mock server
+ const mockServerPath = path.join(__dirname, 'mock-server.js');
+ const child = spawn('node', [mockServerPath], {
+ env: env,
+ stdio: 'inherit',
+ cwd: path.join(__dirname, '..')
+ });
+
+ // Handle process termination
+ process.on('SIGINT', () => {
+ console.log(colorize('\n\n๐ Stopping mock server...', 'yellow'));
+ child.kill('SIGINT');
+ process.exit(0);
+ });
+
+ child.on('close', (code) => {
+ if (code !== 0) {
+ console.log(colorize(`\nโ Mock server exited with code ${code}`, 'red'));
+ } else {
+ console.log(colorize('\nโ
Mock server stopped gracefully', 'green'));
+ }
+ });
+
+ child.on('error', (error) => {
+ console.error(colorize(`\nโ Failed to start mock server: ${error.message}`, 'red'));
+ process.exit(1);
+ });
+}
+
+function listConfigurations() {
+ console.log(colorize('๐ Available Mock Configurations', 'blue'));
+ console.log(colorize('================================', 'blue'));
+ console.log('');
+
+ const configs = listMockConfigs();
+ configs.forEach(config => {
+ console.log(colorize(`๐ง ${config.displayName}`, 'green'));
+ console.log(` Key: ${colorize(config.name, 'cyan')}`);
+ console.log(` Description: ${config.description}`);
+ console.log(` Nodes: ${config.nodeCount}`);
+ console.log('');
+ });
+
+ console.log(colorize('๐ก Usage:', 'yellow'));
+ console.log(' node mock-cli.js start ');
+ console.log(' node mock-cli.js info ');
+ console.log('');
+}
+
+// Main CLI logic
+function main() {
+ const args = process.argv.slice(2);
+ const command = args[0];
+ const configName = args[1];
+
+ printHeader();
+
+ switch (command) {
+ case 'start':
+ if (!configName) {
+ console.log(colorize('โ Error: Configuration name required', 'red'));
+ console.log('Usage: node mock-cli.js start ');
+ console.log('Run "node mock-cli.js list" to see available configurations');
+ process.exit(1);
+ }
+
+ const config = getMockConfig(configName);
+ if (!config) {
+ console.log(colorize(`โ Error: Unknown configuration '${configName}'`, 'red'));
+ console.log('Run "node mock-cli.js list" to see available configurations');
+ process.exit(1);
+ }
+
+ printConfigInfo(configName);
+ startMockServer(configName);
+ break;
+
+ case 'list':
+ listConfigurations();
+ break;
+
+ case 'info':
+ if (!configName) {
+ console.log(colorize('โ Error: Configuration name required', 'red'));
+ console.log('Usage: node mock-cli.js info ');
+ console.log('Run "node mock-cli.js list" to see available configurations');
+ process.exit(1);
+ }
+
+ const infoConfig = getMockConfig(configName);
+ if (!infoConfig) {
+ console.log(colorize(`โ Error: Unknown configuration '${configName}'`, 'red'));
+ console.log('Run "node mock-cli.js list" to see available configurations');
+ process.exit(1);
+ }
+
+ printConfigInfo(configName);
+ break;
+
+ case 'help':
+ case '--help':
+ case '-h':
+ printHelp();
+ break;
+
+ default:
+ if (!command) {
+ console.log(colorize('โ Error: Command required', 'red'));
+ console.log('');
+ printHelp();
+ } else {
+ console.log(colorize(`โ Error: Unknown command '${command}'`, 'red'));
+ console.log('');
+ printHelp();
+ }
+ process.exit(1);
+ }
+}
+
+// Run the CLI
+if (require.main === module) {
+ main();
+}
+
+module.exports = {
+ getMockConfig,
+ listMockConfigs,
+ printConfigInfo,
+ startMockServer
+};
diff --git a/test/mock-configs.js b/test/mock-configs.js
new file mode 100644
index 0000000..c17f979
--- /dev/null
+++ b/test/mock-configs.js
@@ -0,0 +1,291 @@
+/**
+ * Mock Configuration Presets
+ *
+ * Different scenarios for testing the SPORE UI with various conditions
+ */
+
+const mockConfigs = {
+ // Default healthy cluster
+ healthy: {
+ name: "Healthy Cluster",
+ description: "All nodes active and functioning normally",
+ nodes: [
+ {
+ ip: '192.168.1.100',
+ hostname: 'spore-node-1',
+ chipId: 12345678,
+ status: 'ACTIVE',
+ latency: 5
+ },
+ {
+ ip: '192.168.1.101',
+ hostname: 'spore-node-2',
+ chipId: 87654321,
+ status: 'ACTIVE',
+ latency: 8
+ },
+ {
+ ip: '192.168.1.102',
+ hostname: 'spore-node-3',
+ chipId: 11223344,
+ status: 'ACTIVE',
+ latency: 12
+ }
+ ],
+ simulation: {
+ enableTimeProgression: true,
+ enableRandomFailures: false,
+ failureRate: 0.0,
+ updateInterval: 5000,
+ primaryNodeRotation: false,
+ rotationInterval: 30000
+ }
+ },
+
+ // Single node scenario
+ single: {
+ name: "Single Node",
+ description: "Only one node in the cluster",
+ nodes: [
+ {
+ ip: '192.168.1.100',
+ hostname: 'spore-node-1',
+ chipId: 12345678,
+ status: 'ACTIVE',
+ latency: 5
+ }
+ ],
+ simulation: {
+ enableTimeProgression: true,
+ enableRandomFailures: false,
+ failureRate: 0.0,
+ updateInterval: 5000,
+ primaryNodeRotation: false,
+ rotationInterval: 30000
+ }
+ },
+
+ // Large cluster
+ large: {
+ name: "Large Cluster",
+ description: "Many nodes in the cluster",
+ nodes: [
+ { ip: '192.168.1.100', hostname: 'spore-node-1', chipId: 12345678, status: 'ACTIVE', latency: 5 },
+ { ip: '192.168.1.101', hostname: 'spore-node-2', chipId: 87654321, status: 'ACTIVE', latency: 8 },
+ { ip: '192.168.1.102', hostname: 'spore-node-3', chipId: 11223344, status: 'ACTIVE', latency: 12 },
+ { ip: '192.168.1.103', hostname: 'spore-node-4', chipId: 44332211, status: 'ACTIVE', latency: 15 },
+ { ip: '192.168.1.104', hostname: 'spore-node-5', chipId: 55667788, status: 'ACTIVE', latency: 7 },
+ { ip: '192.168.1.105', hostname: 'spore-node-6', chipId: 99887766, status: 'ACTIVE', latency: 20 },
+ { ip: '192.168.1.106', hostname: 'spore-node-7', chipId: 11223355, status: 'ACTIVE', latency: 9 },
+ { ip: '192.168.1.107', hostname: 'spore-node-8', chipId: 66778899, status: 'ACTIVE', latency: 11 }
+ ],
+ simulation: {
+ enableTimeProgression: true,
+ enableRandomFailures: false,
+ failureRate: 0.0,
+ updateInterval: 5000,
+ primaryNodeRotation: true,
+ rotationInterval: 30000
+ }
+ },
+
+ // Degraded cluster with some failures
+ degraded: {
+ name: "Degraded Cluster",
+ description: "Some nodes are inactive or dead",
+ nodes: [
+ {
+ ip: '192.168.1.100',
+ hostname: 'spore-node-1',
+ chipId: 12345678,
+ status: 'ACTIVE',
+ latency: 5
+ },
+ {
+ ip: '192.168.1.101',
+ hostname: 'spore-node-2',
+ chipId: 87654321,
+ status: 'INACTIVE',
+ latency: 8
+ },
+ {
+ ip: '192.168.1.102',
+ hostname: 'spore-node-3',
+ chipId: 11223344,
+ status: 'DEAD',
+ latency: 12
+ },
+ {
+ ip: '192.168.1.103',
+ hostname: 'spore-node-4',
+ chipId: 44332211,
+ status: 'ACTIVE',
+ latency: 15
+ }
+ ],
+ simulation: {
+ enableTimeProgression: true,
+ enableRandomFailures: true,
+ failureRate: 0.1,
+ updateInterval: 5000,
+ primaryNodeRotation: false,
+ rotationInterval: 30000
+ }
+ },
+
+ // High failure rate scenario
+ unstable: {
+ name: "Unstable Cluster",
+ description: "High failure rate with frequent node changes",
+ nodes: [
+ {
+ ip: '192.168.1.100',
+ hostname: 'spore-node-1',
+ chipId: 12345678,
+ status: 'ACTIVE',
+ latency: 5
+ },
+ {
+ ip: '192.168.1.101',
+ hostname: 'spore-node-2',
+ chipId: 87654321,
+ status: 'ACTIVE',
+ latency: 8
+ },
+ {
+ ip: '192.168.1.102',
+ hostname: 'spore-node-3',
+ chipId: 11223344,
+ status: 'ACTIVE',
+ latency: 12
+ }
+ ],
+ simulation: {
+ enableTimeProgression: true,
+ enableRandomFailures: true,
+ failureRate: 0.3, // 30% chance of failures
+ updateInterval: 2000, // Update every 2 seconds
+ primaryNodeRotation: true,
+ rotationInterval: 15000 // Rotate every 15 seconds
+ }
+ },
+
+ // No nodes scenario
+ empty: {
+ name: "Empty Cluster",
+ description: "No nodes discovered",
+ nodes: [],
+ simulation: {
+ enableTimeProgression: false,
+ enableRandomFailures: false,
+ failureRate: 0.0,
+ updateInterval: 5000,
+ primaryNodeRotation: false,
+ rotationInterval: 30000
+ }
+ },
+
+ // Development scenario with custom settings
+ development: {
+ name: "Development Mode",
+ description: "Custom settings for development and testing",
+ nodes: [
+ {
+ ip: '192.168.1.100',
+ hostname: 'dev-node-1',
+ chipId: 12345678,
+ status: 'ACTIVE',
+ latency: 5
+ },
+ {
+ ip: '192.168.1.101',
+ hostname: 'dev-node-2',
+ chipId: 87654321,
+ status: 'ACTIVE',
+ latency: 8
+ }
+ ],
+ simulation: {
+ enableTimeProgression: true,
+ enableRandomFailures: true,
+ failureRate: 0.05, // 5% failure rate
+ updateInterval: 3000, // Update every 3 seconds
+ primaryNodeRotation: true,
+ rotationInterval: 20000 // Rotate every 20 seconds
+ }
+ }
+};
+
+/**
+ * Get a mock configuration by name
+ * @param {string} configName - Name of the configuration preset
+ * @returns {Object} Mock configuration object
+ */
+function getMockConfig(configName = 'healthy') {
+ const config = mockConfigs[configName];
+ if (!config) {
+ console.warn(`Unknown mock config: ${configName}. Using 'healthy' instead.`);
+ return mockConfigs.healthy;
+ }
+ return config;
+}
+
+/**
+ * List all available mock configurations
+ * @returns {Array} Array of configuration names and descriptions
+ */
+function listMockConfigs() {
+ return Object.keys(mockConfigs).map(key => ({
+ name: key,
+ displayName: mockConfigs[key].name,
+ description: mockConfigs[key].description,
+ nodeCount: mockConfigs[key].nodes.length
+ }));
+}
+
+/**
+ * Create a custom mock configuration
+ * @param {Object} options - Configuration options
+ * @returns {Object} Custom mock configuration
+ */
+function createCustomConfig(options = {}) {
+ const defaultConfig = {
+ name: "Custom Configuration",
+ description: "User-defined mock configuration",
+ nodes: [
+ {
+ ip: '192.168.1.100',
+ hostname: 'custom-node-1',
+ chipId: 12345678,
+ status: 'ACTIVE',
+ latency: 5
+ }
+ ],
+ simulation: {
+ enableTimeProgression: true,
+ enableRandomFailures: false,
+ failureRate: 0.0,
+ updateInterval: 5000,
+ primaryNodeRotation: false,
+ rotationInterval: 30000
+ }
+ };
+
+ // Merge with provided options
+ return {
+ ...defaultConfig,
+ ...options,
+ nodes: options.nodes || defaultConfig.nodes,
+ simulation: {
+ ...defaultConfig.simulation,
+ ...options.simulation
+ }
+ };
+}
+
+module.exports = {
+ mockConfigs,
+ getMockConfig,
+ listMockConfigs,
+ createCustomConfig
+};
diff --git a/test/mock-server.js b/test/mock-server.js
new file mode 100644
index 0000000..1eec2dd
--- /dev/null
+++ b/test/mock-server.js
@@ -0,0 +1,791 @@
+#!/usr/bin/env node
+
+/**
+ * Complete Mock Server for SPORE UI
+ *
+ * This mock server provides a complete simulation of the SPORE embedded system
+ * without requiring actual hardware or UDP port conflicts. It simulates:
+ * - Multiple SPORE nodes with different IPs
+ * - All API endpoints from the OpenAPI specification
+ * - Discovery system without UDP conflicts
+ * - Realistic data that changes over time
+ * - Different scenarios (healthy, degraded, error states)
+ */
+
+const express = require('express');
+const cors = require('cors');
+const path = require('path');
+const { getMockConfig } = require('./mock-configs');
+
+// Load mock configuration
+const configName = process.env.MOCK_CONFIG || 'healthy';
+const baseConfig = getMockConfig(configName);
+
+// Mock server configuration
+const MOCK_CONFIG = {
+ // Server settings
+ port: process.env.MOCK_PORT || 3002,
+ baseUrl: process.env.MOCK_BASE_URL || 'http://localhost:3002',
+
+ // Load configuration from preset
+ ...baseConfig
+};
+
+// Initialize Express app
+const app = express();
+app.use(cors({
+ origin: true,
+ credentials: true,
+ allowedHeaders: ['Content-Type', 'Authorization']
+}));
+
+// Middleware
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+
+// Mock data generators
+class MockDataGenerator {
+ constructor() {
+ this.startTime = Date.now();
+ this.nodeStates = new Map();
+ this.primaryNodeIndex = 0;
+ this.initializeNodeStates();
+ }
+
+ initializeNodeStates() {
+ MOCK_CONFIG.nodes.forEach((node, index) => {
+ this.nodeStates.set(node.ip, {
+ ...node,
+ freeHeap: this.generateFreeHeap(),
+ uptime: 0,
+ lastSeen: Date.now(),
+ tasks: this.generateTasks(),
+ systemInfo: this.generateSystemInfo(node),
+ apiEndpoints: this.generateApiEndpoints()
+ });
+ });
+ }
+
+ generateFreeHeap() {
+ // Simulate realistic ESP8266 memory usage
+ const base = 30000;
+ const variation = 20000;
+ return Math.floor(base + Math.random() * variation);
+ }
+
+ generateSystemInfo(node) {
+ return {
+ freeHeap: this.generateFreeHeap(),
+ chipId: node.chipId,
+ sdkVersion: "3.1.2",
+ cpuFreqMHz: 80,
+ flashChipSize: 1048576
+ };
+ }
+
+ generateTasks() {
+ return [
+ {
+ name: "discovery_send",
+ interval: 1000,
+ enabled: true,
+ running: true,
+ autoStart: true
+ },
+ {
+ name: "heartbeat",
+ interval: 2000,
+ enabled: true,
+ running: true,
+ autoStart: true
+ },
+ {
+ name: "status_update",
+ interval: 1000,
+ enabled: true,
+ running: true,
+ autoStart: true
+ },
+ {
+ name: "wifi_monitor",
+ interval: 5000,
+ enabled: true,
+ running: Math.random() > 0.1, // 90% chance of running
+ autoStart: true
+ },
+ {
+ name: "ota_check",
+ interval: 30000,
+ enabled: true,
+ running: Math.random() > 0.2, // 80% chance of running
+ autoStart: true
+ },
+ {
+ name: "cluster_sync",
+ interval: 10000,
+ enabled: true,
+ running: Math.random() > 0.05, // 95% chance of running
+ autoStart: true
+ }
+ ];
+ }
+
+ generateApiEndpoints() {
+ return [
+ { uri: "/api/node/status", method: "GET" },
+ { uri: "/api/tasks/status", method: "GET" },
+ { uri: "/api/tasks/control", method: "POST" },
+ { uri: "/api/cluster/members", method: "GET" },
+ { uri: "/api/node/update", method: "POST" },
+ { uri: "/api/node/restart", method: "POST" }
+ ];
+ }
+
+ updateNodeStates() {
+ if (!MOCK_CONFIG.simulation.enableTimeProgression) return;
+
+ this.nodeStates.forEach((nodeState, ip) => {
+ // Update uptime
+ nodeState.uptime = Date.now() - this.startTime;
+
+ // Update free heap (simulate memory usage changes)
+ const currentHeap = nodeState.freeHeap;
+ const change = Math.floor((Math.random() - 0.5) * 1000);
+ nodeState.freeHeap = Math.max(10000, currentHeap + change);
+
+ // Update last seen
+ nodeState.lastSeen = Date.now();
+
+ // Simulate random failures
+ if (MOCK_CONFIG.simulation.enableRandomFailures && Math.random() < MOCK_CONFIG.simulation.failureRate) {
+ nodeState.status = Math.random() > 0.5 ? 'INACTIVE' : 'DEAD';
+ } else {
+ nodeState.status = 'ACTIVE';
+ }
+
+ // Update task states
+ nodeState.tasks.forEach(task => {
+ if (task.enabled && Math.random() > 0.05) { // 95% chance of running when enabled
+ task.running = true;
+ } else {
+ task.running = false;
+ }
+ });
+ });
+
+ // Rotate primary node if enabled
+ if (MOCK_CONFIG.simulation.primaryNodeRotation) {
+ this.primaryNodeIndex = (this.primaryNodeIndex + 1) % MOCK_CONFIG.nodes.length;
+ }
+ }
+
+ getPrimaryNode() {
+ return MOCK_CONFIG.nodes[this.primaryNodeIndex];
+ }
+
+ getAllNodes() {
+ return Array.from(this.nodeStates.values());
+ }
+
+ getNodeByIp(ip) {
+ return this.nodeStates.get(ip);
+ }
+}
+
+// Initialize mock data generator
+const mockData = new MockDataGenerator();
+
+// Update data periodically
+setInterval(() => {
+ mockData.updateNodeStates();
+}, MOCK_CONFIG.simulation.updateInterval);
+
+// API Routes
+
+// Health check endpoint
+app.get('/api/health', (req, res) => {
+ const primaryNode = mockData.getPrimaryNode();
+ const allNodes = mockData.getAllNodes();
+ const activeNodes = allNodes.filter(node => node.status === 'ACTIVE');
+
+ const health = {
+ status: activeNodes.length > 0 ? 'healthy' : 'degraded',
+ timestamp: new Date().toISOString(),
+ services: {
+ http: true,
+ udp: false, // Mock server doesn't use UDP
+ sporeClient: true
+ },
+ discovery: {
+ totalNodes: allNodes.length,
+ primaryNode: primaryNode.ip,
+ udpPort: 4210,
+ serverRunning: false // Mock server doesn't use UDP
+ },
+ mock: {
+ enabled: true,
+ nodes: allNodes.length,
+ activeNodes: activeNodes.length,
+ simulationMode: MOCK_CONFIG.simulation.enableTimeProgression
+ }
+ };
+
+ if (activeNodes.length === 0) {
+ health.status = 'degraded';
+ health.message = 'No active nodes in mock simulation';
+ }
+
+ res.json(health);
+});
+
+// Discovery endpoints (simulated)
+app.get('/api/discovery/nodes', (req, res) => {
+ const primaryNode = mockData.getPrimaryNode();
+ const allNodes = mockData.getAllNodes();
+
+ const response = {
+ primaryNode: primaryNode.ip,
+ totalNodes: allNodes.length,
+ clientInitialized: true,
+ clientBaseUrl: `http://${primaryNode.ip}`,
+ nodes: allNodes.map(node => ({
+ ip: node.ip,
+ port: 80,
+ discoveredAt: new Date(node.lastSeen - 60000).toISOString(), // 1 minute ago
+ lastSeen: new Date(node.lastSeen).toISOString(),
+ isPrimary: node.ip === primaryNode.ip,
+ hostname: node.hostname,
+ status: node.status
+ }))
+ };
+
+ res.json(response);
+});
+
+app.post('/api/discovery/refresh', (req, res) => {
+ // Simulate discovery refresh
+ mockData.updateNodeStates();
+ res.json({
+ success: true,
+ message: 'Discovery refresh completed',
+ timestamp: new Date().toISOString()
+ });
+});
+
+app.post('/api/discovery/primary/:ip', (req, res) => {
+ const { ip } = req.params;
+ const node = mockData.getNodeByIp(ip);
+
+ if (!node) {
+ return res.status(404).json({
+ success: false,
+ message: `Node ${ip} not found`
+ });
+ }
+
+ // Find and set as primary
+ const nodeIndex = MOCK_CONFIG.nodes.findIndex(n => n.ip === ip);
+ if (nodeIndex !== -1) {
+ mockData.primaryNodeIndex = nodeIndex;
+ }
+
+ res.json({
+ success: true,
+ message: `Primary node set to ${ip}`,
+ primaryNode: ip
+ });
+});
+
+app.post('/api/discovery/random-primary', (req, res) => {
+ const allNodes = mockData.getAllNodes();
+ const activeNodes = allNodes.filter(node => node.status === 'ACTIVE');
+
+ if (activeNodes.length === 0) {
+ return res.status(503).json({
+ success: false,
+ message: 'No active nodes available for selection'
+ });
+ }
+
+ // Randomly select a new primary
+ const randomIndex = Math.floor(Math.random() * activeNodes.length);
+ const newPrimary = activeNodes[randomIndex];
+ const nodeIndex = MOCK_CONFIG.nodes.findIndex(n => n.ip === newPrimary.ip);
+
+ if (nodeIndex !== -1) {
+ mockData.primaryNodeIndex = nodeIndex;
+ }
+
+ res.json({
+ success: true,
+ message: `Primary node randomly selected: ${newPrimary.ip}`,
+ primaryNode: newPrimary.ip,
+ totalNodes: allNodes.length,
+ clientInitialized: true
+ });
+});
+
+// Task management endpoints
+app.get('/api/tasks/status', (req, res) => {
+ const { ip } = req.query;
+ let nodeData;
+
+ if (ip) {
+ nodeData = mockData.getNodeByIp(ip);
+ if (!nodeData) {
+ return res.status(404).json({
+ error: 'Node not found',
+ message: `Node ${ip} not found in mock simulation`
+ });
+ }
+ } else {
+ // Use primary node
+ const primaryNode = mockData.getPrimaryNode();
+ nodeData = mockData.getNodeByIp(primaryNode.ip);
+ }
+
+ const tasks = nodeData.tasks;
+ const activeTasks = tasks.filter(task => task.enabled && task.running).length;
+
+ const response = {
+ summary: {
+ totalTasks: tasks.length,
+ activeTasks: activeTasks
+ },
+ tasks: tasks,
+ system: {
+ freeHeap: nodeData.freeHeap,
+ uptime: nodeData.uptime
+ }
+ };
+
+ res.json(response);
+});
+
+app.post('/api/tasks/control', (req, res) => {
+ const { task, action } = req.body;
+
+ if (!task || !action) {
+ return res.status(400).json({
+ success: false,
+ message: 'Missing parameters. Required: task, action',
+ example: '{"task": "discovery_send", "action": "status"}'
+ });
+ }
+
+ const validActions = ['enable', 'disable', 'start', 'stop', 'status'];
+ if (!validActions.includes(action)) {
+ return res.status(400).json({
+ success: false,
+ message: 'Invalid action. Use: enable, disable, start, stop, or status',
+ task: task,
+ action: action
+ });
+ }
+
+ // Simulate task control
+ const primaryNode = mockData.getPrimaryNode();
+ const nodeData = mockData.getNodeByIp(primaryNode.ip);
+ const taskData = nodeData.tasks.find(t => t.name === task);
+
+ if (!taskData) {
+ return res.status(404).json({
+ success: false,
+ message: `Task ${task} not found`
+ });
+ }
+
+ // Apply action
+ switch (action) {
+ case 'enable':
+ taskData.enabled = true;
+ break;
+ case 'disable':
+ taskData.enabled = false;
+ taskData.running = false;
+ break;
+ case 'start':
+ if (taskData.enabled) {
+ taskData.running = true;
+ }
+ break;
+ case 'stop':
+ taskData.running = false;
+ break;
+ case 'status':
+ // Return detailed status
+ return res.json({
+ success: true,
+ message: 'Task status retrieved',
+ task: task,
+ action: action,
+ taskDetails: {
+ name: taskData.name,
+ enabled: taskData.enabled,
+ running: taskData.running,
+ interval: taskData.interval,
+ system: {
+ freeHeap: nodeData.freeHeap,
+ uptime: nodeData.uptime
+ }
+ }
+ });
+ }
+
+ res.json({
+ success: true,
+ message: `Task ${action}d`,
+ task: task,
+ action: action
+ });
+});
+
+// System status endpoint
+app.get('/api/node/status', (req, res) => {
+ const { ip } = req.query;
+ let nodeData;
+
+ if (ip) {
+ nodeData = mockData.getNodeByIp(ip);
+ if (!nodeData) {
+ return res.status(404).json({
+ error: 'Node not found',
+ message: `Node ${ip} not found in mock simulation`
+ });
+ }
+ } else {
+ // Use primary node
+ const primaryNode = mockData.getPrimaryNode();
+ nodeData = mockData.getNodeByIp(primaryNode.ip);
+ }
+
+ const response = {
+ freeHeap: nodeData.freeHeap,
+ chipId: nodeData.chipId,
+ sdkVersion: nodeData.systemInfo.sdkVersion,
+ cpuFreqMHz: nodeData.systemInfo.cpuFreqMHz,
+ flashChipSize: nodeData.systemInfo.flashChipSize,
+ api: nodeData.apiEndpoints
+ };
+
+ res.json(response);
+});
+
+// Cluster members endpoint
+app.get('/api/cluster/members', (req, res) => {
+ const allNodes = mockData.getAllNodes();
+
+ const members = allNodes.map(node => ({
+ hostname: node.hostname,
+ ip: node.ip,
+ lastSeen: Math.floor(node.lastSeen / 1000), // Convert to seconds
+ latency: node.latency,
+ status: node.status,
+ resources: {
+ freeHeap: node.freeHeap,
+ chipId: node.chipId,
+ sdkVersion: node.systemInfo.sdkVersion,
+ cpuFreqMHz: node.systemInfo.cpuFreqMHz,
+ flashChipSize: node.systemInfo.flashChipSize
+ },
+ api: node.apiEndpoints
+ }));
+
+ res.json({ members });
+});
+
+// Node endpoints endpoint
+app.get('/api/node/endpoints', (req, res) => {
+ const { ip } = req.query;
+ let nodeData;
+
+ if (ip) {
+ nodeData = mockData.getNodeByIp(ip);
+ if (!nodeData) {
+ return res.status(404).json({
+ error: 'Node not found',
+ message: `Node ${ip} not found in mock simulation`
+ });
+ }
+ } else {
+ // Use primary node
+ const primaryNode = mockData.getPrimaryNode();
+ nodeData = mockData.getNodeByIp(primaryNode.ip);
+ }
+
+ res.json(nodeData.apiEndpoints);
+});
+
+// Generic proxy endpoint
+app.post('/api/proxy-call', (req, res) => {
+ const { ip, method, uri, params } = req.body || {};
+
+ if (!ip || !method || !uri) {
+ return res.status(400).json({
+ error: 'Missing required fields',
+ message: 'Required: ip, method, uri'
+ });
+ }
+
+ // Simulate proxy call by routing to appropriate mock endpoint
+ const nodeData = mockData.getNodeByIp(ip);
+ if (!nodeData) {
+ return res.status(404).json({
+ error: 'Node not found',
+ message: `Node ${ip} not found in mock simulation`
+ });
+ }
+
+ // Simulate different responses based on URI
+ if (uri === '/api/node/status') {
+ return res.json({
+ freeHeap: nodeData.freeHeap,
+ chipId: nodeData.chipId,
+ sdkVersion: nodeData.systemInfo.sdkVersion,
+ cpuFreqMHz: nodeData.systemInfo.cpuFreqMHz,
+ flashChipSize: nodeData.systemInfo.flashChipSize,
+ api: nodeData.apiEndpoints
+ });
+ } else if (uri === '/api/tasks/status') {
+ const tasks = nodeData.tasks;
+ const activeTasks = tasks.filter(task => task.enabled && task.running).length;
+
+ return res.json({
+ summary: {
+ totalTasks: tasks.length,
+ activeTasks: activeTasks
+ },
+ tasks: tasks,
+ system: {
+ freeHeap: nodeData.freeHeap,
+ uptime: nodeData.uptime
+ }
+ });
+ } else if (uri === '/api/monitoring/resources') {
+ // Return realistic monitoring resources data
+ const totalHeap = nodeData.systemInfo.flashChipSize || 1048576; // 1MB default
+ const freeHeap = nodeData.freeHeap;
+ const usedHeap = totalHeap - freeHeap;
+ const heapUsagePercent = (usedHeap / totalHeap) * 100;
+
+ return res.json({
+ cpu: {
+ average_usage: Math.random() * 30 + 10, // 10-40% CPU usage
+ current_usage: Math.random() * 50 + 5, // 5-55% current usage
+ frequency_mhz: nodeData.systemInfo.cpuFreqMHz || 80
+ },
+ memory: {
+ total_heap: totalHeap,
+ free_heap: freeHeap,
+ used_heap: usedHeap,
+ heap_usage_percent: heapUsagePercent,
+ min_free_heap: Math.floor(freeHeap * 0.8), // 80% of current free heap
+ max_alloc_heap: Math.floor(totalHeap * 0.9) // 90% of total heap
+ },
+ filesystem: {
+ total_bytes: 3145728, // 3MB SPIFFS
+ used_bytes: Math.floor(3145728 * (0.3 + Math.random() * 0.4)), // 30-70% used
+ free_bytes: 0 // Will be calculated
+ },
+ network: {
+ wifi_rssi: -30 - Math.floor(Math.random() * 40), // -30 to -70 dBm
+ wifi_connected: true,
+ uptime_seconds: nodeData.uptime
+ },
+ timestamp: new Date().toISOString()
+ });
+ } else {
+ return res.json({
+ success: true,
+ message: `Mock response for ${method} ${uri}`,
+ node: ip,
+ timestamp: new Date().toISOString()
+ });
+ }
+});
+
+// Firmware update endpoint
+app.post('/api/node/update', (req, res) => {
+ // Simulate firmware update
+ res.json({
+ status: 'updating',
+ message: 'Firmware update in progress (mock simulation)'
+ });
+});
+
+// System restart endpoint
+app.post('/api/node/restart', (req, res) => {
+ // Simulate system restart
+ res.json({
+ status: 'restarting'
+ });
+});
+
+// Test route
+app.get('/test', (req, res) => {
+ res.send('Mock server is working!');
+});
+
+// Serve the mock UI (main UI with modified API client)
+app.get('/', (req, res) => {
+ const filePath = path.join(__dirname, 'mock-ui.html');
+ console.log('Serving mock UI from:', filePath);
+ res.sendFile(filePath);
+});
+
+// Serve the original mock frontend
+app.get('/frontend', (req, res) => {
+ res.sendFile(path.join(__dirname, 'mock-frontend.html'));
+});
+
+// Serve the main UI with modified API client
+app.get('/ui', (req, res) => {
+ res.sendFile(path.join(__dirname, '../public/index.html'));
+});
+
+// Serve static files from public directory (after custom routes)
+// Only serve static files for specific paths, not the root
+app.use('/static', express.static(path.join(__dirname, '../public')));
+app.use('/styles', express.static(path.join(__dirname, '../public/styles')));
+app.use('/scripts', express.static(path.join(__dirname, '../public/scripts')));
+app.use('/vendor', express.static(path.join(__dirname, '../public/vendor')));
+
+// Serve mock API client
+app.get('/test/mock-api-client.js', (req, res) => {
+ res.sendFile(path.join(__dirname, 'mock-api-client.js'));
+});
+
+// Serve test page
+app.get('/test-page', (req, res) => {
+ res.sendFile(path.join(__dirname, 'test-page.html'));
+});
+
+// Serve favicon to prevent 404 errors
+app.get('/favicon.ico', (req, res) => {
+ res.status(204).end(); // No content
+});
+
+// Serve mock server info page
+app.get('/info', (req, res) => {
+ res.send(`
+
+
+
+
+
+ SPORE UI - Mock Server Info
+
+
+
+
+
๐ SPORE UI Mock Server
+
+
+ Mock Mode Active: This is a complete simulation of the SPORE embedded system.
+ No real hardware or UDP ports are required.
+
+
+
+
๐ Server Status
+
Status: Running
+
Port: ${MOCK_CONFIG.port}
+
Configuration: ${MOCK_CONFIG.name}
+
Mock Nodes: ${MOCK_CONFIG.nodes.length}
+
Primary Node: ${mockData.getPrimaryNode().ip}
+
+
+
+
+
+
๐ Available Endpoints
+
GET /api/health - Health check
+
GET /api/discovery/nodes - Discovery status
+
GET /api/tasks/status - Task status
+
POST /api/tasks/control - Control tasks
+
GET /api/node/status - System status
+
GET /api/cluster/members - Cluster members
+
POST /api/proxy-call - Generic proxy
+
+
+
+
๐ฎ Mock Features
+
+ - โ
Multiple simulated SPORE nodes
+ - โ
Realistic data that changes over time
+ - โ
No UDP port conflicts
+ - โ
All API endpoints implemented
+ - โ
Random failures simulation
+ - โ
Primary node rotation
+
+
+
+
+
๐ง Configuration
+
Use npm scripts to change configuration:
+
+ npm run mock:healthy - Healthy cluster (3 nodes)
+ npm run mock:degraded - Degraded cluster (some inactive)
+ npm run mock:large - Large cluster (8 nodes)
+ npm run mock:unstable - Unstable cluster (high failure rate)
+ npm run mock:single - Single node
+ npm run mock:empty - Empty cluster
+
+
+
+
+
+ `);
+});
+
+// Start the mock server
+const server = app.listen(MOCK_CONFIG.port, () => {
+ console.log('๐ SPORE UI Mock Server Started');
+ console.log('================================');
+ console.log(`Configuration: ${MOCK_CONFIG.name}`);
+ console.log(`Description: ${MOCK_CONFIG.description}`);
+ console.log(`Port: ${MOCK_CONFIG.port}`);
+ console.log(`URL: http://localhost:${MOCK_CONFIG.port}`);
+ console.log(`Mock Nodes: ${MOCK_CONFIG.nodes.length}`);
+ console.log(`Primary Node: ${mockData.getPrimaryNode().ip}`);
+ console.log('');
+ console.log('๐ก Available Mock Nodes:');
+ MOCK_CONFIG.nodes.forEach((node, index) => {
+ console.log(` ${index + 1}. ${node.hostname} (${node.ip}) - ${node.status}`);
+ });
+ console.log('');
+ console.log('๐ฎ Mock Features:');
+ console.log(' โ
No UDP port conflicts');
+ console.log(' โ
Realistic data simulation');
+ console.log(' โ
All API endpoints');
+ console.log(` โ
Time-based data updates (${MOCK_CONFIG.simulation.updateInterval}ms)`);
+ console.log(` โ
Random failure simulation (${MOCK_CONFIG.simulation.enableRandomFailures ? 'Enabled' : 'Disabled'})`);
+ console.log(` โ
Primary node rotation (${MOCK_CONFIG.simulation.primaryNodeRotation ? 'Enabled' : 'Disabled'})`);
+ console.log('');
+ console.log('Press Ctrl+C to stop');
+});
+
+// Graceful shutdown
+process.on('SIGINT', () => {
+ console.log('\n\n๐ Mock server stopped. Goodbye!');
+ server.close(() => {
+ process.exit(0);
+ });
+});
+
+module.exports = { app, mockData, MOCK_CONFIG };
diff --git a/test/mock-test.js b/test/mock-test.js
new file mode 100644
index 0000000..6888237
--- /dev/null
+++ b/test/mock-test.js
@@ -0,0 +1,285 @@
+#!/usr/bin/env node
+
+/**
+ * Mock Server Integration Test
+ *
+ * Tests the mock server functionality to ensure all endpoints work correctly
+ */
+
+const http = require('http');
+
+const MOCK_SERVER_URL = 'http://localhost:3002';
+const TIMEOUT = 5000; // 5 seconds
+
+function makeRequest(path, method = 'GET', body = null) {
+ return new Promise((resolve, reject) => {
+ const options = {
+ hostname: 'localhost',
+ port: 3002,
+ path: path,
+ method: method,
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ };
+
+ const req = http.request(options, (res) => {
+ let data = '';
+
+ res.on('data', (chunk) => {
+ data += chunk;
+ });
+
+ res.on('end', () => {
+ try {
+ const jsonData = JSON.parse(data);
+ resolve({ status: res.statusCode, data: jsonData });
+ } catch (error) {
+ resolve({ status: res.statusCode, data: data });
+ }
+ });
+ });
+
+ req.on('error', (error) => {
+ reject(error);
+ });
+
+ req.setTimeout(TIMEOUT, () => {
+ req.destroy();
+ reject(new Error('Request timeout'));
+ });
+
+ if (body) {
+ req.write(JSON.stringify(body));
+ }
+
+ req.end();
+ });
+}
+
+async function testEndpoint(name, testFn) {
+ try {
+ console.log(`๐งช Testing ${name}...`);
+ const result = await testFn();
+ console.log(`โ
${name}: PASS`);
+ return { name, status: 'PASS', result };
+ } catch (error) {
+ console.log(`โ ${name}: FAIL - ${error.message}`);
+ return { name, status: 'FAIL', error: error.message };
+ }
+}
+
+async function runTests() {
+ console.log('๐ SPORE UI Mock Server Integration Tests');
+ console.log('==========================================');
+ console.log('');
+
+ const results = [];
+
+ // Test 1: Health Check
+ results.push(await testEndpoint('Health Check', async () => {
+ const response = await makeRequest('/api/health');
+ if (response.status !== 200) {
+ throw new Error(`Expected status 200, got ${response.status}`);
+ }
+ if (!response.data.status) {
+ throw new Error('Missing status field');
+ }
+ if (!response.data.mock) {
+ throw new Error('Missing mock field');
+ }
+ return response.data;
+ }));
+
+ // Test 2: Discovery Nodes
+ results.push(await testEndpoint('Discovery Nodes', async () => {
+ const response = await makeRequest('/api/discovery/nodes');
+ if (response.status !== 200) {
+ throw new Error(`Expected status 200, got ${response.status}`);
+ }
+ if (!response.data.primaryNode) {
+ throw new Error('Missing primaryNode field');
+ }
+ if (!Array.isArray(response.data.nodes)) {
+ throw new Error('Nodes should be an array');
+ }
+ return response.data;
+ }));
+
+ // Test 3: Task Status
+ results.push(await testEndpoint('Task Status', async () => {
+ const response = await makeRequest('/api/tasks/status');
+ if (response.status !== 200) {
+ throw new Error(`Expected status 200, got ${response.status}`);
+ }
+ if (!response.data.summary) {
+ throw new Error('Missing summary field');
+ }
+ if (!Array.isArray(response.data.tasks)) {
+ throw new Error('Tasks should be an array');
+ }
+ return response.data;
+ }));
+
+ // Test 4: Task Control
+ results.push(await testEndpoint('Task Control', async () => {
+ const response = await makeRequest('/api/tasks/control', 'POST', {
+ task: 'heartbeat',
+ action: 'status'
+ });
+ if (response.status !== 200) {
+ throw new Error(`Expected status 200, got ${response.status}`);
+ }
+ if (!response.data.success) {
+ throw new Error('Task control should succeed');
+ }
+ return response.data;
+ }));
+
+ // Test 5: System Status
+ results.push(await testEndpoint('System Status', async () => {
+ const response = await makeRequest('/api/node/status');
+ if (response.status !== 200) {
+ throw new Error(`Expected status 200, got ${response.status}`);
+ }
+ if (typeof response.data.freeHeap !== 'number') {
+ throw new Error('freeHeap should be a number');
+ }
+ if (!response.data.chipId) {
+ throw new Error('Missing chipId field');
+ }
+ return response.data;
+ }));
+
+ // Test 6: Cluster Members
+ results.push(await testEndpoint('Cluster Members', async () => {
+ const response = await makeRequest('/api/cluster/members');
+ if (response.status !== 200) {
+ throw new Error(`Expected status 200, got ${response.status}`);
+ }
+ if (!Array.isArray(response.data.members)) {
+ throw new Error('Members should be an array');
+ }
+ return response.data;
+ }));
+
+ // Test 7: Random Primary Selection
+ results.push(await testEndpoint('Random Primary Selection', async () => {
+ const response = await makeRequest('/api/discovery/random-primary', 'POST', {
+ timestamp: new Date().toISOString()
+ });
+ if (response.status !== 200) {
+ throw new Error(`Expected status 200, got ${response.status}`);
+ }
+ if (!response.data.success) {
+ throw new Error('Random selection should succeed');
+ }
+ return response.data;
+ }));
+
+ // Test 8: Proxy Call
+ results.push(await testEndpoint('Proxy Call', async () => {
+ const response = await makeRequest('/api/proxy-call', 'POST', {
+ ip: '192.168.1.100',
+ method: 'GET',
+ uri: '/api/node/status'
+ });
+ if (response.status !== 200) {
+ throw new Error(`Expected status 200, got ${response.status}`);
+ }
+ return response.data;
+ }));
+
+ // Test 9: Error Handling
+ results.push(await testEndpoint('Error Handling', async () => {
+ const response = await makeRequest('/api/tasks/control', 'POST', {
+ task: 'nonexistent',
+ action: 'status'
+ });
+ if (response.status !== 404) {
+ throw new Error(`Expected status 404, got ${response.status}`);
+ }
+ return response.data;
+ }));
+
+ // Test 10: Invalid Parameters
+ results.push(await testEndpoint('Invalid Parameters', async () => {
+ const response = await makeRequest('/api/tasks/control', 'POST', {
+ // Missing required fields
+ });
+ if (response.status !== 400) {
+ throw new Error(`Expected status 400, got ${response.status}`);
+ }
+ return response.data;
+ }));
+
+ // Print Results
+ console.log('');
+ console.log('๐ Test Results');
+ console.log('===============');
+
+ const passed = results.filter(r => r.status === 'PASS').length;
+ const failed = results.filter(r => r.status === 'FAIL').length;
+ const total = results.length;
+
+ results.forEach(result => {
+ const status = result.status === 'PASS' ? 'โ
' : 'โ';
+ console.log(`${status} ${result.name}`);
+ if (result.status === 'FAIL') {
+ console.log(` Error: ${result.error}`);
+ }
+ });
+
+ console.log('');
+ console.log(`Total: ${total} | Passed: ${passed} | Failed: ${failed}`);
+
+ if (failed === 0) {
+ console.log('');
+ console.log('๐ All tests passed! Mock server is working correctly.');
+ } else {
+ console.log('');
+ console.log('โ ๏ธ Some tests failed. Check the mock server configuration.');
+ }
+
+ return failed === 0;
+}
+
+// Check if mock server is running
+async function checkMockServer() {
+ try {
+ const response = await makeRequest('/api/health');
+ return response.status === 200;
+ } catch (error) {
+ return false;
+ }
+}
+
+async function main() {
+ console.log('๐ Checking if mock server is running...');
+
+ const isRunning = await checkMockServer();
+ if (!isRunning) {
+ console.log('โ Mock server is not running!');
+ console.log('');
+ console.log('Please start the mock server first:');
+ console.log(' npm run mock:healthy');
+ console.log('');
+ process.exit(1);
+ }
+
+ console.log('โ
Mock server is running');
+ console.log('');
+
+ const success = await runTests();
+ process.exit(success ? 0 : 1);
+}
+
+// Run tests
+if (require.main === module) {
+ main().catch(error => {
+ console.error('โ Test runner failed:', error.message);
+ process.exit(1);
+ });
+}
+
+module.exports = { runTests, checkMockServer };
diff --git a/test/mock-ui.html b/test/mock-ui.html
new file mode 100644
index 0000000..ab96904
--- /dev/null
+++ b/test/mock-ui.html
@@ -0,0 +1,181 @@
+
+
+
+
+
+ SPORE UI - Mock Mode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
๐ Cluster Online
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/test-discovery.js b/test/test-discovery.js
deleted file mode 100644
index 0ffe263..0000000
--- a/test/test-discovery.js
+++ /dev/null
@@ -1,77 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Test script for UDP discovery
- * Sends CLUSTER_DISCOVERY messages to test the backend discovery functionality
- */
-
-const dgram = require('dgram');
-const client = dgram.createSocket('udp4');
-
-const DISCOVERY_MESSAGE = 'CLUSTER_DISCOVERY';
-const TARGET_PORT = 4210;
-const BROADCAST_ADDRESS = '255.255.255.255';
-
-// Enable broadcast
-client.setBroadcast(true);
-
-function sendDiscoveryMessage() {
- const message = Buffer.from(DISCOVERY_MESSAGE);
-
- client.send(message, 0, message.length, TARGET_PORT, BROADCAST_ADDRESS, (err) => {
- if (err) {
- console.error('Error sending discovery message:', err);
- } else {
- console.log(`Sent CLUSTER_DISCOVERY message to ${BROADCAST_ADDRESS}:${TARGET_PORT}`);
- }
- });
-}
-
-function sendDiscoveryToSpecificIP(ip) {
- const message = Buffer.from(DISCOVERY_MESSAGE);
-
- client.send(message, 0, message.length, TARGET_PORT, ip, (err) => {
- if (err) {
- console.error(`Error sending discovery message to ${ip}:`, err);
- } else {
- console.log(`Sent CLUSTER_DISCOVERY message to ${ip}:${TARGET_PORT}`);
- }
- });
-}
-
-// Main execution
-const args = process.argv.slice(2);
-
-if (args.length === 0) {
- console.log('Usage: node test-discovery.js [broadcast|ip] [count]');
- console.log(' broadcast: Send to broadcast address (default)');
- console.log(' ip: Send to specific IP address');
- console.log(' count: Number of messages to send (default: 1)');
- process.exit(1);
-}
-
-const target = args[0];
-const count = parseInt(args[1]) || 1;
-
-console.log(`Sending ${count} discovery message(s) to ${target === 'broadcast' ? 'broadcast' : target}`);
-
-if (target === 'broadcast') {
- for (let i = 0; i < count; i++) {
- setTimeout(() => {
- sendDiscoveryMessage();
- }, i * 1000); // Send one message per second
- }
-} else {
- // Assume it's an IP address
- for (let i = 0; i < count; i++) {
- setTimeout(() => {
- sendDiscoveryToSpecificIP(target);
- }, i * 1000); // Send one message per second
- }
-}
-
-// Close the client after sending all messages
-setTimeout(() => {
- client.close();
- console.log('Test completed');
-}, (count + 1) * 1000);
\ No newline at end of file
diff --git a/test/test-random-selection.js b/test/test-random-selection.js
deleted file mode 100644
index c0f2b6c..0000000
--- a/test/test-random-selection.js
+++ /dev/null
@@ -1,137 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Test script for Random Primary Node Selection
- * Demonstrates how the random selection works
- */
-
-const http = require('http');
-
-const BASE_URL = 'http://localhost:3001';
-
-function makeRequest(path, method = 'POST', body = null) {
- return new Promise((resolve, reject) => {
- const options = {
- hostname: 'localhost',
- port: 3001,
- path: path,
- method: method,
- headers: {
- 'Content-Type': 'application/json'
- }
- };
-
- const req = http.request(options, (res) => {
- let data = '';
-
- res.on('data', (chunk) => {
- data += chunk;
- });
-
- res.on('end', () => {
- try {
- const jsonData = JSON.parse(data);
- resolve({ status: res.statusCode, data: jsonData });
- } catch (error) {
- resolve({ status: res.statusCode, data: data });
- }
- });
- });
-
- req.on('error', (error) => {
- reject(error);
- });
-
- if (body) {
- req.write(JSON.stringify(body));
- }
-
- req.end();
- });
-}
-
-async function testRandomSelection() {
- console.log('๐ฒ Testing Random Primary Node Selection');
- console.log('======================================');
- console.log('');
-
- try {
- // First, check current discovery status
- console.log('1. Checking current discovery status...');
- const discoveryResponse = await makeRequest('/api/discovery/nodes', 'GET');
-
- if (discoveryResponse.status !== 200) {
- console.log('โ Failed to get discovery status');
- return;
- }
-
- const discovery = discoveryResponse.data;
- console.log(` Current Primary: ${discovery.primaryNode || 'None'}`);
- console.log(` Total Nodes: ${discovery.totalNodes}`);
- console.log(` Client Initialized: ${discovery.clientInitialized}`);
-
- if (discovery.nodes.length === 0) {
- console.log('\n๐ก No nodes discovered yet. Send some discovery messages first:');
- console.log(' npm run test-discovery broadcast');
- return;
- }
-
- console.log('\n2. Testing random primary node selection...');
-
- // Store current primary for comparison
- const currentPrimary = discovery.primaryNode;
- const availableNodes = discovery.nodes.map(n => n.ip);
-
- console.log(` Available nodes: ${availableNodes.join(', ')}`);
- console.log(` Current primary: ${currentPrimary}`);
-
- // Perform random selection
- const randomResponse = await makeRequest('/api/discovery/random-primary', 'POST', {
- timestamp: new Date().toISOString()
- });
-
- if (randomResponse.status === 200) {
- const result = randomResponse.data;
- console.log('\nโ
Random selection successful!');
- console.log(` New Primary: ${result.primaryNode}`);
- console.log(` Previous Primary: ${currentPrimary}`);
- console.log(` Message: ${result.message}`);
- console.log(` Total Nodes: ${result.totalNodes}`);
- console.log(` Client Initialized: ${result.clientInitialized}`);
-
- // Verify the change
- if (result.primaryNode !== currentPrimary) {
- console.log('\n๐ฏ Primary node successfully changed!');
- } else {
- console.log('\nโ ๏ธ Primary node remained the same (only one node available)');
- }
-
- } else {
- console.log('\nโ Random selection failed:');
- console.log(` Status: ${randomResponse.status}`);
- console.log(` Error: ${randomResponse.data.error || 'Unknown error'}`);
- }
-
- // Show updated status
- console.log('\n3. Checking updated discovery status...');
- const updatedResponse = await makeRequest('/api/discovery/nodes', 'GET');
- if (updatedResponse.status === 200) {
- const updated = updatedResponse.data;
- console.log(` Current Primary: ${updated.primaryNode}`);
- console.log(` Client Base URL: ${updated.clientBaseUrl}`);
- }
-
- console.log('\n๐ก To test in the frontend:');
- console.log(' 1. Open http://localhost:3001 in your browser');
- console.log(' 2. Look at the cluster header for primary node info');
- console.log(' 3. Click the ๐ฒ button to randomly select a new primary node');
- console.log(' 4. Watch the display change in real-time');
-
- } catch (error) {
- console.error('\nโ Test failed:', error.message);
- console.log('\n๐ก Make sure the backend is running: npm start');
- }
-}
-
-// Run the test
-testRandomSelection().catch(console.error);
\ No newline at end of file