diff --git a/README.md b/README.md index 780cc50..353d880 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,61 @@ # SPORE UI -## TODO +A modern web interface for monitoring and managing SPORE embedded systems. + +## Features + +- **🌐 Cluster Monitoring**: Real-time view of all cluster members +- **📊 Node Details**: Expandable cards with detailed system information +- **🚀 Modern UI**: Beautiful glassmorphism design with smooth animations +- **⚡ Auto-refresh**: Keeps data current every 30 seconds +- **📱 Responsive**: Works on all devices and screen sizes + +## Project Structure + +``` +spore-ui/ +├── index.js # Express.js backend server +├── api/ +│ └── openapi.yaml # API specification +├── src/ +│ └── client/ # SPORE API client library +│ ├── index.js # Main client class +│ ├── package.json # Client package info +│ ├── README.md # Client documentation +│ └── example.js # Usage examples +├── public/ # Frontend files +│ ├── index.html # Main HTML page +│ ├── styles.css # All CSS styles +│ ├── script.js # All JavaScript functionality +│ └── README.md # Frontend documentation +└── README.md # This file +``` + +## Completed Tasks - [x] bootstrap an express.js app that serves a simple html page - [x] generate js client from api/openapi.yaml - [x] use getClusterStatus client function to get all members and display these members on the html page, use only vanilla JS -- [x] when clicking on one of the members in the UI, it should expand and display all informations from /api/node/status \ No newline at end of file +- [x] when clicking on one of the members in the UI, it should expand and display all informations from /api/node/status +- [x] create separate files for CSS and JS + +## Getting Started + +1. **Install dependencies**: `npm install` +2. **Start the server**: `npm start` +3. **Open in browser**: `http://localhost:3001` + +## API Endpoints + +- **`/`** - Main UI page +- **`/api/cluster/members`** - Get cluster member information +- **`/api/tasks/status`** - Get task status +- **`/api/node/status`** - Get system status +- **`/api/node/status/:ip`** - Get status from specific node + +## Technologies Used + +- **Backend**: Express.js, Node.js +- **Frontend**: Vanilla JavaScript, CSS3, HTML5 +- **API**: SPORE Embedded System API +- **Design**: Glassmorphism, CSS Grid, Flexbox \ No newline at end of file diff --git a/public/README.md b/public/README.md new file mode 100644 index 0000000..f78226c --- /dev/null +++ b/public/README.md @@ -0,0 +1,38 @@ +# SPORE UI Frontend + +This directory contains the frontend files for the SPORE UI application. + +## File Structure + +- **`index.html`** - Main HTML page with minimal markup +- **`styles.css`** - All CSS styles and animations +- **`script.js`** - All JavaScript functionality and API interactions + +## Features + +- **Responsive Design**: Works on all screen sizes +- **Modern UI**: Glassmorphism design with smooth animations +- **Interactive Cards**: Clickable cluster member cards +- **Real-time Data**: Auto-refreshes every 30 seconds +- **Expandable Details**: Click cards to see detailed node information + +## How It Works + +1. **HTML Structure**: Clean, semantic markup +2. **CSS Styling**: Modern design with CSS Grid and Flexbox +3. **JavaScript Logic**: API client, event handling, and DOM manipulation + +## Browser Support + +- Modern browsers with ES6+ support +- CSS Grid and Flexbox support required +- Fetch API support required + +## Development + +To modify the UI: +- **Layout**: Edit `index.html` +- **Styling**: Edit `styles.css` +- **Functionality**: Edit `script.js` + +All files are automatically served by the Express backend. \ No newline at end of file diff --git a/public/index.html b/public/index.html index 59e3d94..cbb9567 100644 --- a/public/index.html +++ b/public/index.html @@ -4,293 +4,7 @@ SPORE UI - +
@@ -312,265 +26,9 @@
Loading cluster members...
- - + \ No newline at end of file diff --git a/public/script.js b/public/script.js new file mode 100644 index 0000000..e8a988d --- /dev/null +++ b/public/script.js @@ -0,0 +1,259 @@ +// Frontend API client - calls our Express backend +class FrontendApiClient { + constructor() { + this.baseUrl = ''; // Same origin as the current page + } + + async getClusterMembers() { + try { + const response = await fetch('/api/cluster/members', { + 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 getNodeStatus(ip) { + try { + // Create a proxy endpoint that forwards the request to the specific node + const response = await fetch(`/api/node/status/${encodeURIComponent(ip)}`, { + 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}`); + } + } +} + +// Global client instance +const client = new FrontendApiClient(); + +// Function to refresh cluster members +async function refreshClusterMembers() { + const container = document.getElementById('cluster-members-container'); + + // Show loading state + container.innerHTML = ` +
+
Loading cluster members...
+
+ `; + + try { + const response = await client.getClusterMembers(); + console.log(response); + displayClusterMembers(response.members); + } catch (error) { + console.error('Failed to fetch cluster members:', error); + container.innerHTML = ` +
+ Error loading cluster members:
+ ${error.message} +
+ `; + } +} + +// Function to load detailed node information +async function loadNodeDetails(card, memberIp) { + console.log('Loading node details for IP:', memberIp); + const memberDetails = card.querySelector('.member-details'); + console.log('Member details element:', memberDetails); + + try { + console.log('Fetching node status...'); + const nodeStatus = await client.getNodeStatus(memberIp); + console.log('Node status received:', nodeStatus); + displayNodeDetails(memberDetails, nodeStatus); + } catch (error) { + console.error('Failed to load node details:', error); + memberDetails.innerHTML = ` +
+ Error loading node details:
+ ${error.message} +
+ `; + } +} + +// Function to display node details +function displayNodeDetails(container, nodeStatus) { + console.log('Displaying node details in container:', container); + console.log('Node status data:', nodeStatus); + + container.innerHTML = ` +
+ Free Heap: + ${Math.round(nodeStatus.freeHeap / 1024)}KB +
+
+ Chip ID: + ${nodeStatus.chipId} +
+
+ SDK Version: + ${nodeStatus.sdkVersion} +
+
+ CPU Frequency: + ${nodeStatus.cpuFreqMHz}MHz +
+
+ Flash Size: + ${Math.round(nodeStatus.flashChipSize / 1024)}KB +
+
+

