feature/capabilities #2
132
index.js
132
index.js
@@ -394,7 +394,45 @@ app.get('/api/tasks/status', async (req, res) => {
|
|||||||
|
|
||||||
// API endpoint to get system status
|
// API endpoint to get system status
|
||||||
app.get('/api/node/status', async (req, res) => {
|
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 {
|
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) {
|
if (!sporeClient) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
error: 'Service unavailable',
|
error: 'Service unavailable',
|
||||||
@@ -402,14 +440,94 @@ app.get('/api/node/status', async (req, res) => {
|
|||||||
discoveredNodes: Array.from(discoveredNodes.keys())
|
discoveredNodes: Array.from(discoveredNodes.keys())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemStatus = await sporeClient.getSystemStatus();
|
const caps = await sporeClient.getCapabilities();
|
||||||
res.json(systemStatus);
|
return res.json(caps);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching system status:', error);
|
console.error('Error fetching capabilities:', error);
|
||||||
res.status(500).json({
|
return res.status(500).json({
|
||||||
error: 'Failed to fetch system status',
|
error: 'Failed to fetch capabilities',
|
||||||
message: error.message
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
async uploadFirmware(file, nodeIp) {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|||||||
@@ -751,12 +751,13 @@ 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('capabilities', this.handleCapabilitiesUpdate.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle node status update with state preservation
|
// 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.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('capabilities'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,7 +765,7 @@ class NodeDetailsComponent extends Component {
|
|||||||
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.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('capabilities'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,11 +794,21 @@ class NodeDetailsComponent extends Component {
|
|||||||
this.updateActiveTab(newTab, previousTab);
|
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() {
|
render() {
|
||||||
const nodeStatus = this.viewModel.get('nodeStatus');
|
const nodeStatus = this.viewModel.get('nodeStatus');
|
||||||
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 capabilities = this.viewModel.get('capabilities');
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
this.setHTML('', '<div class="loading-details">Loading detailed information...</div>');
|
this.setHTML('', '<div class="loading-details">Loading detailed information...</div>');
|
||||||
@@ -819,10 +830,10 @@ class NodeDetailsComponent extends Component {
|
|||||||
return;
|
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
|
// Always start with 'status' tab, don't restore previous state
|
||||||
const activeTab = 'status';
|
const activeTab = 'status';
|
||||||
console.log('NodeDetailsComponent: Rendering with activeTab:', activeTab);
|
console.log('NodeDetailsComponent: Rendering with activeTab:', activeTab);
|
||||||
@@ -832,6 +843,7 @@ class NodeDetailsComponent extends Component {
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -866,6 +878,10 @@ class NodeDetailsComponent extends Component {
|
|||||||
).join('') : '<div class="endpoint-item">No API endpoints available</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)}
|
||||||
</div>
|
</div>
|
||||||
@@ -881,6 +897,120 @@ class NodeDetailsComponent extends Component {
|
|||||||
this.setupFirmwareUpload();
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
renderTasksTab(tasks) {
|
renderTasksTab(tasks) {
|
||||||
if (tasks && tasks.length > 0) {
|
if (tasks && tasks.length > 0) {
|
||||||
const tasksHTML = tasks.map(task => `
|
const tasksHTML = tasks.map(task => `
|
||||||
|
|||||||
@@ -209,16 +209,16 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-card {
|
.member-card {
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
transition: all 0.2s ease;
|
transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease, opacity 0.2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-card::before {
|
.member-card::before {
|
||||||
@@ -240,18 +240,18 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-card:hover {
|
.member-card:hover {
|
||||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
|
||||||
transform: translateY(-2px);
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-card.expanded {
|
.member-card.expanded {
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-card.expanded:hover {
|
.member-card.expanded:hover {
|
||||||
transform: scale(1.02) translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-icon:hover {
|
.expand-icon:hover {
|
||||||
@@ -307,15 +307,16 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-details {
|
.member-details {
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out;
|
transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-card.expanded .member-details {
|
.member-card.expanded .member-details {
|
||||||
max-height: 2000px; /* Allow full expansion for active tasks while maintaining smooth transition */
|
max-height: none; /* Remove fixed limit to allow dynamic height */
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row {
|
.detail-row {
|
||||||
@@ -1689,7 +1690,6 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-card.expanded .member-details {
|
.member-card.expanded .member-details {
|
||||||
max-height: 2000px; /* Allow full expansion for active tasks while maintaining smooth transition */
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1725,4 +1725,153 @@ p {
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
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 <select> as well */
|
||||||
|
.param-input {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ecf0f1;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-input option {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-input:focus {
|
||||||
|
border-color: rgba(139, 92, 246, 0.5);
|
||||||
|
background: rgba(139, 92, 246, 0.08);
|
||||||
|
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-params.none {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.25rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-result {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-call-success {
|
||||||
|
color: #4caf50;
|
||||||
|
background: rgba(76, 175, 80, 0.1);
|
||||||
|
border: 1px solid rgba(76, 175, 80, 0.2);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-call-error {
|
||||||
|
color: #f44336;
|
||||||
|
background: rgba(244, 67, 54, 0.1);
|
||||||
|
border: 1px solid rgba(244, 67, 54, 0.2);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-result-pre {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
@@ -211,7 +211,8 @@ class NodeDetailsViewModel extends ViewModel {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
activeTab: 'status',
|
activeTab: 'status',
|
||||||
nodeIp: null
|
nodeIp: null,
|
||||||
|
capabilities: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +239,9 @@ class NodeDetailsViewModel extends ViewModel {
|
|||||||
// Load tasks data
|
// Load tasks data
|
||||||
await this.loadTasksData();
|
await this.loadTasksData();
|
||||||
|
|
||||||
|
// Load capabilities data
|
||||||
|
await this.loadCapabilitiesData();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load node details:', error);
|
console.error('Failed to load node details:', error);
|
||||||
this.set('error', error.message);
|
this.set('error', error.message);
|
||||||
@@ -258,6 +262,24 @@ class NodeDetailsViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load capabilities data with state preservation
|
||||||
|
async loadCapabilitiesData() {
|
||||||
|
try {
|
||||||
|
const ip = this.get('nodeIp');
|
||||||
|
const response = await window.apiClient.getCapabilities(ip);
|
||||||
|
this.set('capabilities', response || null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load capabilities:', error);
|
||||||
|
this.set('capabilities', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke a capability against this node
|
||||||
|
async callCapability(method, uri, params) {
|
||||||
|
const ip = this.get('nodeIp');
|
||||||
|
return window.apiClient.callCapability({ ip, method, uri, params });
|
||||||
|
}
|
||||||
|
|
||||||
// Set active tab with state persistence
|
// Set active tab with state persistence
|
||||||
setActiveTab(tabName) {
|
setActiveTab(tabName) {
|
||||||
console.log('NodeDetailsViewModel: Setting activeTab to:', tabName);
|
console.log('NodeDetailsViewModel: Setting activeTab to:', tabName);
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ class SporeApiClient {
|
|||||||
return this.request('GET', '/api/node/status');
|
return this.request('GET', '/api/node/status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node capabilities
|
||||||
|
* @returns {Promise<Object>} Capabilities response
|
||||||
|
*/
|
||||||
|
async getCapabilities() {
|
||||||
|
return this.request('GET', '/api/capabilities');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cluster discovery information
|
* Get cluster discovery information
|
||||||
* @returns {Promise<Object>} Cluster discovery response
|
* @returns {Promise<Object>} Cluster discovery response
|
||||||
|
|||||||
Reference in New Issue
Block a user