From bb46e5d4123d5eebac26d0aaa41c0355afc34941 Mon Sep 17 00:00:00 2001 From: master Date: Thu, 28 Aug 2025 11:17:37 +0200 Subject: [PATCH] feature/capabilities (#2) Reviewed-on: https://git.dcentral.systems/iot/spore-ui/pulls/2 --- index.js | 132 ++++++++++++++++++++++++++-- public/api-client.js | 42 +++++++++ public/components.js | 138 +++++++++++++++++++++++++++++- public/styles.css | 195 +++++++++++++++++++++++++++++++++++++----- public/view-models.js | 24 +++++- src/client/index.js | 8 ++ 6 files changed, 504 insertions(+), 35 deletions(-) diff --git a/index.js b/index.js index 53b5e15..6548fc0 100644 --- a/index.js +++ b/index.js @@ -394,7 +394,45 @@ app.get('/api/tasks/status', async (req, res) => { // API endpoint to get system status app.get('/api/node/status', async (req, res) => { + try { + if (!sporeClient) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_DISCOVERY messages...', + discoveredNodes: Array.from(discoveredNodes.keys()) + }); + } + + const systemStatus = await sporeClient.getSystemStatus(); + res.json(systemStatus); + } catch (error) { + console.error('Error fetching system status:', error); + res.status(500).json({ + error: 'Failed to fetch system status', + message: error.message + }); + } +}); + +// Proxy endpoint to get node capabilities (optionally for a specific node via ?ip=) +app.get('/api/capabilities', async (req, res) => { try { + const { ip } = req.query; + + if (ip) { + try { + const nodeClient = new SporeApiClient(`http://${ip}`); + const caps = await nodeClient.getCapabilities(); + return res.json(caps); + } catch (innerError) { + console.error('Error fetching capabilities from specific node:', innerError); + return res.status(500).json({ + error: 'Failed to fetch capabilities from node', + message: innerError.message + }); + } + } + if (!sporeClient) { return res.status(503).json({ error: 'Service unavailable', @@ -402,14 +440,94 @@ app.get('/api/node/status', async (req, res) => { discoveredNodes: Array.from(discoveredNodes.keys()) }); } - - const systemStatus = await sporeClient.getSystemStatus(); - res.json(systemStatus); + + const caps = await sporeClient.getCapabilities(); + return res.json(caps); } catch (error) { - console.error('Error fetching system status:', error); - res.status(500).json({ - error: 'Failed to fetch system status', - message: error.message + console.error('Error fetching capabilities:', error); + return res.status(500).json({ + error: 'Failed to fetch capabilities', + message: error.message + }); + } +}); + +// Generic proxy to call a node capability directly +app.post('/api/proxy-call', async (req, res) => { + try { + 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' + }); + } + + // Build target URL + let targetPath = uri; + let queryParams = new URLSearchParams(); + let bodyParams = new URLSearchParams(); + + if (Array.isArray(params)) { + for (const p of params) { + const name = p?.name; + const value = p?.value ?? ''; + const location = (p?.location || 'body').toLowerCase(); + + if (!name) continue; + + if (location === 'query') { + queryParams.append(name, String(value)); + } else if (location === 'path') { + // Replace {name} or :name in path + targetPath = targetPath.replace(new RegExp(`[{:]${name}[}]?`, 'g'), encodeURIComponent(String(value))); + } else { + // Default to body + bodyParams.append(name, String(value)); + } + } + } + + const queryString = queryParams.toString(); + const fullUrl = `http://${ip}${targetPath}${queryString ? `?${queryString}` : ''}`; + + // Prepare fetch options + const upperMethod = String(method).toUpperCase(); + const fetchOptions = { method: upperMethod, headers: {} }; + + if (upperMethod !== 'GET') { + // Default to form-encoded body for generic proxy + fetchOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + fetchOptions.body = bodyParams.toString(); + } + + // Execute request + const response = await fetch(fullUrl, fetchOptions); + const respContentType = response.headers.get('content-type') || ''; + + let data; + if (respContentType.includes('application/json')) { + data = await response.json(); + } else { + data = await response.text(); + } + + if (!response.ok) { + return res.status(response.status).json({ + error: 'Upstream request failed', + status: response.status, + statusText: response.statusText, + data + }); + } + + return res.json({ success: true, data }); + } catch (error) { + console.error('Error in /api/proxy-call:', error); + return res.status(500).json({ + error: 'Proxy call failed', + message: error.message }); } }); diff --git a/public/api-client.js b/public/api-client.js index 67a261c..480127d 100644 --- a/public/api-client.js +++ b/public/api-client.js @@ -107,6 +107,48 @@ class ApiClient { } } + async getCapabilities(ip) { + try { + const url = ip + ? `${this.baseUrl}/api/capabilities?ip=${encodeURIComponent(ip)}` + : `${this.baseUrl}/api/capabilities`; + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + throw new Error(`Request failed: ${error.message}`); + } + } + + async callCapability({ ip, method, uri, params }) { + try { + const response = await fetch(`${this.baseUrl}/api/proxy-call`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ip, method, uri, params }) + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } catch (error) { + throw new Error(`Request failed: ${error.message}`); + } + } + async uploadFirmware(file, nodeIp) { try { const formData = new FormData(); diff --git a/public/components.js b/public/components.js index c7d4411..61b8d00 100644 --- a/public/components.js +++ b/public/components.js @@ -751,12 +751,13 @@ class NodeDetailsComponent extends Component { this.subscribeToProperty('isLoading', this.handleLoadingUpdate.bind(this)); this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this)); + this.subscribeToProperty('capabilities', this.handleCapabilitiesUpdate.bind(this)); } // Handle node status update with state preservation handleNodeStatusUpdate(newStatus, previousStatus) { if (newStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(newStatus, this.viewModel.get('tasks')); + this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('capabilities')); } } @@ -764,7 +765,7 @@ class NodeDetailsComponent extends Component { handleTasksUpdate(newTasks, previousTasks) { const nodeStatus = this.viewModel.get('nodeStatus'); if (nodeStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(nodeStatus, newTasks); + this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('capabilities')); } } @@ -793,11 +794,21 @@ class NodeDetailsComponent extends Component { this.updateActiveTab(newTab, previousTab); } + // Handle capabilities update with state preservation + handleCapabilitiesUpdate(newCapabilities, previousCapabilities) { + const nodeStatus = this.viewModel.get('nodeStatus'); + const tasks = this.viewModel.get('tasks'); + if (nodeStatus && !this.viewModel.get('isLoading')) { + this.renderNodeDetails(nodeStatus, tasks, newCapabilities); + } + } + render() { const nodeStatus = this.viewModel.get('nodeStatus'); const tasks = this.viewModel.get('tasks'); const isLoading = this.viewModel.get('isLoading'); const error = this.viewModel.get('error'); + const capabilities = this.viewModel.get('capabilities'); if (isLoading) { this.setHTML('', '
Loading detailed information...
'); @@ -819,10 +830,10 @@ class NodeDetailsComponent extends Component { return; } - this.renderNodeDetails(nodeStatus, tasks); + this.renderNodeDetails(nodeStatus, tasks, capabilities); } - renderNodeDetails(nodeStatus, tasks) { + renderNodeDetails(nodeStatus, tasks, capabilities) { // Always start with 'status' tab, don't restore previous state const activeTab = 'status'; console.log('NodeDetailsComponent: Rendering with activeTab:', activeTab); @@ -832,6 +843,7 @@ class NodeDetailsComponent extends Component {
+
@@ -866,6 +878,10 @@ class NodeDetailsComponent extends Component { ).join('') : '
No API endpoints available
'} +
+ ${this.renderCapabilitiesTab(capabilities)} +
+
${this.renderTasksTab(tasks)}
@@ -881,6 +897,120 @@ class NodeDetailsComponent extends Component { this.setupFirmwareUpload(); } + renderCapabilitiesTab(capabilities) { + if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) { + return ` +
+
🧩 No capabilities reported
+
This node did not return any capabilities
+
+ `; + } + + const items = capabilities.endpoints.map((ep, idx) => { + const formId = `cap-form-${idx}`; + const resultId = `cap-result-${idx}`; + const params = Array.isArray(ep.params) && ep.params.length > 0 + ? `
${ep.params.map((p, pidx) => ` + + `).join('')}
` + : '
No parameters
'; + return ` +
+
+ ${ep.method} + ${ep.uri} + +
+
+ ${params} +
+ +
+ `; + }).join(''); + + // Attach events after render in setupCapabilitiesEvents() + setTimeout(() => this.setupCapabilitiesEvents(), 0); + + return ` +

Node Capabilities

+
${items}
+ `; + } + + setupCapabilitiesEvents() { + const buttons = this.findAllElements('.cap-call-btn'); + buttons.forEach(btn => { + this.addEventListener(btn, 'click', async (e) => { + e.stopPropagation(); + const method = btn.dataset.method || 'GET'; + const uri = btn.dataset.uri || ''; + const formId = btn.dataset.formId; + const resultId = btn.dataset.resultId; + + const formEl = this.findElement(`#${formId}`); + const resultEl = this.findElement(`#${resultId}`); + if (!formEl || !resultEl) return; + + const inputs = Array.from(formEl.querySelectorAll('.param-input')); + const params = inputs.map(input => ({ + name: input.dataset.paramName, + location: input.dataset.paramLocation || 'body', + type: input.dataset.paramType || 'string', + required: input.dataset.paramRequired === '1', + value: input.value + })); + + // Required validation + const missing = params.filter(p => p.required && (!p.value || String(p.value).trim() === '')); + if (missing.length > 0) { + resultEl.style.display = 'block'; + resultEl.innerHTML = ` +
+
❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}
+
+ `; + return; + } + + // Show loading state + resultEl.style.display = 'block'; + resultEl.innerHTML = '
Calling endpoint...
'; + + try { + const response = await this.viewModel.callCapability(method, uri, params); + const pretty = typeof response?.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response?.data ?? ''); + resultEl.innerHTML = ` +
+
✅ Success
+
${this.escapeHtml(pretty)}
+
+ `; + } catch (err) { + resultEl.innerHTML = ` +
+
❌ Error: ${this.escapeHtml(err.message || 'Request failed')}
+
+ `; + } + }); + }); + } + + escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>'); + } + renderTasksTab(tasks) { if (tasks && tasks.length > 0) { const tasksHTML = tasks.map(task => ` diff --git a/public/styles.css b/public/styles.css index 6ef03f6..ad07fee 100644 --- a/public/styles.css +++ b/public/styles.css @@ -209,16 +209,16 @@ p { } .member-card { - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 10px; - padding: 1rem; - transition: all 0.2s ease; - cursor: pointer; - position: relative; - margin-bottom: 0.5rem; - opacity: 1; - transform: translateY(0); + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 1rem; + transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease, opacity 0.2s ease; + cursor: pointer; + position: relative; + margin-bottom: 0.5rem; + opacity: 1; + z-index: 1; } .member-card::before { @@ -240,18 +240,18 @@ p { } .member-card:hover { - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); - transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); + z-index: 2; } .member-card.expanded { - background: rgba(0, 0, 0, 0.6); - border-color: rgba(255, 255, 255, 0.2); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.6); + border-color: rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + z-index: 5; } .member-card.expanded:hover { - transform: scale(1.02) translateY(-2px); } .expand-icon:hover { @@ -307,15 +307,16 @@ p { } .member-details { - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out; - opacity: 0; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out; + opacity: 0; } .member-card.expanded .member-details { - max-height: 2000px; /* Allow full expansion for active tasks while maintaining smooth transition */ - opacity: 1; + max-height: none; /* Remove fixed limit to allow dynamic height */ + opacity: 1; + overflow: visible; } .detail-row { @@ -1689,7 +1690,6 @@ p { } .member-card.expanded .member-details { - max-height: 2000px; /* Allow full expansion for active tasks while maintaining smooth transition */ opacity: 1; } @@ -1725,4 +1725,153 @@ p { visibility: hidden; opacity: 0; transition: opacity 0.2s ease; +} + +/* Capabilities Styles */ +.capabilities-list { + margin-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.capability-item { + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0.75rem; +} + +.capability-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.cap-method { + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.5px; + padding: 0.15rem 0.5rem; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); +} + +.cap-uri { + font-family: 'Courier New', monospace; + font-size: 0.85rem; + opacity: 0.9; + flex: 1; +} + +.cap-call-btn { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.9); + padding: 0.4rem 0.8rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(10px); +} + +.cap-call-btn:hover { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); +} + +.capability-form { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.5rem 0.75rem; + margin-top: 0.5rem; +} + +.capability-param { + display: flex; + flex-direction: column; + gap: 0.25rem; + background: rgba(0, 0, 0, 0.15); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + padding: 0.5rem; +} + +.param-name { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.8); + font-weight: 500; +} + +/* Adjust param-input to support