feature/light-theme #4

Merged
master merged 10 commits from feature/light-theme into main 2025-09-13 19:30:02 +02:00
6 changed files with 98 additions and 104 deletions
Showing only changes of commit d0557a56a2 - Show all commits

View File

@@ -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=)
app.get('/api/node/capabilities', async (req, res) => {
app.get('/api/node/endpoints', async (req, res) => {
try {
const { ip } = req.query;
@@ -476,9 +476,9 @@ app.get('/api/node/capabilities', async (req, res) => {
const caps = await nodeClient.getCapabilities();
return res.json(caps);
} catch (innerError) {
console.error('Error fetching capabilities from specific node:', innerError);
console.error('Error fetching endpoints from specific node:', innerError);
return res.status(500).json({
error: 'Failed to fetch capabilities from node',
error: 'Failed to fetch endpoints from node',
message: innerError.message
});
}

View File

@@ -80,11 +80,11 @@ class ApiClient {
return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
}
async getCapabilities(ip) {
return this.request('/api/node/capabilities', { method: 'GET', query: ip ? { ip } : undefined });
async getEndpoints(ip) {
return this.request('/api/node/endpoints', { method: 'GET', query: ip ? { ip } : undefined });
}
async callCapability({ ip, method, uri, params }) {
async callEndpoint({ ip, method, uri, params }) {
return this.request('/api/proxy-call', {
method: 'POST',
body: { ip, method, uri, params }

View File

@@ -10,13 +10,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));
this.subscribeToProperty('endpoints', this.handleEndpointsUpdate.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.viewModel.get('capabilities'));
this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('endpoints'));
}
}
@@ -24,7 +24,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.viewModel.get('capabilities'));
this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('endpoints'));
}
}
@@ -48,12 +48,12 @@ class NodeDetailsComponent extends Component {
this.updateActiveTab(newTab, previousTab);
}
// Handle capabilities update with state preservation
handleCapabilitiesUpdate(newCapabilities, previousCapabilities) {
// Handle endpoints update with state preservation
handleEndpointsUpdate(newEndpoints, previousEndpoints) {
const nodeStatus = this.viewModel.get('nodeStatus');
const tasks = this.viewModel.get('tasks');
if (nodeStatus && !this.viewModel.get('isLoading')) {
this.renderNodeDetails(nodeStatus, tasks, newCapabilities);
this.renderNodeDetails(nodeStatus, tasks, newEndpoints);
}
}
@@ -62,7 +62,7 @@ class NodeDetailsComponent extends Component {
const tasks = this.viewModel.get('tasks');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
const capabilities = this.viewModel.get('capabilities');
const endpoints = this.viewModel.get('endpoints');
if (isLoading) {
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
@@ -79,10 +79,10 @@ class NodeDetailsComponent extends Component {
return;
}
this.renderNodeDetails(nodeStatus, tasks, capabilities);
this.renderNodeDetails(nodeStatus, tasks, endpoints);
}
renderNodeDetails(nodeStatus, tasks, capabilities) {
renderNodeDetails(nodeStatus, tasks, endpoints) {
// 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';
logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab);
@@ -92,7 +92,6 @@ 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>
@@ -121,14 +120,9 @@ class NodeDetailsComponent extends Component {
</div>
<div class="tab-content ${activeTab === 'endpoints' ? 'active' : ''}" id="endpoints-tab">
${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>'}
${this.renderEndpointsTab(endpoints)}
</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)}
@@ -150,18 +144,18 @@ class NodeDetailsComponent extends Component {
this.setupFirmwareUpload();
}
renderCapabilitiesTab(capabilities) {
if (!capabilities || !Array.isArray(capabilities.endpoints) || capabilities.endpoints.length === 0) {
renderEndpointsTab(endpoints) {
if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.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 class="no-endpoints">
<div>🧩 No endpoints reported</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">This node did not return any endpoints</div>
</div>
`;
}
// Sort endpoints by URI (name), then by method for stable ordering
const endpoints = [...capabilities.endpoints].sort((a, b) => {
const endpointsList = [...endpoints.endpoints].sort((a, b) => {
const aUri = String(a.uri || '').toLowerCase();
const bUri = String(b.uri || '').toLowerCase();
if (aUri < bUri) return -1;
@@ -171,22 +165,22 @@ class NodeDetailsComponent extends Component {
return aMethod.localeCompare(bMethod);
});
const total = endpoints.length;
const total = endpointsList.length;
// Preserve selection based on a stable key of method+uri if available
const selectedKey = String(this.getUIState('capSelectedKey') || '');
let selectedIndex = endpoints.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey);
const selectedKey = String(this.getUIState('endpointSelectedKey') || '');
let selectedIndex = endpointsList.findIndex(ep => `${ep.method} ${ep.uri}` === selectedKey);
if (selectedIndex === -1) {
selectedIndex = Number(this.getUIState('capSelectedIndex'));
selectedIndex = Number(this.getUIState('endpointSelectedIndex'));
if (Number.isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= total) {
selectedIndex = 0;
}
}
// Compute padding for aligned display in dropdown
const maxMethodLen = endpoints.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0);
const maxMethodLen = endpointsList.reduce((m, ep) => Math.max(m, String(ep.method || '').length), 0);
const selectorOptions = endpoints.map((ep, idx) => {
const selectorOptions = endpointsList.map((ep, idx) => {
const method = String(ep.method || '');
const uri = String(ep.uri || '');
const padCount = Math.max(1, (maxMethodLen - method.length) + 2);
@@ -194,12 +188,12 @@ class NodeDetailsComponent extends Component {
return `<option value="${idx}" data-method="${method}" data-uri="${uri}" ${idx === selectedIndex ? 'selected' : ''}>${method}${spacer}${uri}</option>`;
}).join('');
const items = endpoints.map((ep, idx) => {
const formId = `cap-form-${idx}`;
const resultId = `cap-result-${idx}`;
const items = endpointsList.map((ep, idx) => {
const formId = `endpoint-form-${idx}`;
const resultId = `endpoint-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}">
? `<div class="endpoint-params">${ep.params.map((p, pidx) => `
<label class="endpoint-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>`
@@ -207,54 +201,54 @@ class NodeDetailsComponent extends Component {
}
</label>
`).join('')}</div>`
: '<div class="capability-params none">No parameters</div>';
: '<div class="endpoint-params none">No parameters</div>';
return `
<div class="capability-item" data-cap-index="${idx}" style="display:${idx === selectedIndex ? '' : 'none'};">
<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 class="endpoint-item" data-endpoint-index="${idx}" style="display:${idx === selectedIndex ? '' : 'none'};">
<div class="endpoint-header">
<span class="endpoint-method">${ep.method}</span>
<span class="endpoint-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>
</div>
<form id="${formId}" class="capability-form" onsubmit="return false;">
<form id="${formId}" class="endpoint-form" onsubmit="return false;">
${params}
</form>
<div id="${resultId}" class="capability-result" style="display:none;"></div>
<div id="${resultId}" class="endpoint-result" style="display:none;"></div>
</div>
`;
}).join('');
// Attach events after render in setupCapabilitiesEvents()
setTimeout(() => this.setupCapabilitiesEvents(), 0);
// Attach events after render in setupEndpointsEvents()
setTimeout(() => this.setupEndpointsEvents(), 0);
return `
<div class="capability-selector">
<label class="param-name" for="capability-select">Capability</label>
<select id="capability-select" class="param-input" style="font-family: monospace;">${selectorOptions}</select>
<div class="endpoint-selector">
<label class="param-name" for="endpoint-select">Endpoint</label>
<select id="endpoint-select" class="param-input" style="font-family: monospace;">${selectorOptions}</select>
</div>
<div class="capabilities-list">${items}</div>
<div class="endpoints-list">${items}</div>
`;
}
setupCapabilitiesEvents() {
const selector = this.findElement('#capability-select');
setupEndpointsEvents() {
const selector = this.findElement('#endpoint-select');
if (selector) {
this.addEventListener(selector, 'change', (e) => {
const selected = Number(e.target.value);
const items = Array.from(this.findAllElements('.capability-item'));
const items = Array.from(this.findAllElements('.endpoint-item'));
items.forEach((el, idx) => {
el.style.display = (idx === selected) ? '' : 'none';
});
this.setUIState('capSelectedIndex', selected);
this.setUIState('endpointSelectedIndex', selected);
const opt = e.target.selectedOptions && e.target.selectedOptions[0];
if (opt) {
const method = opt.dataset.method || '';
const uri = opt.dataset.uri || '';
this.setUIState('capSelectedKey', `${method} ${uri}`);
this.setUIState('endpointSelectedKey', `${method} ${uri}`);
}
});
}
const buttons = this.findAllElements('.cap-call-btn');
const buttons = this.findAllElements('.endpoint-call-btn');
buttons.forEach(btn => {
this.addEventListener(btn, 'click', async (e) => {
e.stopPropagation();
@@ -281,7 +275,7 @@ class NodeDetailsComponent extends Component {
if (missing.length > 0) {
resultEl.style.display = 'block';
resultEl.innerHTML = `
<div class="cap-call-error">
<div class="endpoint-call-error">
<div>❌ Missing required fields: ${missing.map(m => this.escapeHtml(m.name)).join(', ')}</div>
</div>
`;
@@ -293,17 +287,17 @@ class NodeDetailsComponent extends Component {
resultEl.innerHTML = '<div class="loading">Calling endpoint...</div>';
try {
const response = await this.viewModel.callCapability(method, uri, params);
const response = await this.viewModel.callEndpoint(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 class="endpoint-call-success">
<div>✅ Success</div>
<pre class="cap-result-pre">${this.escapeHtml(pretty)}</pre>
<pre class="endpoint-result-pre">${this.escapeHtml(pretty)}</pre>
</div>
`;
} catch (err) {
resultEl.innerHTML = `
<div class="cap-call-error">
<div class="endpoint-call-error">
<div>❌ Error: ${this.escapeHtml(err.message || 'Request failed')}</div>
</div>
`;

View File

@@ -219,7 +219,7 @@ class NodeDetailsViewModel extends ViewModel {
error: null,
activeTab: 'status',
nodeIp: null,
capabilities: null,
endpoints: null,
tasksSummary: null
});
}
@@ -247,8 +247,8 @@ class NodeDetailsViewModel extends ViewModel {
// Load tasks data
await this.loadTasksData();
// Load capabilities data
await this.loadCapabilitiesData();
// Load endpoints data
await this.loadEndpointsData();
} catch (error) {
console.error('Failed to load node details:', error);
@@ -272,22 +272,22 @@ class NodeDetailsViewModel extends ViewModel {
}
}
// Load capabilities data with state preservation
async loadCapabilitiesData() {
// Load endpoints data with state preservation
async loadEndpointsData() {
try {
const ip = this.get('nodeIp');
const response = await window.apiClient.getCapabilities(ip);
this.set('capabilities', response || null);
const response = await window.apiClient.getEndpoints(ip);
this.set('endpoints', response || null);
} catch (error) {
console.error('Failed to load capabilities:', error);
this.set('capabilities', null);
console.error('Failed to load endpoints:', error);
this.set('endpoints', null);
}
}
// Invoke a capability against this node
async callCapability(method, uri, params) {
// Invoke an endpoint against this node
async callEndpoint(method, uri, params) {
const ip = this.get('nodeIp');
return window.apiClient.callCapability({ ip, method, uri, params });
return window.apiClient.callEndpoint({ ip, method, uri, params });
}
// Set active tab with state persistence

View File

@@ -2003,21 +2003,21 @@ p {
/* Capabilities Styles */
.capabilities-list {
.endpoints-list {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Custom dropdown wrapper and arrow for capability selector */
.capability-selector {
/* Custom dropdown wrapper and arrow for endpoint selector */
.endpoint-selector {
display: flex;
align-items: center;
gap: 0.5rem;
}
#capability-select {
#endpoint-select {
padding-right: 2rem;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="%23ecf0f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>');
background-repeat: no-repeat;
@@ -2025,21 +2025,21 @@ p {
background-size: 12px 12px;
}
.capability-item {
.endpoint-item {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 0.75rem;
}
.capability-header {
.endpoint-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.cap-method {
.endpoint-method {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.5px;
@@ -2050,14 +2050,14 @@ p {
color: var(--text-secondary);
}
.cap-uri {
.endpoint-uri {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
opacity: 1;
flex: 1;
}
.cap-call-btn {
.endpoint-call-btn {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
border: 1px solid var(--border-secondary);
color: var(--text-secondary);
@@ -2070,21 +2070,21 @@ p {
backdrop-filter: var(--backdrop-blur);
}
.cap-call-btn:hover {
.endpoint-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 {
.endpoint-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem 0.75rem;
margin-top: 0.5rem;
}
.capability-param {
.endpoint-param {
display: flex;
flex-direction: column;
gap: 0.25rem;
@@ -2124,13 +2124,13 @@ p {
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.15);
}
.capability-params.none {
.endpoint-params.none {
opacity: 1;
font-size: 0.85rem;
padding: 0.25rem 0.25rem 0 0.25rem;
}
.capability-result {
.endpoint-result {
margin-top: 0.5rem;
border-radius: 8px;
border: 1px solid var(--border-primary);
@@ -2138,7 +2138,7 @@ p {
padding: 0.75rem;
}
.cap-call-success {
.endpoint-call-success {
color: var(--accent-success);
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.2);
@@ -2146,7 +2146,7 @@ p {
border-radius: 6px;
}
.cap-call-error {
.endpoint-call-error {
color: #f44336;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.2);
@@ -2154,7 +2154,7 @@ p {
border-radius: 6px;
}
.cap-result-pre {
.endpoint-result-pre {
margin-top: 0.5rem;
white-space: pre-wrap;
word-break: break-word;
@@ -2321,11 +2321,11 @@ p {
/* Responsive Capability Selector */
@media (max-width: 768px) {
.capability-selector {
.endpoint-selector {
flex-wrap: wrap;
align-items: stretch;
}
#capability-select {
#endpoint-select {
flex: 1 1 100%;
width: 100%;
max-width: 100%;
@@ -2334,23 +2334,23 @@ p {
}
@media (max-width: 480px) {
#capability-select {
#endpoint-select {
padding-right: 1.75rem;
background-position: right 0.5rem center;
background-size: 10px 10px;
}
}
/* Capability header mobile wrapping */
/* Endpoint header mobile wrapping */
@media (max-width: 768px) {
.capability-header {
.endpoint-header {
flex-wrap: wrap;
}
.cap-uri {
.endpoint-uri {
flex: 1 1 100%;
min-width: 0;
}
.cap-call-btn {
.endpoint-call-btn {
flex: 1 1 100%;
width: 100%;
justify-content: center;
@@ -2359,7 +2359,7 @@ p {
}
@media (max-width: 480px) {
.cap-call-btn {
.endpoint-call-btn {
padding: 0.35rem 0.75rem;
}
}

View File

@@ -85,11 +85,11 @@ class SporeApiClient {
}
/**
* Get node capabilities
* @returns {Promise<Object>} Capabilities response
* Get node endpoints
* @returns {Promise<Object>} endpoints response
*/
async getCapabilities() {
return this.request('GET', '/api/node/capabilities');
return this.request('GET', '/api/node/endpoints');
}
/**