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 `
+
+ `;
+ }).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