Available API Endpoints:

+ ${nodeStatus.api ? nodeStatus.api.map(endpoint => + `
${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}
` + ).join('') : '
No API endpoints available
'} +
+ `; + + console.log('Node details HTML set successfully'); +} + +// Function to display cluster members +function displayClusterMembers(members) { + const container = document.getElementById('cluster-members-container'); + + if (!members || members.length === 0) { + container.innerHTML = ` +
+
🌐
+
No cluster members found
+
+ The cluster might be empty or not yet discovered +
+
+ `; + return; + } + + const membersHTML = members.map(member => { + const statusClass = member.status === 'active' ? 'status-online' : 'status-offline'; + const statusText = member.status === 'active' ? 'Online' : 'Offline'; + const statusIcon = member.status === 'active' ? '🟢' : '🔴'; + + return ` +
+
+
+
${member.hostname || 'Unknown Device'}
+
${member.ip || 'No IP'}
+
+ ${statusIcon} ${statusText} +
+
+
▶️
+
+
+
Loading detailed information...
+
+
+ `; + }).join(''); + + container.innerHTML = membersHTML; + + // Add event listeners for expand/collapse + console.log('Setting up event listeners for', members.length, 'member cards'); + + // Small delay to ensure DOM is ready + setTimeout(() => { + document.querySelectorAll('.member-card').forEach((card, index) => { + const expandIcon = card.querySelector('.expand-icon'); + const memberDetails = card.querySelector('.member-details'); + const memberIp = card.dataset.memberIp; + + console.log(`Setting up card ${index} with IP: ${memberIp}`); + + // Make the entire card clickable + card.addEventListener('click', async (e) => { + // Don't trigger if clicking on the expand icon (to avoid double-triggering) + if (e.target === expandIcon) { + return; + } + + console.log('Card clicked for IP:', memberIp); + + const isExpanding = !card.classList.contains('expanded'); + console.log('Is expanding:', isExpanding); + + if (isExpanding) { + // Expanding - fetch detailed status + console.log('Starting to expand...'); + await loadNodeDetails(card, memberIp); + card.classList.add('expanded'); + expandIcon.classList.add('expanded'); + console.log('Card expanded successfully'); + } else { + // Collapsing + console.log('Collapsing...'); + card.classList.remove('expanded'); + expandIcon.classList.remove('expanded'); + console.log('Card collapsed successfully'); + } + }); + + // Keep the expand icon click handler for visual feedback + if (expandIcon) { + expandIcon.addEventListener('click', async (e) => { + e.stopPropagation(); + console.log('Expand icon clicked for IP:', memberIp); + + const isExpanding = !card.classList.contains('expanded'); + console.log('Is expanding:', isExpanding); + + if (isExpanding) { + // Expanding - fetch detailed status + console.log('Starting to expand...'); + await loadNodeDetails(card, memberIp); + card.classList.add('expanded'); + expandIcon.classList.add('expanded'); + console.log('Card expanded successfully'); + } else { + // Collapsing + console.log('Collapsing...'); + card.classList.remove('expanded'); + expandIcon.classList.remove('expanded'); + console.log('Card collapsed successfully'); + } + }); + + console.log(`Event listener added for expand icon on card ${index}`); + } else { + console.error(`No expand icon found for card ${index}`); + } + + console.log(`Event listener added for card ${index}`); + }); + }, 100); +} + +// Load cluster members when page loads +document.addEventListener('DOMContentLoaded', function() { + refreshClusterMembers(); +}); + +// Auto-refresh every 30 seconds +setInterval(refreshClusterMembers, 30000); \ No newline at end of file diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..570fd29 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,281 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 2rem; + color: white; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +.header { + text-align: center; + margin-bottom: 3rem; +} + +h1 { + font-size: 3rem; + margin-bottom: 1rem; + font-weight: 700; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +p { + font-size: 1.2rem; + opacity: 0.9; + margin-bottom: 2rem; +} + +.status { + background: rgba(255, 255, 255, 0.2); + padding: 1rem 2rem; + border-radius: 50px; + font-weight: 500; + display: inline-block; + margin-bottom: 2rem; +} + +.cluster-section { + background: rgba(255, 255, 255, 0.1); + border-radius: 20px; + backdrop-filter: blur(10px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 2rem; + margin-bottom: 2rem; +} + +.cluster-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.cluster-title { + font-size: 1.5rem; + font-weight: 600; +} + +.refresh-btn { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; +} + +.refresh-btn:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +.refresh-btn:active { + transform: translateY(0); +} + +.members-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} + +.member-card { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + padding: 1.5rem; + transition: all 0.3s ease; + cursor: pointer; + position: relative; +} + +.member-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.member-card:hover::before { + opacity: 1; +} + +.member-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); +} + +.member-card.expanded { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.4); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + transform: scale(1.02); +} + +.member-card.expanded:hover { + transform: none; +} + +.member-card.expanded .expand-icon { + transform: rotate(90deg); +} + +.member-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.member-info { + flex: 1; +} + +.expand-icon { + font-size: 1.2rem; + opacity: 0.7; + transition: transform 0.3s ease; +} + +.member-details { + display: none; + opacity: 0; + margin-top: 0; + padding-top: 0; + border-top: 1px solid transparent; + transition: opacity 0.3s ease; +} + +.member-card.expanded .member-details { + display: block; + opacity: 1; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.2); +} + +.detail-row { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.detail-label { + opacity: 0.7; + font-weight: 500; +} + +.detail-value { + font-family: 'Courier New', monospace; + opacity: 0.9; +} + +.api-endpoints { + margin-top: 1rem; +} + +.api-endpoints h4 { + margin-bottom: 0.5rem; + font-size: 0.9rem; + opacity: 0.8; +} + +.endpoint-item { + background: rgba(255, 255, 255, 0.1); + padding: 0.5rem; + border-radius: 6px; + margin-bottom: 0.5rem; + font-size: 0.8rem; + font-family: 'Courier New', monospace; +} + +.loading-details { + text-align: center; + padding: 1rem; + opacity: 0.7; + font-size: 0.9rem; +} + +.member-name { + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: #fff; +} + +.member-ip { + font-size: 0.9rem; + opacity: 0.8; + margin-bottom: 0.5rem; + font-family: 'Courier New', monospace; +} + +.member-status { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.status-online { + background: rgba(76, 175, 80, 0.3); + color: #4caf50; + border: 1px solid rgba(76, 175, 80, 0.5); +} + +.status-offline { + background: rgba(244, 67, 54, 0.3); + color: #f44336; + border: 1px solid rgba(244, 67, 54, 0.5); +} + +.loading { + text-align: center; + padding: 2rem; + opacity: 0.7; +} + +.error { + background: rgba(244, 67, 54, 0.2); + border: 1px solid rgba(244, 67, 54, 0.3); + color: #ffcdd2; + padding: 1rem; + border-radius: 8px; + margin-top: 1rem; +} + +.empty-state { + text-align: center; + padding: 2rem; + opacity: 0.7; +} + +.empty-state-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.5; +} \ No newline at end of file