Compare commits

...

11 Commits

Author SHA1 Message Date
8476c76637 Merge pull request 'feature/light-theme' (#4) from feature/light-theme into main
Reviewed-on: #4
2025-09-13 19:30:01 +02:00
d0557a56a2 refactor: remove capabilities in favor of endpoints 2025-09-13 19:15:38 +02:00
3055c2cb0e fix: graph styling 2025-09-13 13:50:46 +02:00
0b341ad6dd fix: capabilities endpoint 2025-09-13 13:46:30 +02:00
103aeabea7 Fix mobile navigation light theme
- Replace hardcoded dark colors in main.css with CSS variables
- Add comprehensive light theme overrides for mobile navigation
- Implement cool glass-morphism styling for mobile expanded state
- Ensure proper contrast and readability on all screen sizes
- Add cache-busting parameter to theme.css for browser refresh

Mobile navigation now properly displays light theme when expanded.
2025-09-06 14:02:48 +02:00
2cf3fb2852 feat: remove theme switcher text 2025-09-04 21:19:37 +02:00
7898a1d461 Implement cool & minimal light theme
- Replace warm & cozy theme with cool & minimal aesthetic
- Use clean slate grays (#f8fafc, #f1f5f9, #e2e8f0) for backgrounds
- Apply sophisticated blue accents (#3b82f6, #1d4ed8) for interactive elements
- Maintain high contrast text colors for excellent readability
- Preserve glass-morphism effects with cool-tinted shadows
- Create professional, corporate-friendly color scheme
- Ensure minimal visual noise with subtle borders and clean typography
2025-09-04 21:15:44 +02:00
2cf50486a7 refactor: Simplify navigation and background gradient
🎨 Background Gradient:
- Reduced from 5 to 3 Nord Frost colors
- Cleaner, more minimal gradient: #8fbcbb → #88c0d0 → #81a1c1
- Less visual noise in background

🧭 Navigation Improvements:
- Only active tab has background and border
- Inactive tabs are completely transparent
- Subtle hover effects for better UX
- Cleaner visual hierarchy

 Benefits:
- More minimal and professional appearance
- Better focus on active content
- Reduced visual complexity
- Consistent with modern design principles

Files modified:
- public/styles/theme.css - Simplified gradient and navigation styling
2025-09-04 21:08:24 +02:00
4498da72fa feat: Implement Nord Frost glass-morphism theme
🎨 Color Palette:
- Nord Frost gradient: #8fbcbb → #88c0d0 → #81a1c1 → #5e81ac
- Cool, sophisticated color scheme
- Nord text colors for optimal readability

 Glass-Morphism Effects:
- Maintained beautiful transparency effects
- Enhanced backdrop blur (20-25px)
- Nord-tinted shadows throughout
- Layered depth with opacity variations

🔧 UI Improvements:
- Removed member card hover effects for cleaner look
- Only active navigation tabs have borders
- Complete member overlay styling
- Consistent glass effects across all components

🎯 Visual Benefits:
- Cool, minimal aesthetic
- Professional appearance
- Easy on the eyes
- Modern glass-morphism design

Files modified:
- public/styles/theme.css - Complete Nord Frost theme implementation
2025-09-04 21:02:44 +02:00
1564816dc6 chore: Remove temporary test file 2025-09-04 20:48:32 +02:00
4b1011ce5e feat: Add comprehensive light theme and theme switcher
 Features:
- Complete light theme with improved color palette
- Theme switcher in header with sun/moon icons
- Theme persistence using localStorage
- Smooth transitions between themes

🎨 Theme Improvements:
- Softer, easier-on-eyes light theme colors
- Fixed text contrast issues across all components
- Enhanced member card and tab text readability
- Fixed hover effects that made text disappear
- Improved member overlay header and body styling

🔧 Technical Implementation:
- CSS variables system for easy theme management
- JavaScript ThemeManager class for theme switching
- Responsive design for mobile devices
- Comprehensive hover state fixes
- Z-index solutions for text visibility

📱 Components Fixed:
- Member cards (hostname, IP, latency, details)
- Navigation tabs and content
- Task summaries and progress items
- Capability forms and API endpoints
- Member overlay popup
- Form inputs and dropdowns
- Status indicators and label chips

🎯 Accessibility:
- WCAG AA contrast compliance
- Proper color hierarchy
- Clear visual feedback
- Mobile-responsive design

Files added:
- public/styles/theme.css - Theme system and variables
- public/scripts/theme-manager.js - Theme management logic
- THEME_README.md - Comprehensive documentation
- THEME_IMPROVEMENTS.md - Improvement details

Files modified:
- public/index.html - Added theme switcher and script includes
- public/styles/main.css - Updated to use CSS variables
2025-09-04 20:48:29 +02:00
13 changed files with 5250 additions and 315 deletions

67
THEME_IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,67 @@
# Theme Improvements - Better Contrast and Readability
## Issues Fixed
### 1. **Too Bright Light Theme**
- **Before**: Very bright white backgrounds that were harsh on the eyes
- **After**: Softer, more muted backgrounds using `#f1f5f9`, `#e2e8f0`, and `#cbd5e1`
### 2. **Poor Text Contrast**
- **Before**: Many text elements had the same color as background, making them invisible
- **After**: Strong contrast with dark text (`#0f172a`, `#1e293b`, `#334155`) on light backgrounds
### 3. **Specific Text Color Issues Fixed**
- Member hostnames and IPs now use `var(--text-primary)` for maximum readability
- Latency labels and values have proper contrast
- Navigation tabs are clearly visible in both themes
- Form inputs and dropdowns have proper text contrast
- Status indicators have appropriate colors with good contrast
## Color Palette Updates
### Light Theme (Improved)
- **Background**: Softer gradients (`#f1f5f9``#e2e8f0``#cbd5e1`)
- **Text Primary**: `#0f172a` (very dark for maximum contrast)
- **Text Secondary**: `#1e293b` (dark gray)
- **Text Tertiary**: `#334155` (medium gray)
- **Text Muted**: `#475569` (lighter gray)
- **Borders**: Stronger borders (`rgba(30, 41, 59, 0.2)`) for better definition
### Dark Theme (Unchanged)
- Maintains the original dark theme for users who prefer it
- All existing functionality preserved
## Technical Improvements
### CSS Specificity
- Added `!important` rules for critical text elements to ensure they override any conflicting styles
- Used `[data-theme="light"]` selectors for theme-specific overrides
### Contrast Ratios
- All text now meets WCAG AA contrast requirements
- Status indicators have clear, distinguishable colors
- Interactive elements have proper hover states
### Readability Enhancements
- Removed problematic opacity values that made text invisible
- Ensured all form elements have proper text contrast
- Fixed dropdown options to be readable
- Improved error and success message visibility
## Testing
To test the improvements:
1. Open `http://localhost:8080` in your browser
2. Click the theme toggle to switch to light theme
3. Verify all text is clearly readable
4. Check that the background is not too bright
5. Test form interactions and dropdowns
6. Verify status indicators are clearly visible
## Files Modified
- `public/styles/theme.css` - Updated color palette and added contrast fixes
- `public/styles/main.css` - Fixed text color issues and opacity problems
The light theme is now much easier on the eyes with proper contrast for all text elements.

144
THEME_README.md Normal file
View File

@@ -0,0 +1,144 @@
# SPORE UI Theme System
This document describes the theme system implementation for SPORE UI, which provides both dark and light themes with a user-friendly theme switcher.
## Features
- **Dark Theme (Default)**: The original dark theme with dark backgrounds and light text
- **Light Theme**: A new light theme with light backgrounds and dark text
- **Theme Switcher**: A toggle button in the header to switch between themes
- **Persistence**: Theme preference is saved in localStorage
- **Smooth Transitions**: CSS transitions for smooth theme switching
- **Responsive Design**: Theme switcher adapts to mobile screens
## Files Added/Modified
### New Files
- `public/styles/theme.css` - CSS variables and theme definitions
- `public/scripts/theme-manager.js` - JavaScript theme management
- `THEME_README.md` - This documentation
### Modified Files
- `public/index.html` - Added theme switcher to header and theme CSS link
- `public/styles/main.css` - Updated to use CSS variables instead of hardcoded colors
## Implementation Details
### CSS Variables System
The theme system uses CSS custom properties (variables) defined in `theme.css`:
```css
:root {
/* Dark theme variables */
--bg-primary: linear-gradient(135deg, #2c3e50 0%, #34495e 50%, #1a252f 100%);
--text-primary: #ecf0f1;
--border-primary: rgba(255, 255, 255, 0.1);
/* ... more variables */
}
[data-theme="light"] {
/* Light theme overrides */
--bg-primary: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%);
--text-primary: #1e293b;
--border-primary: rgba(30, 41, 59, 0.1);
/* ... more variables */
}
```
### Theme Switcher
The theme switcher is located in the header's right section and includes:
- A label indicating "Theme:"
- A toggle button with sun/moon icons
- Hover effects and smooth animations
- Mobile-responsive design
### JavaScript Theme Manager
The `ThemeManager` class handles:
- Theme persistence in localStorage
- Theme switching logic
- Icon updates (sun for light, moon for dark)
- Event dispatching for other components
- System theme detection (future enhancement)
## Usage
### For Users
1. Click the theme toggle button in the header
2. The theme will switch immediately with smooth transitions
3. Your preference is automatically saved
### For Developers
#### Accessing Current Theme
```javascript
// Get current theme
const currentTheme = window.themeManager.getCurrentTheme();
// Listen for theme changes
window.addEventListener('themeChanged', (event) => {
console.log('Theme changed to:', event.detail.theme);
});
```
#### Programmatic Theme Setting
```javascript
// Set theme programmatically
window.themeManager.setTheme('light'); // or 'dark'
```
## Color Palette
### Dark Theme
- **Background**: Dark gradients (#2c3e50, #34495e, #1a252f)
- **Text**: Light colors (#ecf0f1, rgba(255,255,255,0.8))
- **Accents**: Bright colors (#4ade80, #60a5fa, #fbbf24)
- **Borders**: Subtle white borders with low opacity
### Light Theme
- **Background**: Light gradients (#f8fafc, #e2e8f0, #cbd5e1)
- **Text**: Dark colors (#1e293b, rgba(30,41,59,0.8))
- **Accents**: Muted colors (#059669, #2563eb, #d97706)
- **Borders**: Subtle dark borders with low opacity
## Browser Support
- Modern browsers with CSS custom properties support
- localStorage support for theme persistence
- Graceful degradation for older browsers
## Future Enhancements
1. **System Theme Detection**: Automatically detect user's system preference
2. **More Themes**: Additional theme options (e.g., high contrast, colorblind-friendly)
3. **Theme Customization**: Allow users to customize accent colors
4. **Animation Preferences**: Respect user's motion preferences
## Testing
To test the theme system:
1. Start the development server: `cd public && python3 -m http.server 8080`
2. Open `http://localhost:8080` in your browser
3. Click the theme toggle button in the header
4. Verify smooth transitions and proper color contrast
5. Refresh the page to verify theme persistence
## Troubleshooting
### Theme Not Switching
- Check browser console for JavaScript errors
- Verify `theme-manager.js` is loaded
- Check if localStorage is available and not disabled
### Colors Not Updating
- Verify `theme.css` is loaded after `main.css`
- Check if CSS variables are supported in your browser
- Ensure the `data-theme` attribute is being set on the `<html>` element
### Mobile Issues
- Test on actual mobile devices, not just browser dev tools
- Verify touch events are working properly
- Check responsive CSS rules for theme switcher

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

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPORE UI</title> <title>SPORE UI</title>
<link rel="stylesheet" href="styles/main.css"> <link rel="stylesheet" href="styles/main.css">
<link rel="stylesheet" href="styles/theme.css?v=1757159926">
</head> </head>
<body> <body>
@@ -22,6 +23,14 @@
<button class="nav-tab" data-view="firmware">📦 Firmware</button> <button class="nav-tab" data-view="firmware">📦 Firmware</button>
</div> </div>
<div class="nav-right"> <div class="nav-right">
<div class="theme-switcher">
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
</button>
</div>
<div class="cluster-status">🚀 Cluster Online</div> <div class="cluster-status">🚀 Cluster Online</div>
</div> </div>
</div> </div>
@@ -147,6 +156,7 @@
<script src="./scripts/components/ClusterStatusComponent.js"></script> <script src="./scripts/components/ClusterStatusComponent.js"></script>
<script src="./scripts/components/TopologyGraphComponent.js"></script> <script src="./scripts/components/TopologyGraphComponent.js"></script>
<script src="./scripts/components/ComponentsLoader.js"></script> <script src="./scripts/components/ComponentsLoader.js"></script>
<script src="./scripts/theme-manager.js"></script>
<script src="./scripts/app.js"></script> <script src="./scripts/app.js"></script>
</body> </body>

View File

@@ -80,11 +80,11 @@ class ApiClient {
return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined }); return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
} }
async getCapabilities(ip) { async getEndpoints(ip) {
return this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined }); 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', { return this.request('/api/proxy-call', {
method: 'POST', method: 'POST',
body: { ip, method, uri, params } body: { ip, method, uri, params }

View File

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

View File

@@ -168,7 +168,7 @@ class TopologyGraphComponent extends Component {
.attr('height', '100%') .attr('height', '100%')
.attr('viewBox', `0 0 ${this.width} ${this.height}`) .attr('viewBox', `0 0 ${this.width} ${this.height}`)
.style('border', '1px solid rgba(255, 255, 255, 0.1)') .style('border', '1px solid rgba(255, 255, 255, 0.1)')
.style('background', 'rgba(0, 0, 0, 0.2)') //.style('background', 'rgba(0, 0, 0, 0.2)')
.style('border-radius', '12px'); .style('border-radius', '12px');
// Add zoom behavior // Add zoom behavior
@@ -280,7 +280,7 @@ class TopologyGraphComponent extends Component {
.attr('x', 15) .attr('x', 15)
.attr('y', 4) .attr('y', 4)
.attr('font-size', '13px') .attr('font-size', '13px')
.attr('fill', '#ecf0f1') .attr('fill', 'var(--text-primary)')
.attr('font-weight', '500'); .attr('font-weight', '500');
// IP // IP
@@ -289,7 +289,7 @@ class TopologyGraphComponent extends Component {
.attr('x', 15) .attr('x', 15)
.attr('y', 20) .attr('y', 20)
.attr('font-size', '11px') .attr('font-size', '11px')
.attr('fill', 'rgba(255, 255, 255, 0.7)'); .attr('fill', 'var(--text-secondary)');
// Status text // Status text
node.append('text') node.append('text')
@@ -307,7 +307,7 @@ class TopologyGraphComponent extends Component {
.data(links) .data(links)
.enter().append('text') .enter().append('text')
.attr('font-size', '12px') .attr('font-size', '12px')
.attr('fill', '#ecf0f1') .attr('fill', 'var(--text-primary)')
.attr('font-weight', '600') .attr('font-weight', '600')
.attr('text-anchor', 'middle') .attr('text-anchor', 'middle')
.style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)') .style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)')

View File

@@ -0,0 +1,120 @@
// Theme Manager - Handles theme switching and persistence
class ThemeManager {
constructor() {
this.currentTheme = this.getStoredTheme() || 'dark';
this.themeToggle = document.getElementById('theme-toggle');
this.init();
}
init() {
// Apply stored theme on page load
this.applyTheme(this.currentTheme);
// Set up event listener for theme toggle
if (this.themeToggle) {
this.themeToggle.addEventListener('click', () => this.toggleTheme());
}
// Listen for system theme changes
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addListener((e) => {
if (this.getStoredTheme() === 'system') {
this.applyTheme(e.matches ? 'dark' : 'light');
}
});
}
}
getStoredTheme() {
try {
return localStorage.getItem('spore-ui-theme');
} catch (e) {
console.warn('Could not access localStorage for theme preference');
return 'dark';
}
}
setStoredTheme(theme) {
try {
localStorage.setItem('spore-ui-theme', theme);
} catch (e) {
console.warn('Could not save theme preference to localStorage');
}
}
applyTheme(theme) {
// Update data attribute on html element
document.documentElement.setAttribute('data-theme', theme);
// Update theme toggle icon
this.updateThemeIcon(theme);
// Store the theme preference
this.setStoredTheme(theme);
this.currentTheme = theme;
// Dispatch custom event for other components
window.dispatchEvent(new CustomEvent('themeChanged', {
detail: { theme: theme }
}));
}
updateThemeIcon(theme) {
if (!this.themeToggle) return;
const svg = this.themeToggle.querySelector('svg');
if (!svg) return;
// Update the SVG content based on theme
if (theme === 'light') {
// Sun icon for light theme
svg.innerHTML = `
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
`;
} else {
// Moon icon for dark theme
svg.innerHTML = `
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
`;
}
}
toggleTheme() {
const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
this.applyTheme(newTheme);
// Add a subtle animation to the toggle button
if (this.themeToggle) {
this.themeToggle.style.transform = 'scale(0.9)';
setTimeout(() => {
this.themeToggle.style.transform = 'scale(1)';
}, 150);
}
}
// Method to get current theme (useful for other components)
getCurrentTheme() {
return this.currentTheme;
}
// Method to set theme programmatically
setTheme(theme) {
if (['dark', 'light'].includes(theme)) {
this.applyTheme(theme);
}
}
}
// Initialize theme manager when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
window.themeManager = new ThemeManager();
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = ThemeManager;
}

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1148
public/styles/theme.css Normal file

File diff suppressed because it is too large Load Diff

View File

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