feature/capabilities (#2)

Reviewed-on: #2
This commit is contained in:
2025-08-28 11:17:37 +02:00
parent 6c58e479af
commit bb46e5d412
6 changed files with 504 additions and 35 deletions

View File

@@ -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('', '<div class="loading-details">Loading detailed information...</div>');
@@ -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 {
<div class="tabs-header">
<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 === 'capabilities' ? 'active' : ''}" data-tab="capabilities">Capabilities</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>
</div>
@@ -866,6 +878,10 @@ class NodeDetailsComponent extends Component {
).join('') : '<div class="endpoint-item">No API endpoints available</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">
${this.renderTasksTab(tasks)}
</div>
@@ -881,6 +897,120 @@ class NodeDetailsComponent extends Component {
this.setupFirmwareUpload();
}
renderCapabilitiesTab(capabilities) {
if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) {
return `
<div class="no-capabilities">
<div>🧩 No capabilities reported</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">This node did not return any capabilities</div>
</div>
`;
}
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
? `<div class="capability-params">${ep.params.map((p, pidx) => `
<label class="capability-param" for="${formId}-field-${pidx}">
<span class="param-name">${p.name}${p.required ? ' *' : ''}</span>
${ (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>`
: `<input 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" type="text" placeholder="${p.location || 'body'}${p.type || 'string'}" value="${(Array.isArray(p.values) && p.values.length === 1) ? p.values[0] : ''}">`
}
</label>
`).join('')}</div>`
: '<div class="capability-params none">No parameters</div>';
return `
<div class="capability-item" data-cap-index="${idx}">
<div class="capability-header">
<span class="cap-method">${ep.method}</span>
<span class="cap-uri">${ep.uri}</span>
<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>
<form id="${formId}" class="capability-form" onsubmit="return false;">
${params}
</form>
<div id="${resultId}" class="capability-result" style="display:none;"></div>
</div>
`;
}).join('');
// Attach events after render in setupCapabilitiesEvents()
setTimeout(() => this.setupCapabilitiesEvents(), 0);
return `
<h4>Node Capabilities</h4>
<div class="capabilities-list">${items}</div>
`;
}
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 = `
<div class="cap-call-error">
<div>❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}</div>
</div>
`;
return;
}
// Show loading state
resultEl.style.display = 'block';
resultEl.innerHTML = '<div class="loading">Calling endpoint...</div>';
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 = `
<div class="cap-call-success">
<div>✅ Success</div>
<pre class="cap-result-pre">${this.escapeHtml(pretty)}</pre>
</div>
`;
} catch (err) {
resultEl.innerHTML = `
<div class="cap-call-error">
<div>❌ Error: ${this.escapeHtml(err.message || 'Request failed')}</div>
</div>
`;
}
});
});
}
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
renderTasksTab(tasks) {
if (tasks && tasks.length > 0) {
const tasksHTML = tasks.map(task => `