Compare commits
1 Commits
eb50048016
...
0d7c3a087e
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d7c3a087e |
10
README.md
10
README.md
@@ -33,13 +33,13 @@ spore-ui/
|
|||||||
├── public/ # Frontend files
|
├── public/ # Frontend files
|
||||||
│ ├── index.html # Main HTML page
|
│ ├── index.html # Main HTML page
|
||||||
│ ├── styles.css # All CSS styles
|
│ ├── styles.css # All CSS styles
|
||||||
│ ├── framework.js # Enhanced component framework
|
│ ├── framework.js # Enhanced component framework with state preservation
|
||||||
│ ├── components.js # UI components with partial update support
|
│ ├── components.js # UI components with partial update support
|
||||||
│ ├── view-models.js # Data models with UI state management
|
│ ├── view-models.js # Data models with UI state management
|
||||||
│ ├── app.js # Main application logic
|
│ ├── app.js # Main application logic
|
||||||
│ └── test-interface.html # Test interface
|
│ └── test-state-preservation.html # Test interface for state preservation
|
||||||
├── docs/
|
├── docs/
|
||||||
│ └── FRAMEWORK_README.md # Framework documentation
|
│ └── STATE_PRESERVATION.md # Detailed documentation of state preservation system
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ spore-ui/
|
|||||||
1. **Install dependencies**: `npm install`
|
1. **Install dependencies**: `npm install`
|
||||||
2. **Start the server**: `npm start`
|
2. **Start the server**: `npm start`
|
||||||
3. **Open in browser**: `http://localhost:3001`
|
3. **Open in browser**: `http://localhost:3001`
|
||||||
4. **Test interface**: `http://localhost:3001/test-interface.html`
|
4. **Test state preservation**: `http://localhost:3001/test-state-preservation.html`
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ spore-ui/
|
|||||||
|
|
||||||
- **Backend**: Express.js, Node.js
|
- **Backend**: Express.js, Node.js
|
||||||
- **Frontend**: Vanilla JavaScript, CSS3, HTML5
|
- **Frontend**: Vanilla JavaScript, CSS3, HTML5
|
||||||
- **Framework**: Custom component-based architecture
|
- **Framework**: Custom component-based architecture with state preservation
|
||||||
- **API**: SPORE Embedded System API
|
- **API**: SPORE Embedded System API
|
||||||
- **Design**: Glassmorphism, CSS Grid, Flexbox
|
- **Design**: Glassmorphism, CSS Grid, Flexbox
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 224 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 394 KiB After Width: | Height: | Size: 257 KiB |
22
index.js
22
index.js
@@ -466,7 +466,7 @@ app.get('/api/node/status', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Proxy endpoint to get node capabilities (optionally for a specific node via ?ip=)
|
// Proxy endpoint to get node capabilities (optionally for a specific node via ?ip=)
|
||||||
app.get('/api/node/endpoints', async (req, res) => {
|
app.get('/api/capabilities', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { ip } = req.query;
|
const { ip } = req.query;
|
||||||
|
|
||||||
@@ -476,9 +476,9 @@ app.get('/api/node/endpoints', async (req, res) => {
|
|||||||
const caps = await nodeClient.getCapabilities();
|
const caps = await nodeClient.getCapabilities();
|
||||||
return res.json(caps);
|
return res.json(caps);
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
console.error('Error fetching endpoints from specific node:', innerError);
|
console.error('Error fetching capabilities from specific node:', innerError);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: 'Failed to fetch endpoints from node',
|
error: 'Failed to fetch capabilities from node',
|
||||||
message: innerError.message
|
message: innerError.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -645,21 +645,7 @@ app.post('/api/node/update', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const updateResult = await nodeClient.updateFirmware(uploadedFile.data, uploadedFile.name);
|
const updateResult = await nodeClient.updateFirmware(uploadedFile.data, uploadedFile.name);
|
||||||
console.log(`Firmware upload to SPORE device ${nodeIp} completed:`, updateResult);
|
console.log(`Firmware upload to SPORE device ${nodeIp} completed successfully:`, updateResult);
|
||||||
|
|
||||||
// Check if the SPORE device reported a failure
|
|
||||||
if (updateResult && updateResult.status === 'FAIL') {
|
|
||||||
console.error(`SPORE device ${nodeIp} reported firmware update failure:`, updateResult.message);
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Firmware update failed',
|
|
||||||
message: updateResult.message || 'Firmware update failed on device',
|
|
||||||
nodeIp: nodeIp,
|
|
||||||
fileSize: uploadedFile.data.length,
|
|
||||||
filename: uploadedFile.name,
|
|
||||||
result: updateResult
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -11,18 +11,6 @@
|
|||||||
"demo-discovery": "node test/demo-discovery.js",
|
"demo-discovery": "node test/demo-discovery.js",
|
||||||
"demo-frontend": "node test/demo-frontend.js",
|
"demo-frontend": "node test/demo-frontend.js",
|
||||||
"test-random-selection": "node test/test-random-selection.js",
|
"test-random-selection": "node test/test-random-selection.js",
|
||||||
"mock": "node test/mock-cli.js",
|
|
||||||
"mock:start": "node test/mock-cli.js start",
|
|
||||||
"mock:list": "node test/mock-cli.js list",
|
|
||||||
"mock:info": "node test/mock-cli.js info",
|
|
||||||
"mock:healthy": "node test/mock-cli.js start healthy",
|
|
||||||
"mock:degraded": "node test/mock-cli.js start degraded",
|
|
||||||
"mock:large": "node test/mock-cli.js start large",
|
|
||||||
"mock:unstable": "node test/mock-cli.js start unstable",
|
|
||||||
"mock:single": "node test/mock-cli.js start single",
|
|
||||||
"mock:empty": "node test/mock-cli.js start empty",
|
|
||||||
"mock:test": "node test/mock-test.js",
|
|
||||||
"mock:integration": "node test/test-mock-integration.js",
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SPORE UI</title>
|
<title>SPORE UI</title>
|
||||||
<link rel="stylesheet" href="styles/main.css">
|
<link rel="stylesheet" href="styles/main.css">
|
||||||
<link rel="stylesheet" href="styles/theme.css?v=1757159926">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -23,14 +22,6 @@
|
|||||||
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<div class="theme-switcher">
|
|
||||||
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="5"/>
|
|
||||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="cluster-status">🚀 Cluster Online</div>
|
<div class="cluster-status">🚀 Cluster Online</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +137,6 @@
|
|||||||
<script src="./scripts/api-client.js"></script>
|
<script src="./scripts/api-client.js"></script>
|
||||||
<script src="./scripts/view-models.js"></script>
|
<script src="./scripts/view-models.js"></script>
|
||||||
<!-- Base/leaf components first -->
|
<!-- Base/leaf components first -->
|
||||||
<script src="./scripts/components/DrawerComponent.js"></script>
|
|
||||||
<script src="./scripts/components/PrimaryNodeComponent.js"></script>
|
<script src="./scripts/components/PrimaryNodeComponent.js"></script>
|
||||||
<script src="./scripts/components/NodeDetailsComponent.js"></script>
|
<script src="./scripts/components/NodeDetailsComponent.js"></script>
|
||||||
<script src="./scripts/components/ClusterMembersComponent.js"></script>
|
<script src="./scripts/components/ClusterMembersComponent.js"></script>
|
||||||
@@ -157,7 +147,6 @@
|
|||||||
<script src="./scripts/components/ClusterStatusComponent.js"></script>
|
<script src="./scripts/components/ClusterStatusComponent.js"></script>
|
||||||
<script src="./scripts/components/TopologyGraphComponent.js"></script>
|
<script src="./scripts/components/TopologyGraphComponent.js"></script>
|
||||||
<script src="./scripts/components/ComponentsLoader.js"></script>
|
<script src="./scripts/components/ComponentsLoader.js"></script>
|
||||||
<script src="./scripts/theme-manager.js"></script>
|
|
||||||
<script src="./scripts/app.js"></script>
|
<script src="./scripts/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -80,11 +80,11 @@ class ApiClient {
|
|||||||
return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
|
return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEndpoints(ip) {
|
async getCapabilities(ip) {
|
||||||
return this.request('/api/node/endpoints', { method: 'GET', query: ip ? { ip } : undefined });
|
return this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
async callEndpoint({ ip, method, uri, params }) {
|
async callCapability({ ip, method, uri, params }) {
|
||||||
return this.request('/api/proxy-call', {
|
return this.request('/api/proxy-call', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { ip, method, uri, params }
|
body: { ip, method, uri, params }
|
||||||
@@ -94,31 +94,13 @@ class ApiClient {
|
|||||||
async uploadFirmware(file, nodeIp) {
|
async uploadFirmware(file, nodeIp) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
const data = await this.request(`/api/node/update`, {
|
return this.request(`/api/node/update`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
query: { ip: nodeIp },
|
query: { ip: nodeIp },
|
||||||
body: formData,
|
body: formData,
|
||||||
isForm: true,
|
isForm: true,
|
||||||
headers: {},
|
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: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Set up periodic updates
|
// Set up periodic updates with state preservation
|
||||||
function setupPeriodicUpdates() {
|
function setupPeriodicUpdates() {
|
||||||
// Auto-refresh cluster members every 30 seconds using smart update
|
// Auto-refresh cluster members every 30 seconds using smart update
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -99,7 +99,7 @@ function setupPeriodicUpdates() {
|
|||||||
|
|
||||||
// Use smart update if available, otherwise fall back to regular update
|
// Use smart update if available, otherwise fall back to regular update
|
||||||
if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') {
|
if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') {
|
||||||
logger.debug('App: Performing smart update...');
|
logger.debug('App: Performing smart update to preserve UI state...');
|
||||||
viewModel.smartUpdate();
|
viewModel.smartUpdate();
|
||||||
} else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
|
} else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
|
||||||
logger.debug('App: Performing regular update...');
|
logger.debug('App: Performing regular update...');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Cluster Members Component
|
// Cluster Members Component with enhanced state preservation
|
||||||
class ClusterMembersComponent extends Component {
|
class ClusterMembersComponent extends Component {
|
||||||
constructor(container, viewModel, eventBus) {
|
constructor(container, viewModel, eventBus) {
|
||||||
super(container, viewModel, eventBus);
|
super(container, viewModel, eventBus);
|
||||||
@@ -19,69 +19,6 @@ class ClusterMembersComponent extends Component {
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
// Drawer state for desktop
|
|
||||||
this.drawer = new DrawerComponent();
|
|
||||||
|
|
||||||
// Selection state for highlighting
|
|
||||||
this.selectedMemberIp = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if we should use desktop drawer behavior
|
|
||||||
isDesktop() {
|
|
||||||
return this.drawer.isDesktop();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
openDrawerForMember(memberIp) {
|
|
||||||
// Set selected member and update highlighting
|
|
||||||
this.setSelectedMember(memberIp);
|
|
||||||
|
|
||||||
// Get display name for drawer title
|
|
||||||
let displayName = memberIp;
|
|
||||||
try {
|
|
||||||
const members = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('members') : [];
|
|
||||||
const member = Array.isArray(members) ? members.find(m => m && m.ip === memberIp) : null;
|
|
||||||
const hostname = (member && member.hostname) ? member.hostname : '';
|
|
||||||
const ip = (member && member.ip) ? member.ip : memberIp;
|
|
||||||
|
|
||||||
if (hostname && ip) {
|
|
||||||
displayName = `${hostname} - ${ip}`;
|
|
||||||
} else if (hostname) {
|
|
||||||
displayName = hostname;
|
|
||||||
} else if (ip) {
|
|
||||||
displayName = ip;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// no-op if anything goes wrong, default title remains
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open drawer with content callback
|
|
||||||
this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
|
|
||||||
// Load and mount NodeDetails into drawer
|
|
||||||
const nodeDetailsVM = new NodeDetailsViewModel();
|
|
||||||
const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
|
|
||||||
setActiveComponent(nodeDetailsComponent);
|
|
||||||
|
|
||||||
nodeDetailsVM.loadNodeDetails(memberIp).then(() => {
|
|
||||||
nodeDetailsComponent.mount();
|
|
||||||
}).catch((error) => {
|
|
||||||
logger.error('Failed to load node details for drawer:', error);
|
|
||||||
contentContainer.innerHTML = `
|
|
||||||
<div class="error">
|
|
||||||
<strong>Error loading node details:</strong><br>
|
|
||||||
${this.escapeHtml(error.message)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
}, null, () => {
|
|
||||||
// Close callback - clear selection when drawer is closed
|
|
||||||
this.clearSelectedMember();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
closeDrawer() {
|
|
||||||
this.drawer.closeDrawer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mount() {
|
mount() {
|
||||||
@@ -142,7 +79,7 @@ class ClusterMembersComponent extends Component {
|
|||||||
logger.debug('ClusterMembersComponent: View model listeners set up');
|
logger.debug('ClusterMembersComponent: View model listeners set up');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle members update
|
// Handle members update with state preservation
|
||||||
handleMembersUpdate(newMembers, previousMembers) {
|
handleMembersUpdate(newMembers, previousMembers) {
|
||||||
logger.debug('ClusterMembersComponent: Members updated:', { newMembers, previousMembers });
|
logger.debug('ClusterMembersComponent: Members updated:', { newMembers, previousMembers });
|
||||||
|
|
||||||
@@ -166,9 +103,9 @@ class ClusterMembersComponent extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.shouldSkipFullRender(newMembers, previousMembers)) {
|
if (this.shouldPreserveState(newMembers, previousMembers)) {
|
||||||
// Perform partial update
|
// Perform partial update to preserve UI state
|
||||||
logger.debug('ClusterMembersComponent: Skipping full render, performing partial update');
|
logger.debug('ClusterMembersComponent: Preserving state, performing partial update');
|
||||||
this.updateMembersPartially(newMembers, previousMembers);
|
this.updateMembersPartially(newMembers, previousMembers);
|
||||||
} else {
|
} else {
|
||||||
// Full re-render if structure changed significantly
|
// Full re-render if structure changed significantly
|
||||||
@@ -242,8 +179,8 @@ class ClusterMembersComponent extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should skip full re-render during update
|
// Check if we should preserve UI state during update
|
||||||
shouldSkipFullRender(newMembers, previousMembers) {
|
shouldPreserveState(newMembers, previousMembers) {
|
||||||
if (!previousMembers || !Array.isArray(previousMembers)) return false;
|
if (!previousMembers || !Array.isArray(previousMembers)) return false;
|
||||||
if (!Array.isArray(newMembers)) return false;
|
if (!Array.isArray(newMembers)) return false;
|
||||||
|
|
||||||
@@ -254,7 +191,7 @@ class ClusterMembersComponent extends Component {
|
|||||||
const newIps = new Set(newMembers.map(m => m.ip));
|
const newIps = new Set(newMembers.map(m => m.ip));
|
||||||
const prevIps = new Set(previousMembers.map(m => m.ip));
|
const prevIps = new Set(previousMembers.map(m => m.ip));
|
||||||
|
|
||||||
// If IPs are the same, we can skip full re-render
|
// If IPs are the same, we can preserve state
|
||||||
return newIps.size === prevIps.size &&
|
return newIps.size === prevIps.size &&
|
||||||
[...newIps].every(ip => prevIps.has(ip));
|
[...newIps].every(ip => prevIps.has(ip));
|
||||||
}
|
}
|
||||||
@@ -269,9 +206,9 @@ class ClusterMembersComponent extends Component {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update members partially
|
// Update members partially to preserve UI state
|
||||||
updateMembersPartially(newMembers, previousMembers) {
|
updateMembersPartially(newMembers, previousMembers) {
|
||||||
logger.debug('ClusterMembersComponent: Performing partial update');
|
logger.debug('ClusterMembersComponent: Performing partial update to preserve UI state');
|
||||||
|
|
||||||
// Build previous map by IP for stable diffs
|
// Build previous map by IP for stable diffs
|
||||||
const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m]));
|
const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m]));
|
||||||
@@ -298,8 +235,8 @@ class ClusterMembersComponent extends Component {
|
|||||||
// Update status
|
// Update status
|
||||||
const statusElement = card.querySelector('.member-status');
|
const statusElement = card.querySelector('.member-status');
|
||||||
if (statusElement) {
|
if (statusElement) {
|
||||||
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline';
|
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
|
||||||
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? '🟢' : '🔴';
|
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
|
||||||
|
|
||||||
statusElement.className = `member-status ${statusClass}`;
|
statusElement.className = `member-status ${statusClass}`;
|
||||||
statusElement.innerHTML = `${statusIcon}`;
|
statusElement.innerHTML = `${statusIcon}`;
|
||||||
@@ -402,9 +339,9 @@ class ClusterMembersComponent extends Component {
|
|||||||
logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
|
logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
|
||||||
|
|
||||||
const membersHTML = members.map(member => {
|
const membersHTML = members.map(member => {
|
||||||
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline';
|
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
|
||||||
const statusText = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'Online' : 'Offline';
|
const statusText = member.status === 'active' ? 'Online' : 'Offline';
|
||||||
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? '🟢' : '🔴';
|
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
|
||||||
|
|
||||||
logger.debug('ClusterMembersComponent: Rendering member:', member);
|
logger.debug('ClusterMembersComponent: Rendering member:', member);
|
||||||
|
|
||||||
@@ -472,16 +409,8 @@ class ClusterMembersComponent extends Component {
|
|||||||
this.addEventListener(card, 'click', async (e) => {
|
this.addEventListener(card, 'click', async (e) => {
|
||||||
if (e.target === expandIcon) return;
|
if (e.target === expandIcon) return;
|
||||||
|
|
||||||
// On desktop, open slide-in drawer instead of inline expand
|
|
||||||
if (this.isDesktop()) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.openDrawerForMember(memberIp);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mobile/low-res: keep inline expand/collapse
|
|
||||||
const isExpanding = !card.classList.contains('expanded');
|
const isExpanding = !card.classList.contains('expanded');
|
||||||
|
|
||||||
if (isExpanding) {
|
if (isExpanding) {
|
||||||
await this.expandCard(card, memberIp, memberDetails);
|
await this.expandCard(card, memberIp, memberDetails);
|
||||||
} else {
|
} else {
|
||||||
@@ -493,11 +422,9 @@ class ClusterMembersComponent extends Component {
|
|||||||
if (expandIcon) {
|
if (expandIcon) {
|
||||||
this.addEventListener(expandIcon, 'click', async (e) => {
|
this.addEventListener(expandIcon, 'click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (this.isDesktop()) {
|
|
||||||
this.openDrawerForMember(memberIp);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const isExpanding = !card.classList.contains('expanded');
|
const isExpanding = !card.classList.contains('expanded');
|
||||||
|
|
||||||
if (isExpanding) {
|
if (isExpanding) {
|
||||||
await this.expandCard(card, memberIp, memberDetails);
|
await this.expandCard(card, memberIp, memberDetails);
|
||||||
} else {
|
} else {
|
||||||
@@ -696,32 +623,6 @@ class ClusterMembersComponent extends Component {
|
|||||||
// Don't re-render on resume - maintain current state
|
// Don't re-render on resume - maintain current state
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set selected member and update highlighting
|
|
||||||
setSelectedMember(memberIp) {
|
|
||||||
// Clear previous selection
|
|
||||||
this.clearSelectedMember();
|
|
||||||
|
|
||||||
// Set new selection
|
|
||||||
this.selectedMemberIp = memberIp;
|
|
||||||
|
|
||||||
// Add selected class to the member card
|
|
||||||
const card = this.findElement(`[data-member-ip="${memberIp}"]`);
|
|
||||||
if (card) {
|
|
||||||
card.classList.add('selected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear selected member highlighting
|
|
||||||
clearSelectedMember() {
|
|
||||||
if (this.selectedMemberIp) {
|
|
||||||
const card = this.findElement(`[data-member-ip="${this.selectedMemberIp}"]`);
|
|
||||||
if (card) {
|
|
||||||
card.classList.remove('selected');
|
|
||||||
}
|
|
||||||
this.selectedMemberIp = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.ClusterMembersComponent = ClusterMembersComponent;
|
window.ClusterMembersComponent = ClusterMembersComponent;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
(function(){
|
(function(){
|
||||||
// Simple readiness flag once all component constructors are present
|
// Simple readiness flag once all component constructors are present
|
||||||
function allReady(){
|
function allReady(){
|
||||||
return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent && window.DrawerComponent);
|
return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent);
|
||||||
}
|
}
|
||||||
window.waitForComponentsReady = function(timeoutMs = 5000){
|
window.waitForComponentsReady = function(timeoutMs = 5000){
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
// Reusable Drawer Component for desktop slide-in panels
|
|
||||||
class DrawerComponent {
|
|
||||||
constructor() {
|
|
||||||
this.detailsDrawer = null;
|
|
||||||
this.detailsDrawerContent = null;
|
|
||||||
this.detailsDrawerBackdrop = null;
|
|
||||||
this.activeDrawerComponent = null;
|
|
||||||
this.onCloseCallback = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if we should use desktop drawer behavior
|
|
||||||
isDesktop() {
|
|
||||||
try {
|
|
||||||
return window && window.innerWidth >= 1024; // desktop threshold
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureDrawer() {
|
|
||||||
if (this.detailsDrawer) return;
|
|
||||||
|
|
||||||
// Create backdrop
|
|
||||||
this.detailsDrawerBackdrop = document.createElement('div');
|
|
||||||
this.detailsDrawerBackdrop.className = 'details-drawer-backdrop';
|
|
||||||
document.body.appendChild(this.detailsDrawerBackdrop);
|
|
||||||
|
|
||||||
// Create drawer
|
|
||||||
this.detailsDrawer = document.createElement('div');
|
|
||||||
this.detailsDrawer.className = 'details-drawer';
|
|
||||||
|
|
||||||
// Header with close button
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.className = 'details-drawer-header';
|
|
||||||
header.innerHTML = `
|
|
||||||
<div class="drawer-title">Node Details</div>
|
|
||||||
<button class="drawer-close" aria-label="Close">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
this.detailsDrawer.appendChild(header);
|
|
||||||
|
|
||||||
// Content container
|
|
||||||
this.detailsDrawerContent = document.createElement('div');
|
|
||||||
this.detailsDrawerContent.className = 'details-drawer-content';
|
|
||||||
this.detailsDrawer.appendChild(this.detailsDrawerContent);
|
|
||||||
|
|
||||||
document.body.appendChild(this.detailsDrawer);
|
|
||||||
|
|
||||||
// Close handlers
|
|
||||||
const close = () => this.closeDrawer();
|
|
||||||
header.querySelector('.drawer-close').addEventListener('click', close);
|
|
||||||
this.detailsDrawerBackdrop.addEventListener('click', close);
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
openDrawer(title, contentCallback, errorCallback, onCloseCallback) {
|
|
||||||
this.ensureDrawer();
|
|
||||||
this.onCloseCallback = onCloseCallback;
|
|
||||||
|
|
||||||
// Set drawer title
|
|
||||||
const titleEl = this.detailsDrawer.querySelector('.drawer-title');
|
|
||||||
if (titleEl) {
|
|
||||||
titleEl.textContent = title;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear previous component if any
|
|
||||||
if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') {
|
|
||||||
try {
|
|
||||||
this.activeDrawerComponent.unmount();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
this.detailsDrawerContent.innerHTML = '<div class="loading-details">Loading detailed information...</div>';
|
|
||||||
|
|
||||||
// Execute content callback
|
|
||||||
try {
|
|
||||||
contentCallback(this.detailsDrawerContent, (component) => {
|
|
||||||
this.activeDrawerComponent = component;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to load drawer content:', error);
|
|
||||||
if (errorCallback) {
|
|
||||||
errorCallback(error);
|
|
||||||
} else {
|
|
||||||
this.detailsDrawerContent.innerHTML = `
|
|
||||||
<div class="error">
|
|
||||||
<strong>Error loading content:</strong><br>
|
|
||||||
${this.escapeHtml ? this.escapeHtml(error.message) : error.message}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open drawer
|
|
||||||
this.detailsDrawer.classList.add('open');
|
|
||||||
this.detailsDrawerBackdrop.classList.add('visible');
|
|
||||||
}
|
|
||||||
|
|
||||||
closeDrawer() {
|
|
||||||
if (this.detailsDrawer) this.detailsDrawer.classList.remove('open');
|
|
||||||
if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible');
|
|
||||||
|
|
||||||
// Call close callback if provided
|
|
||||||
if (this.onCloseCallback) {
|
|
||||||
this.onCloseCallback();
|
|
||||||
this.onCloseCallback = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up drawer elements
|
|
||||||
destroy() {
|
|
||||||
if (this.detailsDrawer && this.detailsDrawer.parentNode) {
|
|
||||||
this.detailsDrawer.parentNode.removeChild(this.detailsDrawer);
|
|
||||||
}
|
|
||||||
if (this.detailsDrawerBackdrop && this.detailsDrawerBackdrop.parentNode) {
|
|
||||||
this.detailsDrawerBackdrop.parentNode.removeChild(this.detailsDrawerBackdrop);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.detailsDrawer = null;
|
|
||||||
this.detailsDrawerContent = null;
|
|
||||||
this.detailsDrawerBackdrop = null;
|
|
||||||
this.activeDrawerComponent = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method for HTML escaping (can be overridden)
|
|
||||||
escapeHtml(text) {
|
|
||||||
if (typeof text !== 'string') return text;
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.DrawerComponent = DrawerComponent;
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
// Node Details Component
|
// Node Details Component with enhanced state preservation
|
||||||
class NodeDetailsComponent extends Component {
|
class NodeDetailsComponent extends Component {
|
||||||
constructor(container, viewModel, eventBus) {
|
constructor(container, viewModel, eventBus) {
|
||||||
super(container, viewModel, eventBus);
|
super(container, viewModel, eventBus);
|
||||||
this.suppressLoadingUI = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for color conversion
|
// Helper functions for color conversion
|
||||||
@@ -33,29 +32,27 @@ class NodeDetailsComponent extends Component {
|
|||||||
this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this));
|
this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this));
|
||||||
this.subscribeToProperty('error', this.handleErrorUpdate.bind(this));
|
this.subscribeToProperty('error', this.handleErrorUpdate.bind(this));
|
||||||
this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this));
|
this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this));
|
||||||
this.subscribeToProperty('endpoints', this.handleEndpointsUpdate.bind(this));
|
this.subscribeToProperty('capabilities', this.handleCapabilitiesUpdate.bind(this));
|
||||||
this.subscribeToProperty('monitoringResources', this.handleMonitoringResourcesUpdate.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle node status update
|
// Handle node status update with state preservation
|
||||||
handleNodeStatusUpdate(newStatus, previousStatus) {
|
handleNodeStatusUpdate(newStatus, previousStatus) {
|
||||||
if (newStatus && !this.viewModel.get('isLoading')) {
|
if (newStatus && !this.viewModel.get('isLoading')) {
|
||||||
this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('endpoints'), this.viewModel.get('monitoringResources'));
|
this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('capabilities'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tasks update
|
// Handle tasks update with state preservation
|
||||||
handleTasksUpdate(newTasks, previousTasks) {
|
handleTasksUpdate(newTasks, previousTasks) {
|
||||||
const nodeStatus = this.viewModel.get('nodeStatus');
|
const nodeStatus = this.viewModel.get('nodeStatus');
|
||||||
if (nodeStatus && !this.viewModel.get('isLoading')) {
|
if (nodeStatus && !this.viewModel.get('isLoading')) {
|
||||||
this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('endpoints'), this.viewModel.get('monitoringResources'));
|
this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('capabilities'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle loading state update
|
// Handle loading state update
|
||||||
handleLoadingUpdate(isLoading) {
|
handleLoadingUpdate(isLoading) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
if (this.suppressLoadingUI) return;
|
|
||||||
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
|
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,22 +70,12 @@ class NodeDetailsComponent extends Component {
|
|||||||
this.updateActiveTab(newTab, previousTab);
|
this.updateActiveTab(newTab, previousTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle endpoints update
|
// Handle capabilities update with state preservation
|
||||||
handleEndpointsUpdate(newEndpoints, previousEndpoints) {
|
handleCapabilitiesUpdate(newCapabilities, previousCapabilities) {
|
||||||
const nodeStatus = this.viewModel.get('nodeStatus');
|
const nodeStatus = this.viewModel.get('nodeStatus');
|
||||||
const tasks = this.viewModel.get('tasks');
|
const tasks = this.viewModel.get('tasks');
|
||||||
if (nodeStatus && !this.viewModel.get('isLoading')) {
|
if (nodeStatus && !this.viewModel.get('isLoading')) {
|
||||||
this.renderNodeDetails(nodeStatus, tasks, newEndpoints, this.viewModel.get('monitoringResources'));
|
this.renderNodeDetails(nodeStatus, tasks, newCapabilities);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle monitoring resources update
|
|
||||||
handleMonitoringResourcesUpdate(newResources, previousResources) {
|
|
||||||
const nodeStatus = this.viewModel.get('nodeStatus');
|
|
||||||
const tasks = this.viewModel.get('tasks');
|
|
||||||
const endpoints = this.viewModel.get('endpoints');
|
|
||||||
if (nodeStatus && !this.viewModel.get('isLoading')) {
|
|
||||||
this.renderNodeDetails(nodeStatus, tasks, endpoints, newResources);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +84,7 @@ class NodeDetailsComponent extends Component {
|
|||||||
const tasks = this.viewModel.get('tasks');
|
const tasks = this.viewModel.get('tasks');
|
||||||
const isLoading = this.viewModel.get('isLoading');
|
const isLoading = this.viewModel.get('isLoading');
|
||||||
const error = this.viewModel.get('error');
|
const error = this.viewModel.get('error');
|
||||||
const endpoints = this.viewModel.get('endpoints');
|
const capabilities = this.viewModel.get('capabilities');
|
||||||
const monitoringResources = this.viewModel.get('monitoringResources');
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
|
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
|
||||||
@@ -115,47 +101,56 @@ class NodeDetailsComponent extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.renderNodeDetails(nodeStatus, tasks, endpoints, monitoringResources);
|
this.renderNodeDetails(nodeStatus, tasks, capabilities);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderNodeDetails(nodeStatus, tasks, endpoints, monitoringResources) {
|
renderNodeDetails(nodeStatus, tasks, capabilities) {
|
||||||
// Use persisted active tab from the view model, default to 'status'
|
// Use persisted active tab from the view model, default to 'status'
|
||||||
const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status';
|
const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status';
|
||||||
logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab);
|
logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab);
|
||||||
|
|
||||||
// Build labels bar (above tabs)
|
|
||||||
const labelsObj = (nodeStatus && nodeStatus.labels) ? nodeStatus.labels : null;
|
|
||||||
const labelsBar = (labelsObj && Object.keys(labelsObj).length)
|
|
||||||
? `<div class="member-labels" style="margin: 0 0 12px 0;">${Object.entries(labelsObj)
|
|
||||||
.map(([k, v]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
|
|
||||||
.join('')}</div>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
${labelsBar}
|
|
||||||
<div class="tabs-container">
|
<div class="tabs-container">
|
||||||
<div class="tabs-header">
|
<div class="tabs-header">
|
||||||
<button class="tab-button ${activeTab === 'status' ? 'active' : ''}" data-tab="status">Status</button>
|
<button class="tab-button ${activeTab === 'status' ? 'active' : ''}" data-tab="status">Status</button>
|
||||||
<button class="tab-button ${activeTab === 'endpoints' ? 'active' : ''}" data-tab="endpoints">Endpoints</button>
|
<button class="tab-button ${activeTab === 'endpoints' ? 'active' : ''}" data-tab="endpoints">Endpoints</button>
|
||||||
|
<button class="tab-button ${activeTab === 'capabilities' ? 'active' : ''}" data-tab="capabilities">Capabilities</button>
|
||||||
<button class="tab-button ${activeTab === 'tasks' ? 'active' : ''}" data-tab="tasks">Tasks</button>
|
<button class="tab-button ${activeTab === 'tasks' ? 'active' : ''}" data-tab="tasks">Tasks</button>
|
||||||
<button class="tab-button ${activeTab === 'firmware' ? 'active' : ''}" data-tab="firmware">Firmware</button>
|
<button class="tab-button ${activeTab === 'firmware' ? 'active' : ''}" data-tab="firmware">Firmware</button>
|
||||||
<button class="tab-refresh-btn" title="Refresh current tab" aria-label="Refresh">
|
|
||||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
||||||
<path d="M1 4v6h6" />
|
|
||||||
<path d="M23 20v-6h-6" />
|
|
||||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content ${activeTab === 'status' ? 'active' : ''}" id="status-tab">
|
<div class="tab-content ${activeTab === 'status' ? 'active' : ''}" id="status-tab">
|
||||||
${this.renderStatusTab(nodeStatus, monitoringResources)}
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Free Heap:</span>
|
||||||
|
<span class="detail-value">${Math.round(nodeStatus.freeHeap / 1024)}KB</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Chip ID:</span>
|
||||||
|
<span class="detail-value">${nodeStatus.chipId}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">SDK Version:</span>
|
||||||
|
<span class="detail-value">${nodeStatus.sdkVersion}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">CPU Frequency:</span>
|
||||||
|
<span class="detail-value">${nodeStatus.cpuFreqMHz}MHz</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Flash Size:</span>
|
||||||
|
<span class="detail-value">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content ${activeTab === 'endpoints' ? 'active' : ''}" id="endpoints-tab">
|
<div class="tab-content ${activeTab === 'endpoints' ? 'active' : ''}" id="endpoints-tab">
|
||||||
${this.renderEndpointsTab(endpoints)}
|
${nodeStatus.api ? nodeStatus.api.map(endpoint =>
|
||||||
|
`<div class="endpoint-item">${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}</div>`
|
||||||
|
).join('') : '<div class="endpoint-item">No API endpoints available</div>'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content ${activeTab === 'capabilities' ? 'active' : ''}" id="capabilities-tab">
|
||||||
|
${this.renderCapabilitiesTab(capabilities)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-content ${activeTab === 'tasks' ? 'active' : ''}" id="tasks-tab">
|
<div class="tab-content ${activeTab === 'tasks' ? 'active' : ''}" id="tasks-tab">
|
||||||
${this.renderTasksTab(tasks)}
|
${this.renderTasksTab(tasks)}
|
||||||
@@ -169,7 +164,6 @@ class NodeDetailsComponent extends Component {
|
|||||||
|
|
||||||
this.setHTML('', html);
|
this.setHTML('', html);
|
||||||
this.setupTabs();
|
this.setupTabs();
|
||||||
this.setupTabRefreshButton();
|
|
||||||
// Restore last active tab from view model if available
|
// Restore last active tab from view model if available
|
||||||
const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null;
|
const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null;
|
||||||
if (restored) {
|
if (restored) {
|
||||||
@@ -178,230 +172,18 @@ class NodeDetailsComponent extends Component {
|
|||||||
this.setupFirmwareUpload();
|
this.setupFirmwareUpload();
|
||||||
}
|
}
|
||||||
|
|
||||||
setupTabRefreshButton() {
|
renderCapabilitiesTab(capabilities) {
|
||||||
const btn = this.findElement('.tab-refresh-btn');
|
if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) {
|
||||||
if (!btn) return;
|
|
||||||
this.addEventListener(btn, 'click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const original = btn.innerHTML;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = `
|
|
||||||
<svg class="refresh-icon spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
||||||
<path d="M1 4v6h6" />
|
|
||||||
<path d="M23 20v-6h-6" />
|
|
||||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 += `
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Chip ID:</span>
|
|
||||||
<span class="detail-value">${nodeStatus.chipId}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">SDK Version:</span>
|
|
||||||
<span class="detail-value">${nodeStatus.sdkVersion}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">CPU Frequency:</span>
|
|
||||||
<span class="detail-value">${nodeStatus.cpuFreqMHz}MHz</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Flash Size:</span>
|
|
||||||
<span class="detail-value">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add monitoring resources if available
|
|
||||||
if (monitoringResources) {
|
|
||||||
html += `
|
|
||||||
<div class="monitoring-section">
|
|
||||||
<div class="monitoring-header">Resources</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// CPU Usage
|
|
||||||
if (monitoringResources.cpu) {
|
|
||||||
html += `
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">CPU Usage (Avg):</span>
|
|
||||||
<span class="detail-value">${monitoringResources.cpu.average_usage ? monitoringResources.cpu.average_usage.toFixed(1) + '%' : 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory Usage
|
|
||||||
if (monitoringResources.memory) {
|
|
||||||
const heapUsagePercent = monitoringResources.memory.heap_usage_percent || 0;
|
|
||||||
const totalHeap = monitoringResources.memory.total_heap || 0;
|
|
||||||
const usedHeap = totalHeap - (monitoringResources.memory.free_heap || 0);
|
|
||||||
const usedHeapKB = Math.round(usedHeap / 1024);
|
|
||||||
const totalHeapKB = Math.round(totalHeap / 1024);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Heap Usage:</span>
|
|
||||||
<span class="detail-value">${heapUsagePercent.toFixed(1)}% (${usedHeapKB}KB / ${totalHeapKB}KB)</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filesystem Usage
|
|
||||||
if (monitoringResources.filesystem) {
|
|
||||||
const usedKB = Math.round(monitoringResources.filesystem.used_bytes / 1024);
|
|
||||||
const totalKB = Math.round(monitoringResources.filesystem.total_bytes / 1024);
|
|
||||||
const usagePercent = monitoringResources.filesystem.total_bytes > 0
|
|
||||||
? ((monitoringResources.filesystem.used_bytes / monitoringResources.filesystem.total_bytes) * 100).toFixed(1)
|
|
||||||
: '0.0';
|
|
||||||
html += `
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Filesystem:</span>
|
|
||||||
<span class="detail-value">${usagePercent}% (${usedKB}KB / ${totalKB}KB)</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// System Information
|
|
||||||
if (monitoringResources.system) {
|
|
||||||
html += `
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Uptime:</span>
|
|
||||||
<span class="detail-value">${monitoringResources.system.uptime_formatted || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network Information
|
|
||||||
if (monitoringResources.network) {
|
|
||||||
const uptimeSeconds = monitoringResources.network.uptime_seconds || 0;
|
|
||||||
const uptimeHours = Math.floor(uptimeSeconds / 3600);
|
|
||||||
const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
|
|
||||||
const uptimeFormatted = `${uptimeHours}h ${uptimeMinutes}m`;
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">WiFi RSSI:</span>
|
|
||||||
<span class="detail-value">${monitoringResources.network.wifi_rssi || 'N/A'} dBm</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Network Uptime:</span>
|
|
||||||
<span class="detail-value">${uptimeFormatted}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 `
|
|
||||||
<div class="resource-gauges">
|
|
||||||
<div class="gauge-container">
|
|
||||||
<div class="gauge ${getColorClass(cpuUsage)}" data-percentage="${cpuUsage}" style="--percentage: ${cpuUsage}">
|
|
||||||
<div class="gauge-circle">
|
|
||||||
<div class="gauge-text">
|
|
||||||
<div class="gauge-value">${cpuUsage.toFixed(1)}%</div>
|
|
||||||
<div class="gauge-label">CPU</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="gauge-container">
|
|
||||||
<div class="gauge ${getColorClass(heapUsage)}" data-percentage="${heapUsage}" style="--percentage: ${heapUsage}">
|
|
||||||
<div class="gauge-circle">
|
|
||||||
<div class="gauge-text">
|
|
||||||
<div class="gauge-value">${heapUsage.toFixed(1)}%</div>
|
|
||||||
<div class="gauge-label">Heap</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="gauge-container">
|
|
||||||
<div class="gauge ${getColorClass(filesystemUsage)}" data-percentage="${filesystemUsage}" style="--percentage: ${filesystemUsage}">
|
|
||||||
<div class="gauge-circle">
|
|
||||||
<div class="gauge-text">
|
|
||||||
<div class="gauge-value">${filesystemUsage.toFixed(1)}%</div>
|
|
||||||
<div class="gauge-label">Storage</div>
|
|
||||||
<!-- <div class="gauge-detail">${filesystemUsedKB}KB / ${filesystemTotalKB}KB</div> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEndpointsTab(endpoints) {
|
|
||||||
if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) {
|
|
||||||
return `
|
return `
|
||||||
<div class="no-endpoints">
|
<div class="no-capabilities">
|
||||||
<div>🧩 No endpoints reported</div>
|
<div>🧩 No capabilities reported</div>
|
||||||
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">This node did not return any endpoints</div>
|
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">This node did not return any capabilities</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort endpoints by URI (name), then by method for stable ordering
|
// Sort endpoints by URI (name), then by method for stable ordering
|
||||||
const endpointsList = [...endpoints.endpoints].sort((a, b) => {
|
const endpoints = [...capabilities.endpoints].sort((a, b) => {
|
||||||
const aUri = String(a.uri || '').toLowerCase();
|
const aUri = String(a.uri || '').toLowerCase();
|
||||||
const bUri = String(b.uri || '').toLowerCase();
|
const bUri = String(b.uri || '').toLowerCase();
|
||||||
if (aUri < bUri) return -1;
|
if (aUri < bUri) return -1;
|
||||||
@@ -411,22 +193,22 @@ class NodeDetailsComponent extends Component {
|
|||||||
return aMethod.localeCompare(bMethod);
|
return aMethod.localeCompare(bMethod);
|
||||||
});
|
});
|
||||||
|
|
||||||
const total = endpointsList.length;
|
const total = endpoints.length;
|
||||||
|
|
||||||
// Preserve selection based on a stable key of method+uri if available
|
// Preserve selection based on a stable key of method+uri if available
|
||||||
const selectedKey = String(this.getUIState('endpointSelectedKey') || '');
|
const selectedKey = String(this.getUIState('capSelectedKey') || '');
|
||||||
let selectedIndex = endpointsList.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey);
|
let selectedIndex = endpoints.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey);
|
||||||
if (selectedIndex === -1) {
|
if (selectedIndex === -1) {
|
||||||
selectedIndex = Number(this.getUIState('endpointSelectedIndex'));
|
selectedIndex = Number(this.getUIState('capSelectedIndex'));
|
||||||
if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) {
|
if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) {
|
||||||
selectedIndex = 0;
|
selectedIndex = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute padding for aligned display in dropdown
|
// Compute padding for aligned display in dropdown
|
||||||
const maxMethodLen = endpointsList.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0);
|
const maxMethodLen = endpoints.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0);
|
||||||
|
|
||||||
const selectorOptions = endpointsList.map((ep, idx) => {
|
const selectorOptions = endpoints.map((ep, idx) => {
|
||||||
const method = String(ep.method || '');
|
const method = String(ep.method || '');
|
||||||
const uri = String(ep.uri || '');
|
const uri = String(ep.uri || '');
|
||||||
const padCount = Math.max(1, (maxMethodLen - method.length) + 2);
|
const padCount = Math.max(1, (maxMethodLen - method.length) + 2);
|
||||||
@@ -434,12 +216,12 @@ class NodeDetailsComponent extends Component {
|
|||||||
return `<option value="${idx}" data-method="${method}" data-uri="${uri}" ${idx === selectedIndex ? 'selected' : ''}>${method}${spacer}${uri}</option>`;
|
return `<option value="${idx}" data-method="${method}" data-uri="${uri}" ${idx === selectedIndex ? 'selected' : ''}>${method}${spacer}${uri}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
const items = endpointsList.map((ep, idx) => {
|
const items = endpoints.map((ep, idx) => {
|
||||||
const formId = `endpoint-form-${idx}`;
|
const formId = `cap-form-${idx}`;
|
||||||
const resultId = `endpoint-result-${idx}`;
|
const resultId = `cap-result-${idx}`;
|
||||||
const params = Array.isArray(ep.params) && ep.params.length > 0
|
const params = Array.isArray(ep.params) && ep.params.length > 0
|
||||||
? `<div class="endpoint-params">${ep.params.map((p, pidx) => `
|
? `<div class="capability-params">${ep.params.map((p, pidx) => `
|
||||||
<label class="endpoint-param" for="${formId}-field-${pidx}">
|
<label class="capability-param" for="${formId}-field-${pidx}">
|
||||||
<span class="param-name">${p.name}${p.required ? ' *' : ''}</span>
|
<span class="param-name">${p.name}${p.required ? ' *' : ''}</span>
|
||||||
${ (Array.isArray(p.values) && p.values.length > 1)
|
${ (Array.isArray(p.values) && p.values.length > 1)
|
||||||
? `<select id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input">${p.values.map(v => `<option value="${v}">${v}</option>`).join('')}</select>`
|
? `<select id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input">${p.values.map(v => `<option value="${v}">${v}</option>`).join('')}</select>`
|
||||||
@@ -471,54 +253,54 @@ class NodeDetailsComponent extends Component {
|
|||||||
}
|
}
|
||||||
</label>
|
</label>
|
||||||
`).join('')}</div>`
|
`).join('')}</div>`
|
||||||
: '<div class="endpoint-params none">No parameters</div>';
|
: '<div class="capability-params none">No parameters</div>';
|
||||||
return `
|
return `
|
||||||
<div class="endpoint-item" data-endpoint-index="${idx}" style="display:${idx === selectedIndex ? '' : 'none'};">
|
<div class="capability-item" data-cap-index="${idx}" style="display:${idx === selectedIndex ? '' : 'none'};">
|
||||||
<div class="endpoint-header">
|
<div class="capability-header">
|
||||||
<span class="endpoint-method">${ep.method}</span>
|
<span class="cap-method">${ep.method}</span>
|
||||||
<span class="endpoint-uri">${ep.uri}</span>
|
<span class="cap-uri">${ep.uri}</span>
|
||||||
<button class="endpoint-call-btn" data-action="call-endpoint" data-method="${ep.method}" data-uri="${ep.uri}" data-form-id="${formId}" data-result-id="${resultId}">Call</button>
|
<button class="cap-call-btn" data-action="call-capability" data-method="${ep.method}" data-uri="${ep.uri}" data-form-id="${formId}" data-result-id="${resultId}">Call</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="${formId}" class="endpoint-form" onsubmit="return false;">
|
<form id="${formId}" class="capability-form" onsubmit="return false;">
|
||||||
${params}
|
${params}
|
||||||
</form>
|
</form>
|
||||||
<div id="${resultId}" class="endpoint-result" style="display:none;"></div>
|
<div id="${resultId}" class="capability-result" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Attach events after render in setupEndpointsEvents()
|
// Attach events after render in setupCapabilitiesEvents()
|
||||||
setTimeout(() => this.setupEndpointsEvents(), 0);
|
setTimeout(() => this.setupCapabilitiesEvents(), 0);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="endpoint-selector">
|
<div class="capability-selector">
|
||||||
<label class="param-name" for="endpoint-select">Endpoint</label>
|
<label class="param-name" for="capability-select">Capability</label>
|
||||||
<select id="endpoint-select" class="param-input" style="font-family: monospace;">${selectorOptions}</select>
|
<select id="capability-select" class="param-input" style="font-family: monospace;">${selectorOptions}</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="endpoints-list">${items}</div>
|
<div class="capabilities-list">${items}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEndpointsEvents() {
|
setupCapabilitiesEvents() {
|
||||||
const selector = this.findElement('#endpoint-select');
|
const selector = this.findElement('#capability-select');
|
||||||
if (selector) {
|
if (selector) {
|
||||||
this.addEventListener(selector, 'change', (e) => {
|
this.addEventListener(selector, 'change', (e) => {
|
||||||
const selected = Number(e.target.value);
|
const selected = Number(e.target.value);
|
||||||
const items = Array.from(this.findAllElements('.endpoint-item'));
|
const items = Array.from(this.findAllElements('.capability-item'));
|
||||||
items.forEach((el, idx) => {
|
items.forEach((el, idx) => {
|
||||||
el.style.display = (idx === selected) ? '' : 'none';
|
el.style.display = (idx === selected) ? '' : 'none';
|
||||||
});
|
});
|
||||||
this.setUIState('endpointSelectedIndex', selected);
|
this.setUIState('capSelectedIndex', selected);
|
||||||
const opt = e.target.selectedOptions && e.target.selectedOptions[0];
|
const opt = e.target.selectedOptions && e.target.selectedOptions[0];
|
||||||
if (opt) {
|
if (opt) {
|
||||||
const method = opt.dataset.method || '';
|
const method = opt.dataset.method || '';
|
||||||
const uri = opt.dataset.uri || '';
|
const uri = opt.dataset.uri || '';
|
||||||
this.setUIState('endpointSelectedKey', `${method} ${uri}`);
|
this.setUIState('capSelectedKey', `${method} ${uri}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttons = this.findAllElements('.endpoint-call-btn');
|
const buttons = this.findAllElements('.cap-call-btn');
|
||||||
buttons.forEach(btn => {
|
buttons.forEach(btn => {
|
||||||
this.addEventListener(btn, 'click', async (e) => {
|
this.addEventListener(btn, 'click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -553,7 +335,7 @@ class NodeDetailsComponent extends Component {
|
|||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
resultEl.style.display = 'block';
|
resultEl.style.display = 'block';
|
||||||
resultEl.innerHTML = `
|
resultEl.innerHTML = `
|
||||||
<div class="endpoint-call-error">
|
<div class="cap-call-error">
|
||||||
<div>❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}</div>
|
<div>❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -565,17 +347,17 @@ class NodeDetailsComponent extends Component {
|
|||||||
resultEl.innerHTML = '<div class="loading">Calling endpoint...</div>';
|
resultEl.innerHTML = '<div class="loading">Calling endpoint...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.viewModel.callEndpoint(method, uri, params);
|
const response = await this.viewModel.callCapability(method, uri, params);
|
||||||
const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? '');
|
const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? '');
|
||||||
resultEl.innerHTML = `
|
resultEl.innerHTML = `
|
||||||
<div class="endpoint-call-success">
|
<div class="cap-call-success">
|
||||||
<div>✅ Success</div>
|
<div>✅ Success</div>
|
||||||
<pre class="endpoint-result-pre">${this.escapeHtml(pretty)}</pre>
|
<pre class="cap-result-pre">${this.escapeHtml(pretty)}</pre>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
resultEl.innerHTML = `
|
resultEl.innerHTML = `
|
||||||
<div class="endpoint-call-error">
|
<div class="cap-call-error">
|
||||||
<div>❌ Error: ${this.escapeHtml(err.message || 'Request failed')}</div>
|
<div>❌ Error: ${this.escapeHtml(err.message || 'Request failed')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -779,14 +561,9 @@ class NodeDetailsComponent extends Component {
|
|||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
uploadBtn.textContent = '⏳ Uploading...';
|
uploadBtn.textContent = '⏳ Uploading...';
|
||||||
|
|
||||||
// Get the member IP from the card if available, otherwise fallback to view model state
|
// Get the member IP from the card
|
||||||
const memberCard = this.container.closest('.member-card');
|
const memberCard = this.container.closest('.member-card');
|
||||||
let memberIp = null;
|
const memberIp = memberCard.dataset.memberIp;
|
||||||
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) {
|
if (!memberIp) {
|
||||||
throw new Error('Could not determine target node IP address');
|
throw new Error('Could not determine target node IP address');
|
||||||
|
|||||||
@@ -9,106 +9,6 @@ class TopologyGraphComponent extends Component {
|
|||||||
this.width = 0; // Will be set dynamically based on container size
|
this.width = 0; // Will be set dynamically based on container size
|
||||||
this.height = 0; // Will be set dynamically based on container size
|
this.height = 0; // Will be set dynamically based on container size
|
||||||
this.isInitialized = false;
|
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 = `
|
|
||||||
<div class="error">
|
|
||||||
<strong>Error loading node details:</strong><br>
|
|
||||||
${this.escapeHtml(error.message)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
|
|
||||||
.join('');
|
|
||||||
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>`;
|
|
||||||
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) {
|
updateDimensions(container) {
|
||||||
@@ -268,7 +168,7 @@ class TopologyGraphComponent extends Component {
|
|||||||
.attr('height', '100%')
|
.attr('height', '100%')
|
||||||
.attr('viewBox', `0 0 ${this.width} ${this.height}`)
|
.attr('viewBox', `0 0 ${this.width} ${this.height}`)
|
||||||
.style('border', '1px solid rgba(255, 255, 255, 0.1)')
|
.style('border', '1px solid rgba(255, 255, 255, 0.1)')
|
||||||
//.style('background', 'rgba(0, 0, 0, 0.2)')
|
.style('background', 'rgba(0, 0, 0, 0.2)')
|
||||||
.style('border-radius', '12px');
|
.style('border-radius', '12px');
|
||||||
|
|
||||||
// Add zoom behavior
|
// Add zoom behavior
|
||||||
@@ -380,32 +280,22 @@ class TopologyGraphComponent extends Component {
|
|||||||
.attr('x', 15)
|
.attr('x', 15)
|
||||||
.attr('y', 4)
|
.attr('y', 4)
|
||||||
.attr('font-size', '13px')
|
.attr('font-size', '13px')
|
||||||
.attr('fill', 'var(--text-primary)')
|
.attr('fill', '#ecf0f1')
|
||||||
.attr('font-weight', '500');
|
.attr('font-weight', '500');
|
||||||
|
|
||||||
// IP
|
// IP
|
||||||
node.append('text')
|
node.append('text')
|
||||||
.text(d => d.ip)
|
.text(d => d.ip)
|
||||||
.attr('x', 15)
|
.attr('x', 15)
|
||||||
.attr('y', 22)
|
.attr('y', 20)
|
||||||
.attr('font-size', '11px')
|
.attr('font-size', '11px')
|
||||||
.attr('fill', 'var(--text-secondary)');
|
.attr('fill', 'rgba(255, 255, 255, 0.7)');
|
||||||
|
|
||||||
// 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
|
// Status text
|
||||||
node.append('text')
|
node.append('text')
|
||||||
.text(d => d.status)
|
.text(d => d.status)
|
||||||
.attr('x', 15)
|
.attr('x', 15)
|
||||||
.attr('y', 56)
|
.attr('y', 35)
|
||||||
.attr('font-size', '11px')
|
.attr('font-size', '11px')
|
||||||
.attr('fill', d => this.getNodeColor(d.status))
|
.attr('fill', d => this.getNodeColor(d.status))
|
||||||
.attr('font-weight', '600');
|
.attr('font-weight', '600');
|
||||||
@@ -417,7 +307,7 @@ class TopologyGraphComponent extends Component {
|
|||||||
.data(links)
|
.data(links)
|
||||||
.enter().append('text')
|
.enter().append('text')
|
||||||
.attr('font-size', '12px')
|
.attr('font-size', '12px')
|
||||||
.attr('fill', 'var(--text-primary)')
|
.attr('fill', '#ecf0f1')
|
||||||
.attr('font-weight', '600')
|
.attr('font-weight', '600')
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)')
|
.style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)')
|
||||||
@@ -455,31 +345,19 @@ class TopologyGraphComponent extends Component {
|
|||||||
node.on('click', (event, d) => {
|
node.on('click', (event, d) => {
|
||||||
this.viewModel.selectNode(d.id);
|
this.viewModel.selectNode(d.id);
|
||||||
this.updateSelection(d.id);
|
this.updateSelection(d.id);
|
||||||
if (this.isDesktop()) {
|
this.showMemberCardOverlay(d);
|
||||||
// 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) => {
|
node.on('mouseover', (event, d) => {
|
||||||
d3.select(event.currentTarget).select('circle')
|
d3.select(event.currentTarget).select('circle')
|
||||||
.attr('r', d => this.getNodeRadius(d.status) + 4)
|
.attr('r', d => this.getNodeRadius(d.status) + 4)
|
||||||
.attr('stroke-width', 3);
|
.attr('stroke-width', 3);
|
||||||
this.showTooltip(d, event.pageX, event.pageY);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
node.on('mouseout', (event, d) => {
|
node.on('mouseout', (event, d) => {
|
||||||
d3.select(event.currentTarget).select('circle')
|
d3.select(event.currentTarget).select('circle')
|
||||||
.attr('r', d => this.getNodeRadius(d.status))
|
.attr('r', d => this.getNodeRadius(d.status))
|
||||||
.attr('stroke-width', 2);
|
.attr('stroke-width', 2);
|
||||||
this.hideTooltip();
|
|
||||||
});
|
|
||||||
|
|
||||||
node.on('mousemove', (event, d) => {
|
|
||||||
this.moveTooltip(event.pageX, event.pageY);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
link.on('mouseover', (event, d) => {
|
link.on('mouseover', (event, d) => {
|
||||||
@@ -722,7 +600,7 @@ class TopologyGraphComponent extends Component {
|
|||||||
hostname: nodeData.hostname,
|
hostname: nodeData.hostname,
|
||||||
status: this.normalizeStatus(nodeData.status),
|
status: this.normalizeStatus(nodeData.status),
|
||||||
latency: nodeData.latency,
|
latency: nodeData.latency,
|
||||||
labels: (nodeData.labels && typeof nodeData.labels === 'object') ? nodeData.labels : (nodeData.resources || {})
|
labels: nodeData.resources || {}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.memberOverlayComponent.show(memberData);
|
this.memberOverlayComponent.show(memberData);
|
||||||
@@ -831,10 +709,10 @@ class MemberCardOverlayComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderMemberCard(member) {
|
renderMemberCard(member) {
|
||||||
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' :
|
const statusClass = member.status === 'active' ? 'status-online' :
|
||||||
(member.status && member.status.toUpperCase() === 'INACTIVE') ? 'status-inactive' : 'status-offline';
|
member.status === 'inactive' ? 'status-inactive' : 'status-offline';
|
||||||
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? '🟢' :
|
const statusIcon = member.status === 'active' ? '🟢' :
|
||||||
(member.status && member.status.toUpperCase() === 'INACTIVE') ? '🟠' : '🔴';
|
member.status === 'inactive' ? '🟠' : '🔴';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="member-overlay-content">
|
<div class="member-overlay-content">
|
||||||
|
|||||||
@@ -225,7 +225,10 @@ class ViewModel {
|
|||||||
|
|
||||||
// Batch update with change detection
|
// Batch update with change detection
|
||||||
batchUpdate(updates, options = {}) {
|
batchUpdate(updates, options = {}) {
|
||||||
const { notifyChanges = true } = options;
|
const { preserveUIState = true, notifyChanges = true } = options;
|
||||||
|
|
||||||
|
// Optionally preserve UI state snapshot
|
||||||
|
const currentUIState = preserveUIState ? new Map(this._uiState) : null;
|
||||||
|
|
||||||
// Track which keys actually change and what the previous values were
|
// Track which keys actually change and what the previous values were
|
||||||
const changedKeys = [];
|
const changedKeys = [];
|
||||||
@@ -242,6 +245,11 @@ class ViewModel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restore UI state if requested
|
||||||
|
if (preserveUIState && currentUIState) {
|
||||||
|
this._uiState = currentUIState;
|
||||||
|
}
|
||||||
|
|
||||||
// Notify listeners for changed keys
|
// Notify listeners for changed keys
|
||||||
if (notifyChanges) {
|
if (notifyChanges) {
|
||||||
changedKeys.forEach(key => {
|
changedKeys.forEach(key => {
|
||||||
@@ -251,7 +259,7 @@ class ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base Component class
|
// Base Component class with enhanced state preservation
|
||||||
class Component {
|
class Component {
|
||||||
constructor(container, viewModel, eventBus) {
|
constructor(container, viewModel, eventBus) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
// Theme Manager - Handles theme switching and persistence
|
|
||||||
|
|
||||||
class ThemeManager {
|
|
||||||
constructor() {
|
|
||||||
this.currentTheme = this.getStoredTheme() || 'dark';
|
|
||||||
this.themeToggle = document.getElementById('theme-toggle');
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Apply stored theme on page load
|
|
||||||
this.applyTheme(this.currentTheme);
|
|
||||||
|
|
||||||
// Set up event listener for theme toggle
|
|
||||||
if (this.themeToggle) {
|
|
||||||
this.themeToggle.addEventListener('click', () => this.toggleTheme());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
if (window.matchMedia) {
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
mediaQuery.addListener((e) => {
|
|
||||||
if (this.getStoredTheme() === 'system') {
|
|
||||||
this.applyTheme(e.matches ? 'dark' : 'light');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getStoredTheme() {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem('spore-ui-theme');
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Could not access localStorage for theme preference');
|
|
||||||
return 'dark';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setStoredTheme(theme) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('spore-ui-theme', theme);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Could not save theme preference to localStorage');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyTheme(theme) {
|
|
||||||
// Update data attribute on html element
|
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
|
|
||||||
// Update theme toggle icon
|
|
||||||
this.updateThemeIcon(theme);
|
|
||||||
|
|
||||||
// Store the theme preference
|
|
||||||
this.setStoredTheme(theme);
|
|
||||||
|
|
||||||
this.currentTheme = theme;
|
|
||||||
|
|
||||||
// Dispatch custom event for other components
|
|
||||||
window.dispatchEvent(new CustomEvent('themeChanged', {
|
|
||||||
detail: { theme: theme }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateThemeIcon(theme) {
|
|
||||||
if (!this.themeToggle) return;
|
|
||||||
|
|
||||||
const svg = this.themeToggle.querySelector('svg');
|
|
||||||
if (!svg) return;
|
|
||||||
|
|
||||||
// Update the SVG content based on theme
|
|
||||||
if (theme === 'light') {
|
|
||||||
// Sun icon for light theme
|
|
||||||
svg.innerHTML = `
|
|
||||||
<circle cx="12" cy="12" r="5"/>
|
|
||||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
// Moon icon for dark theme
|
|
||||||
svg.innerHTML = `
|
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleTheme() {
|
|
||||||
const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
|
|
||||||
this.applyTheme(newTheme);
|
|
||||||
|
|
||||||
// Add a subtle animation to the toggle button
|
|
||||||
if (this.themeToggle) {
|
|
||||||
this.themeToggle.style.transform = 'scale(0.9)';
|
|
||||||
setTimeout(() => {
|
|
||||||
this.themeToggle.style.transform = 'scale(1)';
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to get current theme (useful for other components)
|
|
||||||
getCurrentTheme() {
|
|
||||||
return this.currentTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method to set theme programmatically
|
|
||||||
setTheme(theme) {
|
|
||||||
if (['dark', 'light'].includes(theme)) {
|
|
||||||
this.applyTheme(theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize theme manager when DOM is loaded
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
window.themeManager = new ThemeManager();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export for use in other modules
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = ThemeManager;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// View Models for SPORE UI Components
|
// View Models for SPORE UI Components
|
||||||
|
|
||||||
// Cluster View Model
|
// Cluster View Model with enhanced state preservation
|
||||||
class ClusterViewModel extends ViewModel {
|
class ClusterViewModel extends ViewModel {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -23,7 +23,7 @@ class ClusterViewModel extends ViewModel {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cluster members
|
// Update cluster members with state preservation
|
||||||
async updateClusterMembers() {
|
async updateClusterMembers() {
|
||||||
try {
|
try {
|
||||||
logger.debug('ClusterViewModel: updateClusterMembers called');
|
logger.debug('ClusterViewModel: updateClusterMembers called');
|
||||||
@@ -42,15 +42,15 @@ class ClusterViewModel extends ViewModel {
|
|||||||
|
|
||||||
const members = response.members || [];
|
const members = response.members || [];
|
||||||
const onlineNodes = Array.isArray(members)
|
const onlineNodes = Array.isArray(members)
|
||||||
? members.filter(m => m && m.status && m.status.toUpperCase() === 'ACTIVE').length
|
? members.filter(m => m && m.status === 'active').length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Use batch update
|
// Use batch update to preserve UI state
|
||||||
this.batchUpdate({
|
this.batchUpdate({
|
||||||
members: members,
|
members: members,
|
||||||
lastUpdateTime: new Date().toISOString(),
|
lastUpdateTime: new Date().toISOString(),
|
||||||
onlineNodes: onlineNodes
|
onlineNodes: onlineNodes
|
||||||
});
|
}, { preserveUIState: true });
|
||||||
|
|
||||||
// Restore expanded cards and active tabs
|
// Restore expanded cards and active tabs
|
||||||
this.set('expandedCards', currentExpandedCards);
|
this.set('expandedCards', currentExpandedCards);
|
||||||
@@ -69,12 +69,12 @@ class ClusterViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update primary node display
|
// Update primary node display with state preservation
|
||||||
async updatePrimaryNodeDisplay() {
|
async updatePrimaryNodeDisplay() {
|
||||||
try {
|
try {
|
||||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||||
|
|
||||||
// Use batch update
|
// Use batch update to preserve UI state
|
||||||
const updates = {};
|
const updates = {};
|
||||||
|
|
||||||
if (discoveryInfo.primaryNode) {
|
if (discoveryInfo.primaryNode) {
|
||||||
@@ -91,7 +91,7 @@ class ClusterViewModel extends ViewModel {
|
|||||||
updates.totalNodes = 0;
|
updates.totalNodes = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.batchUpdate(updates);
|
this.batchUpdate(updates, { preserveUIState: true });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch discovery info:', error);
|
console.error('Failed to fetch discovery info:', error);
|
||||||
@@ -208,7 +208,7 @@ class ClusterViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Node Details View Model
|
// Node Details View Model with enhanced state preservation
|
||||||
class NodeDetailsViewModel extends ViewModel {
|
class NodeDetailsViewModel extends ViewModel {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -219,13 +219,12 @@ class NodeDetailsViewModel extends ViewModel {
|
|||||||
error: null,
|
error: null,
|
||||||
activeTab: 'status',
|
activeTab: 'status',
|
||||||
nodeIp: null,
|
nodeIp: null,
|
||||||
endpoints: null,
|
capabilities: null,
|
||||||
tasksSummary: null,
|
tasksSummary: null
|
||||||
monitoringResources: null
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load node details
|
// Load node details with state preservation
|
||||||
async loadNodeDetails(ip) {
|
async loadNodeDetails(ip) {
|
||||||
try {
|
try {
|
||||||
// Store current UI state
|
// Store current UI state
|
||||||
@@ -237,10 +236,10 @@ class NodeDetailsViewModel extends ViewModel {
|
|||||||
|
|
||||||
const nodeStatus = await window.apiClient.getNodeStatus(ip);
|
const nodeStatus = await window.apiClient.getNodeStatus(ip);
|
||||||
|
|
||||||
// Use batch update
|
// Use batch update to preserve UI state
|
||||||
this.batchUpdate({
|
this.batchUpdate({
|
||||||
nodeStatus: nodeStatus
|
nodeStatus: nodeStatus
|
||||||
});
|
}, { preserveUIState: true });
|
||||||
|
|
||||||
// Restore active tab
|
// Restore active tab
|
||||||
this.set('activeTab', currentActiveTab);
|
this.set('activeTab', currentActiveTab);
|
||||||
@@ -248,11 +247,8 @@ class NodeDetailsViewModel extends ViewModel {
|
|||||||
// Load tasks data
|
// Load tasks data
|
||||||
await this.loadTasksData();
|
await this.loadTasksData();
|
||||||
|
|
||||||
// Load endpoints data
|
// Load capabilities data
|
||||||
await this.loadEndpointsData();
|
await this.loadCapabilitiesData();
|
||||||
|
|
||||||
// Load monitoring resources data
|
|
||||||
await this.loadMonitoringResources();
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load node details:', error);
|
console.error('Failed to load node details:', error);
|
||||||
@@ -262,7 +258,7 @@ class NodeDetailsViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load tasks data
|
// Load tasks data with state preservation
|
||||||
async loadTasksData() {
|
async loadTasksData() {
|
||||||
try {
|
try {
|
||||||
const ip = this.get('nodeIp');
|
const ip = this.get('nodeIp');
|
||||||
@@ -276,38 +272,22 @@ class NodeDetailsViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load endpoints data
|
// Load capabilities data with state preservation
|
||||||
async loadEndpointsData() {
|
async loadCapabilitiesData() {
|
||||||
try {
|
try {
|
||||||
const ip = this.get('nodeIp');
|
const ip = this.get('nodeIp');
|
||||||
const response = await window.apiClient.getEndpoints(ip);
|
const response = await window.apiClient.getCapabilities(ip);
|
||||||
// Handle both real API (wrapped in endpoints) and mock API (direct array)
|
this.set('capabilities', response || null);
|
||||||
const endpointsData = (response && response.endpoints) ? response : { endpoints: response };
|
|
||||||
this.set('endpoints', endpointsData || null);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load endpoints:', error);
|
console.error('Failed to load capabilities:', error);
|
||||||
this.set('endpoints', null);
|
this.set('capabilities', null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load monitoring resources data
|
// Invoke a capability against this node
|
||||||
async loadMonitoringResources() {
|
async callCapability(method, uri, params) {
|
||||||
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');
|
const ip = this.get('nodeIp');
|
||||||
return window.apiClient.callEndpoint({ ip, method, uri, params });
|
return window.apiClient.callCapability({ ip, method, uri, params });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set active tab with state persistence
|
// Set active tab with state persistence
|
||||||
@@ -556,8 +536,6 @@ class TopologyViewModel extends ViewModel {
|
|||||||
ip: member.ip,
|
ip: member.ip,
|
||||||
status: member.status || 'UNKNOWN',
|
status: member.status || 'UNKNOWN',
|
||||||
latency: member.latency || 0,
|
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 || {},
|
resources: member.resources || {},
|
||||||
x: Math.random() * 1200 + 100, // Better spacing for 1400px width
|
x: Math.random() * 1200 + 100, // Better spacing for 1400px width
|
||||||
y: Math.random() * 800 + 100 // Better spacing for 1000px height
|
y: Math.random() * 800 + 100 // Better spacing for 1000px height
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -85,11 +85,11 @@ class SporeApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get node endpoints
|
* Get node capabilities
|
||||||
* @returns {Promise<Object>} endpoints response
|
* @returns {Promise<Object>} Capabilities response
|
||||||
*/
|
*/
|
||||||
async getCapabilities() {
|
async getCapabilities() {
|
||||||
return this.request('GET', '/api/node/endpoints');
|
return this.request('GET', '/api/capabilities');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
124
test/demo-discovery.js
Normal file
124
test/demo-discovery.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
#!/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);
|
||||||
102
test/demo-frontend.js
Normal file
102
test/demo-frontend.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/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);
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
232
test/mock-cli.js
232
test/mock-cli.js
@@ -1,232 +0,0 @@
|
|||||||
#!/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 <command> [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 <config> 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 <config-key>');
|
|
||||||
console.log(' node mock-cli.js info <config-key>');
|
|
||||||
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 <config-name>');
|
|
||||||
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 <config-name>');
|
|
||||||
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
|
|
||||||
};
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
};
|
|
||||||
@@ -1,791 +0,0 @@
|
|||||||
#!/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(`
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>SPORE UI - Mock Server Info</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
|
|
||||||
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
||||||
h1 { color: #333; text-align: center; }
|
|
||||||
.status { background: #e8f5e8; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
|
||||||
.info { background: #f0f8ff; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
|
||||||
.endpoint { background: #f9f9f9; padding: 10px; margin: 5px 0; border-left: 4px solid #007acc; }
|
|
||||||
.mock-note { background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #ffc107; }
|
|
||||||
.btn { display: inline-block; padding: 10px 20px; background: #007acc; color: white; text-decoration: none; border-radius: 5px; margin: 5px; }
|
|
||||||
.btn:hover { background: #005a9e; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🚀 SPORE UI Mock Server</h1>
|
|
||||||
|
|
||||||
<div class="mock-note">
|
|
||||||
<strong>Mock Mode Active:</strong> This is a complete simulation of the SPORE embedded system.
|
|
||||||
No real hardware or UDP ports are required.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status">
|
|
||||||
<h3>📊 Server Status</h3>
|
|
||||||
<p><strong>Status:</strong> Running</p>
|
|
||||||
<p><strong>Port:</strong> ${MOCK_CONFIG.port}</p>
|
|
||||||
<p><strong>Configuration:</strong> ${MOCK_CONFIG.name}</p>
|
|
||||||
<p><strong>Mock Nodes:</strong> ${MOCK_CONFIG.nodes.length}</p>
|
|
||||||
<p><strong>Primary Node:</strong> ${mockData.getPrimaryNode().ip}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<h3>🌐 Access Points</h3>
|
|
||||||
<p><a href="/" class="btn">Mock UI (Port 3002)</a> - Full UI with mock data</p>
|
|
||||||
<p><a href="/frontend" class="btn">Mock Frontend</a> - Custom mock frontend</p>
|
|
||||||
<p><a href="/ui" class="btn">Real UI (Port 3002)</a> - Real UI connected to mock server</p>
|
|
||||||
<p><a href="/api/health" class="btn">API Health</a> - Check server status</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<h3>🔗 Available Endpoints</h3>
|
|
||||||
<div class="endpoint"><strong>GET</strong> /api/health - Health check</div>
|
|
||||||
<div class="endpoint"><strong>GET</strong> /api/discovery/nodes - Discovery status</div>
|
|
||||||
<div class="endpoint"><strong>GET</strong> /api/tasks/status - Task status</div>
|
|
||||||
<div class="endpoint"><strong>POST</strong> /api/tasks/control - Control tasks</div>
|
|
||||||
<div class="endpoint"><strong>GET</strong> /api/node/status - System status</div>
|
|
||||||
<div class="endpoint"><strong>GET</strong> /api/cluster/members - Cluster members</div>
|
|
||||||
<div class="endpoint"><strong>POST</strong> /api/proxy-call - Generic proxy</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<h3>🎮 Mock Features</h3>
|
|
||||||
<ul>
|
|
||||||
<li>✅ Multiple simulated SPORE nodes</li>
|
|
||||||
<li>✅ Realistic data that changes over time</li>
|
|
||||||
<li>✅ No UDP port conflicts</li>
|
|
||||||
<li>✅ All API endpoints implemented</li>
|
|
||||||
<li>✅ Random failures simulation</li>
|
|
||||||
<li>✅ Primary node rotation</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<h3>🔧 Configuration</h3>
|
|
||||||
<p>Use npm scripts to change configuration:</p>
|
|
||||||
<ul>
|
|
||||||
<li><code>npm run mock:healthy</code> - Healthy cluster (3 nodes)</li>
|
|
||||||
<li><code>npm run mock:degraded</code> - Degraded cluster (some inactive)</li>
|
|
||||||
<li><code>npm run mock:large</code> - Large cluster (8 nodes)</li>
|
|
||||||
<li><code>npm run mock:unstable</code> - Unstable cluster (high failure rate)</li>
|
|
||||||
<li><code>npm run mock:single</code> - Single node</li>
|
|
||||||
<li><code>npm run mock:empty</code> - Empty cluster</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
#!/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 };
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>SPORE UI - Mock Mode</title>
|
|
||||||
|
|
||||||
<!-- Include all the same styles as the main UI -->
|
|
||||||
<link rel="stylesheet" href="/styles/main.css">
|
|
||||||
<link rel="stylesheet" href="/styles/theme.css">
|
|
||||||
|
|
||||||
<!-- Include D3.js for topology visualization -->
|
|
||||||
<script src="/vendor/d3.v7.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Include framework and components in correct order -->
|
|
||||||
<script src="/scripts/constants.js"></script>
|
|
||||||
<script src="/scripts/framework.js"></script>
|
|
||||||
<script src="/test/mock-api-client.js"></script>
|
|
||||||
<script src="/scripts/view-models.js"></script>
|
|
||||||
<script src="/scripts/components/DrawerComponent.js"></script>
|
|
||||||
<script src="/scripts/components/PrimaryNodeComponent.js"></script>
|
|
||||||
<script src="/scripts/components/NodeDetailsComponent.js"></script>
|
|
||||||
<script src="/scripts/components/ClusterMembersComponent.js"></script>
|
|
||||||
<script src="/scripts/components/FirmwareComponent.js"></script>
|
|
||||||
<script src="/scripts/components/FirmwareViewComponent.js"></script>
|
|
||||||
<script src="/scripts/components/ClusterViewComponent.js"></script>
|
|
||||||
<script src="/scripts/components/ClusterStatusComponent.js"></script>
|
|
||||||
<script src="/scripts/components/TopologyGraphComponent.js"></script>
|
|
||||||
<script src="/scripts/components/ComponentsLoader.js"></script>
|
|
||||||
<script src="/scripts/theme-manager.js"></script>
|
|
||||||
<script src="/scripts/app.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="main-navigation">
|
|
||||||
<button class="burger-btn" id="burger-btn" aria-label="Menu" title="Menu">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="nav-left">
|
|
||||||
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
|
||||||
<button class="nav-tab" data-view="topology">🔗 Topology</button>
|
|
||||||
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
|
||||||
</div>
|
|
||||||
<div class="nav-right">
|
|
||||||
<div class="theme-switcher">
|
|
||||||
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="5"/>
|
|
||||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="cluster-status">🚀 Cluster Online</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="cluster-view" class="view-content active">
|
|
||||||
<div class="cluster-section">
|
|
||||||
<div class="cluster-header">
|
|
||||||
<div class="cluster-header-left">
|
|
||||||
<div class="primary-node-info">
|
|
||||||
<span class="primary-node-label">Primary Node:</span>
|
|
||||||
<span class="primary-node-ip" id="primary-node-ip">Discovering...</span>
|
|
||||||
<button class="primary-node-refresh" id="select-random-primary-btn"
|
|
||||||
title="🎲 Select random primary node">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14"
|
|
||||||
height="14">
|
|
||||||
<path d="M1 4v6h6M23 20v-6h-6" />
|
|
||||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cluster-header-right">
|
|
||||||
<button class="refresh-btn" id="refresh-cluster-btn" title="Refresh cluster data">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16"
|
|
||||||
height="16">
|
|
||||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
|
||||||
<path d="M21 3v5h-5" />
|
|
||||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
|
||||||
<path d="M3 21v-5h5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cluster-members" id="cluster-members-container">
|
|
||||||
<!-- Cluster members will be rendered here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="topology-view" class="view-content">
|
|
||||||
<div class="topology-section">
|
|
||||||
<div class="topology-graph" id="topology-graph-container">
|
|
||||||
<!-- Topology graph will be rendered here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="firmware-view" class="view-content">
|
|
||||||
<div class="firmware-section">
|
|
||||||
<div class="firmware-header">
|
|
||||||
<h2>Firmware Management</h2>
|
|
||||||
<button class="refresh-btn" id="refresh-firmware-btn" title="Refresh firmware">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16"
|
|
||||||
height="16">
|
|
||||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
|
||||||
<path d="M21 3v5h-5" />
|
|
||||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
|
||||||
<path d="M3 21v-5h5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="firmware-content" id="firmware-container">
|
|
||||||
<!-- Firmware content will be rendered here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Mock server status indicator
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const mockStatus = document.getElementById('mock-status');
|
|
||||||
const mockInfoBtn = document.getElementById('mock-info-btn');
|
|
||||||
|
|
||||||
mockInfoBtn.addEventListener('click', function() {
|
|
||||||
alert('🎭 Mock\n\n' +
|
|
||||||
'This UI is connected to the mock server on port 3002.\n' +
|
|
||||||
'All data is simulated and updates automatically.\n\n' +
|
|
||||||
'To switch to real server:\n' +
|
|
||||||
'1. Start real server: npm start\n' +
|
|
||||||
'2. Open: http://localhost:3001\n\n' +
|
|
||||||
'To change mock configuration:\n' +
|
|
||||||
'npm run mock:degraded\n' +
|
|
||||||
'npm run mock:large\n' +
|
|
||||||
'npm run mock:unstable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.mock-status {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background: rgba(255, 193, 7, 0.9);
|
|
||||||
color: #000;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border-radius: 25px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
|
||||||
z-index: 1000;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mock-status-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mock-status-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mock-status-text {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
77
test/test-discovery.js
Normal file
77
test/test-discovery.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/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);
|
||||||
137
test/test-random-selection.js
Normal file
137
test/test-random-selection.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
#!/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);
|
||||||
Reference in New Issue
Block a user