Add Docker deployment for Unraid
- Dockerfile with nginx:alpine for static file serving - docker-compose.yml with port 9113:80 mapping - deploy.sh for git-based deployment to Unraid - setup-unraid.sh for initial server configuration - manage.sh for container operations (logs, status, restart, etc.) - .gitignore to exclude config.js Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ Thumbs.db
|
|||||||
# IDE files
|
# IDE files
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|||||||
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY index.html /usr/share/nginx/html/
|
||||||
|
COPY styles.css /usr/share/nginx/html/
|
||||||
|
COPY script.js /usr/share/nginx/html/
|
||||||
|
COPY config.js /usr/share/nginx/html/
|
||||||
|
RUN chmod 644 /usr/share/nginx/html/*.html /usr/share/nginx/html/*.css /usr/share/nginx/html/*.js
|
||||||
|
EXPOSE 80
|
||||||
680
IMPROVEMENTS.md
Normal file
680
IMPROVEMENTS.md
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
# Dashboard Improvement Ideas
|
||||||
|
|
||||||
|
This document contains recommended improvements for the Thanos Systems Monitor dashboard.
|
||||||
|
|
||||||
|
## Status Legend
|
||||||
|
- ✅ **Completed**: Implementation finished
|
||||||
|
- 🔄 **In Progress**: Currently being worked on
|
||||||
|
- ⏳ **Pending**: Not yet started
|
||||||
|
- 💡 **Proposed**: Idea for consideration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Security Concerns ✅
|
||||||
|
|
||||||
|
**Status**: COMPLETED (2025-11-22)
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
- ✅ Created separate `config.js` file for API credentials
|
||||||
|
- ✅ Added `config.js` to `.gitignore`
|
||||||
|
- ✅ Created `config.example.js` template
|
||||||
|
- ✅ Updated `script.js` to remove hardcoded credentials
|
||||||
|
- ✅ Added validation check for missing configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Error Handling and User Feedback ⏳
|
||||||
|
|
||||||
|
**Current Limitation**: The `showError()` function at `script.js:479-485` only displays errors in the timestamp element, which does not exist in your HTML.
|
||||||
|
|
||||||
|
**Proposed Improvements**:
|
||||||
|
|
||||||
|
### 2.1 Add Timestamp and Error Display
|
||||||
|
- Add timestamp element to header showing last update time
|
||||||
|
- Create dedicated error notification system
|
||||||
|
- Style error notifications with cyberpunk theme
|
||||||
|
|
||||||
|
### 2.2 Enhanced Error Handling
|
||||||
|
- Implement retry logic with exponential backoff when API requests fail
|
||||||
|
- Display connection status indicator (online/offline/connecting)
|
||||||
|
- Show loading states during initial data fetch
|
||||||
|
- Add user-friendly error messages for common failures
|
||||||
|
|
||||||
|
### 2.3 Implementation Details
|
||||||
|
```javascript
|
||||||
|
// Retry logic with exponential backoff
|
||||||
|
async function fetchWithRetry(fetchFunction, maxRetries = 3) {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
return await fetchFunction();
|
||||||
|
} catch (error) {
|
||||||
|
if (i === maxRetries - 1) throw error;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection status indicator
|
||||||
|
function updateConnectionStatus(status) {
|
||||||
|
// 'online', 'offline', 'connecting', 'error'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Visualization Enhancements ✅
|
||||||
|
|
||||||
|
### 3.1 Ring Chart Improvements
|
||||||
|
**Status**: COMPLETED (2025-11-22)
|
||||||
|
|
||||||
|
**Implemented Features**:
|
||||||
|
- ✅ Added smooth CSS transitions for ring chart value changes
|
||||||
|
- ✅ Display trend indicators (▲/▼/━ showing increasing/decreasing/stable usage)
|
||||||
|
- ✅ Added hover tooltips showing detailed information (Used, Free, Total, Usage %)
|
||||||
|
- ✅ Implemented historical data tracking (last 20 data points per metric)
|
||||||
|
- ✅ Enhanced hover effects with scale transformation
|
||||||
|
- ✅ Refactored code to use generic updateRingChartGeneric() function, reducing duplication
|
||||||
|
|
||||||
|
**Implementation Details**:
|
||||||
|
- Created HistoricalData object to track CPU, Memory, and individual disk usage over time
|
||||||
|
- Added createTrendIndicator() helper function for visual trend display
|
||||||
|
- Added createTooltip() helper function for consistent tooltip formatting
|
||||||
|
- Tooltips display on hover with fade-in animation
|
||||||
|
- Trend calculation based on last 3 data points with 1% threshold for stability
|
||||||
|
|
||||||
|
### 3.2 Disk Array Enhancements
|
||||||
|
**Status**: COMPLETED (2025-11-22)
|
||||||
|
|
||||||
|
**Implemented Features**:
|
||||||
|
- ✅ Added hover tooltips for each disk showing capacity, temperature, usage, and status
|
||||||
|
- ✅ Added trend indicators to disk progress bars (▲/▼/━)
|
||||||
|
- ✅ Enhanced hover effects (background color change, border color change)
|
||||||
|
- ✅ Historical data tracking for individual disks
|
||||||
|
|
||||||
|
**Remaining Enhancements**:
|
||||||
|
- ⏳ Visual indicators for disk health status (SMART data if available from API)
|
||||||
|
- ⏳ Show read/write speed metrics if available from API
|
||||||
|
- ⏳ Group disks by function (parity, cache, data) more clearly
|
||||||
|
- ⏳ Add disk age/hours powered on if available from API
|
||||||
|
|
||||||
|
### 3.3 Historical Data Graphs
|
||||||
|
**New Feature**:
|
||||||
|
- Line graphs for CPU usage over time (last hour/day)
|
||||||
|
- Memory usage trends
|
||||||
|
- Disk I/O graphs
|
||||||
|
- Network traffic visualization
|
||||||
|
- Implement using HTML5 Canvas or lightweight charting library
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Performance Optimizations ⏳
|
||||||
|
|
||||||
|
**Current Implementation**: Updates occur every 5 seconds regardless of visibility
|
||||||
|
|
||||||
|
### 4.1 Page Visibility API
|
||||||
|
```javascript
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
// Pause updates
|
||||||
|
clearInterval(updateInterval);
|
||||||
|
} else {
|
||||||
|
// Resume updates
|
||||||
|
updateInterval = setInterval(updateDashboard, 5000);
|
||||||
|
updateDashboard(); // Immediate update on return
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Animation Optimization
|
||||||
|
- Use `requestAnimationFrame` for smoother animations
|
||||||
|
- Batch DOM updates to minimize reflows
|
||||||
|
- Use CSS transforms instead of position changes
|
||||||
|
|
||||||
|
### 4.3 Differential Updates
|
||||||
|
- Only update DOM elements that have changed values
|
||||||
|
- Compare previous state with current state
|
||||||
|
- Reduce unnecessary re-renders
|
||||||
|
|
||||||
|
### 4.4 Web Workers
|
||||||
|
- Move data processing to Web Worker
|
||||||
|
- Keep UI thread responsive
|
||||||
|
- Process API responses in background
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Code Quality Improvements ⏳
|
||||||
|
|
||||||
|
### 5.1 Eliminate Code Duplication
|
||||||
|
|
||||||
|
**Current Issue**: Ring chart update functions (`script.js:113-240`) contain significant duplication
|
||||||
|
|
||||||
|
**Refactoring Recommendation**:
|
||||||
|
```javascript
|
||||||
|
function updateRingChart(elementId, percentage, usedKB, totalKB) {
|
||||||
|
const ring = document.getElementById(`${elementId}-ring`);
|
||||||
|
const text = document.getElementById(`${elementId}-percentage`);
|
||||||
|
const fraction = document.getElementById(`${elementId}-fraction`);
|
||||||
|
|
||||||
|
const circumference = 2 * Math.PI * 80;
|
||||||
|
const offset = circumference - (percentage / 100) * circumference;
|
||||||
|
|
||||||
|
ring.style.strokeDashoffset = offset;
|
||||||
|
text.textContent = percentage + '%';
|
||||||
|
|
||||||
|
const usedTB = (usedKB / 1024 / 1024 / 1024).toFixed(2);
|
||||||
|
const totalTB = (totalKB / 1024 / 1024 / 1024).toFixed(2);
|
||||||
|
fraction.textContent = `${usedTB} / ${totalTB} TB`;
|
||||||
|
|
||||||
|
// Apply color coding
|
||||||
|
applyThresholdColors(ring, text, fraction, percentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThresholdColors(ring, text, fraction, percentage) {
|
||||||
|
const color = percentage >= 90 ? '#ffffff' :
|
||||||
|
percentage >= 70 ? '#ff00ff' : '#00ffff';
|
||||||
|
|
||||||
|
ring.style.stroke = color;
|
||||||
|
text.style.fill = color;
|
||||||
|
fraction.style.fill = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
updateRingChart('disk', totalUsagePercentage, totalUsed, totalCapacity);
|
||||||
|
updateRingChart('docker', dockerUsagePercentage, dockerUsed, dockerCapacity);
|
||||||
|
updateRingChart('photos', photosUsagePercentage, photosUsed, photosCapacity);
|
||||||
|
updateRingChart('media', mediaUsagePercentage, mediaUsed, mediaCapacity);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Extract Constants
|
||||||
|
```javascript
|
||||||
|
// Configuration constants
|
||||||
|
const CONSTANTS = {
|
||||||
|
UPDATE_INTERVAL: 5000,
|
||||||
|
RING_RADIUS: 80,
|
||||||
|
WARNING_THRESHOLD: 70,
|
||||||
|
CRITICAL_THRESHOLD: 90,
|
||||||
|
COLORS: {
|
||||||
|
PRIMARY: '#00ffff',
|
||||||
|
WARNING: '#ff00ff',
|
||||||
|
CRITICAL: '#ffffff',
|
||||||
|
BACKGROUND: '#1a1a1a'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Add JSDoc Documentation
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Updates a ring chart with usage data
|
||||||
|
* @param {string} elementId - The base ID for the ring chart elements
|
||||||
|
* @param {number} percentage - Usage percentage (0-100)
|
||||||
|
* @param {number} usedKB - Used space in kilobytes
|
||||||
|
* @param {number} totalKB - Total space in kilobytes
|
||||||
|
*/
|
||||||
|
function updateRingChart(elementId, percentage, usedKB, totalKB) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Error Boundaries
|
||||||
|
- Add try-catch blocks around each update function
|
||||||
|
- Prevent one failing component from breaking entire dashboard
|
||||||
|
- Log errors for debugging
|
||||||
|
|
||||||
|
### 5.5 Data Validation
|
||||||
|
```javascript
|
||||||
|
function validateMetricsData(data) {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
throw new Error('Invalid metrics data');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.cpu && typeof data.cpu.percentTotal !== 'number') {
|
||||||
|
console.warn('Invalid CPU data:', data.cpu);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Responsive Design Enhancements ⏳
|
||||||
|
|
||||||
|
**Current State**: Basic responsive design at 768px breakpoint
|
||||||
|
|
||||||
|
### 6.1 Additional Breakpoints
|
||||||
|
```css
|
||||||
|
/* Tablet landscape */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.grid-row-1 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-usage-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet portrait */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ring-chart-row-split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.mini-ring-chart {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Raspberry Pi Display Optimization
|
||||||
|
- Test on actual Raspberry Pi display resolution
|
||||||
|
- Optimize for common resolutions: 1920x1080, 1280x720
|
||||||
|
- Consider touch-friendly interface elements
|
||||||
|
- Test browser performance on Raspberry Pi 3
|
||||||
|
|
||||||
|
### 6.3 Orientation Support
|
||||||
|
- Handle landscape and portrait orientations
|
||||||
|
- Reflow layout appropriately for vertical displays
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Accessibility Improvements ⏳
|
||||||
|
|
||||||
|
**Current State**: Limited accessibility features
|
||||||
|
|
||||||
|
### 7.1 ARIA Labels and Roles
|
||||||
|
```html
|
||||||
|
<!-- Progress bars -->
|
||||||
|
<div class="progress-bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow="75"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-label="CPU Usage">
|
||||||
|
<div class="progress-fill" style="width: 75%"></div>
|
||||||
|
<span class="progress-text">75%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status indicators -->
|
||||||
|
<div class="status-item" role="status" aria-live="polite">
|
||||||
|
<span class="status-label">PARITY STATUS:</span>
|
||||||
|
<span class="status-value" id="parity-status">VALID</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Live Regions
|
||||||
|
```html
|
||||||
|
<!-- Add to header for status updates -->
|
||||||
|
<div id="status-announcer"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
class="sr-only">
|
||||||
|
<!-- Screen reader announcements go here -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Keyboard Navigation
|
||||||
|
- Add tab index to interactive elements
|
||||||
|
- Implement keyboard shortcuts for common actions
|
||||||
|
- Ensure focus indicators are visible
|
||||||
|
|
||||||
|
### 7.4 Color Contrast
|
||||||
|
- Verify all text meets WCAG AA standards (4.5:1 ratio)
|
||||||
|
- Current cyan (#00ffff) on black (#000000) has 15.3:1 ratio ✓
|
||||||
|
- Test magenta (#ff00ff) and white (#ffffff) combinations
|
||||||
|
- Consider adding high contrast mode option
|
||||||
|
|
||||||
|
### 7.5 Screen Reader Support
|
||||||
|
```javascript
|
||||||
|
function announceUpdate(message) {
|
||||||
|
const announcer = document.getElementById('status-announcer');
|
||||||
|
announcer.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
announceUpdate('CPU usage increased to 85%');
|
||||||
|
announceUpdate('Docker container "plex" started');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Additional Features 💡
|
||||||
|
|
||||||
|
### 8.1 User Preferences
|
||||||
|
**Proposed Implementation**:
|
||||||
|
```javascript
|
||||||
|
const UserPreferences = {
|
||||||
|
updateInterval: 5000,
|
||||||
|
theme: 'dark', // Future: 'light' option
|
||||||
|
showAnimations: true,
|
||||||
|
temperatureUnit: 'celsius', // or 'fahrenheit'
|
||||||
|
notifications: {
|
||||||
|
enabled: true,
|
||||||
|
cpuThreshold: 90,
|
||||||
|
memoryThreshold: 90,
|
||||||
|
diskThreshold: 90
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
function savePreferences(prefs) {
|
||||||
|
localStorage.setItem('dashboardPrefs', JSON.stringify(prefs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from localStorage
|
||||||
|
function loadPreferences() {
|
||||||
|
const saved = localStorage.getItem('dashboardPrefs');
|
||||||
|
return saved ? JSON.parse(saved) : UserPreferences;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Configurable Alerts
|
||||||
|
- Set custom thresholds for warnings
|
||||||
|
- Browser notifications for critical states
|
||||||
|
- Email/webhook notifications (requires backend)
|
||||||
|
- Sound alerts for critical conditions
|
||||||
|
|
||||||
|
### 8.3 Data Export
|
||||||
|
```javascript
|
||||||
|
function exportData(format = 'json') {
|
||||||
|
const data = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
cpu: currentCPUUsage,
|
||||||
|
memory: currentMemoryUsage,
|
||||||
|
disks: currentDiskData,
|
||||||
|
docker: currentDockerData
|
||||||
|
};
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)],
|
||||||
|
{ type: 'application/json' });
|
||||||
|
downloadFile(blob, `unraid-metrics-${Date.now()}.json`);
|
||||||
|
} else if (format === 'csv') {
|
||||||
|
// Convert to CSV and download
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Theme Options
|
||||||
|
- Light theme option
|
||||||
|
- Custom color schemes
|
||||||
|
- Adjustable glow intensity
|
||||||
|
- Disable animations option (for performance)
|
||||||
|
|
||||||
|
### 8.5 Network Monitoring
|
||||||
|
**If Available from API**:
|
||||||
|
- Network interface statistics
|
||||||
|
- Upload/download speeds
|
||||||
|
- Total bandwidth usage
|
||||||
|
- Per-container network usage
|
||||||
|
|
||||||
|
### 8.6 UPS Status
|
||||||
|
**If Available from API**:
|
||||||
|
- Battery charge level
|
||||||
|
- Runtime remaining
|
||||||
|
- Power status (on battery/on AC)
|
||||||
|
- UPS load percentage
|
||||||
|
|
||||||
|
### 8.7 System Temperature Monitoring
|
||||||
|
**If Available from API**:
|
||||||
|
- CPU temperature
|
||||||
|
- Motherboard temperature
|
||||||
|
- Additional thermal sensors
|
||||||
|
- Temperature history graphs
|
||||||
|
|
||||||
|
### 8.8 Cache Drive Monitoring
|
||||||
|
- Separate cache drive statistics
|
||||||
|
- Cache utilization
|
||||||
|
- Mover status and schedule
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Docker Container Enhancements ⏳
|
||||||
|
|
||||||
|
**Current Display**: Basic name and status
|
||||||
|
|
||||||
|
### 9.1 Additional Container Information
|
||||||
|
**Proposed Additions**:
|
||||||
|
- CPU usage per container (if available)
|
||||||
|
- Memory usage per container (if available)
|
||||||
|
- Port mappings display
|
||||||
|
- Container uptime
|
||||||
|
- Container health check status
|
||||||
|
- Image version/tag
|
||||||
|
|
||||||
|
### 9.2 Container Controls
|
||||||
|
**If API Supports**:
|
||||||
|
```javascript
|
||||||
|
async function controlContainer(containerId, action) {
|
||||||
|
// action: 'start', 'stop', 'restart'
|
||||||
|
const query = `mutation {
|
||||||
|
dockerContainerControl(id: "${containerId}", action: "${action}") {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return await executeGraphQLQuery(query);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Implementation**:
|
||||||
|
- Add start/stop/restart buttons to each container card
|
||||||
|
- Confirmation dialog for destructive actions
|
||||||
|
- Loading state during action execution
|
||||||
|
- Success/error feedback
|
||||||
|
|
||||||
|
### 9.3 Container Details Modal
|
||||||
|
- Click container to view detailed information
|
||||||
|
- Show logs (last 100 lines)
|
||||||
|
- Environment variables
|
||||||
|
- Volume mounts
|
||||||
|
- Network configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing and Monitoring ⏳
|
||||||
|
|
||||||
|
### 10.1 Unit Tests
|
||||||
|
**Proposed Framework**: Jest or Vitest (lightweight)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Example test file: script.test.js
|
||||||
|
describe('formatBytes', () => {
|
||||||
|
test('formats bytes correctly', () => {
|
||||||
|
expect(formatBytes(0)).toBe('0 B');
|
||||||
|
expect(formatBytes(1024)).toBe('1 KB');
|
||||||
|
expect(formatBytes(1048576)).toBe('1 MB');
|
||||||
|
expect(formatBytes(1073741824)).toBe('1 GB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateCPU', () => {
|
||||||
|
test('applies critical class above 90%', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="progress-fill" id="cpu-progress"></div>
|
||||||
|
<span id="cpu-text"></span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
updateCPU(95);
|
||||||
|
|
||||||
|
const progressBar = document.getElementById('cpu-progress');
|
||||||
|
expect(progressBar.classList.contains('critical')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Integration Tests
|
||||||
|
**Test API Integration**:
|
||||||
|
```javascript
|
||||||
|
describe('API Integration', () => {
|
||||||
|
test('fetches system metrics successfully', async () => {
|
||||||
|
const metrics = await fetchSystemMetrics();
|
||||||
|
expect(metrics).toHaveProperty('cpu');
|
||||||
|
expect(metrics).toHaveProperty('memory');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles API errors gracefully', async () => {
|
||||||
|
// Mock failed request
|
||||||
|
global.fetch = jest.fn(() => Promise.reject('API Error'));
|
||||||
|
|
||||||
|
await expect(fetchSystemMetrics()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Performance Monitoring
|
||||||
|
```javascript
|
||||||
|
// Add performance tracking
|
||||||
|
const performanceMetrics = {
|
||||||
|
apiCallDuration: [],
|
||||||
|
renderDuration: [],
|
||||||
|
totalUpdateDuration: []
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateDashboard() {
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Existing update logic
|
||||||
|
} finally {
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
performanceMetrics.totalUpdateDuration.push(duration);
|
||||||
|
|
||||||
|
// Log if update takes too long
|
||||||
|
if (duration > 1000) {
|
||||||
|
console.warn(`Slow update: ${duration}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 Error Logging
|
||||||
|
**Client-Side Error Tracking**:
|
||||||
|
```javascript
|
||||||
|
// Simple error logger
|
||||||
|
const ErrorLogger = {
|
||||||
|
errors: [],
|
||||||
|
|
||||||
|
log(error, context = {}) {
|
||||||
|
const errorEntry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
context: context
|
||||||
|
};
|
||||||
|
|
||||||
|
this.errors.push(errorEntry);
|
||||||
|
console.error('Error logged:', errorEntry);
|
||||||
|
|
||||||
|
// Optional: Send to remote logging service
|
||||||
|
// this.sendToRemote(errorEntry);
|
||||||
|
},
|
||||||
|
|
||||||
|
getErrors() {
|
||||||
|
return this.errors;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearErrors() {
|
||||||
|
this.errors = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
ErrorLogger.log(event.error, {
|
||||||
|
type: 'uncaught',
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.5 Health Check Endpoint
|
||||||
|
**If you add a service worker**:
|
||||||
|
```javascript
|
||||||
|
// Monitor dashboard health
|
||||||
|
function dashboardHealthCheck() {
|
||||||
|
return {
|
||||||
|
status: 'healthy',
|
||||||
|
lastUpdate: lastUpdateTimestamp,
|
||||||
|
apiConnected: apiConnectionStatus,
|
||||||
|
errorCount: ErrorLogger.errors.length,
|
||||||
|
uptime: performance.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Advanced Features 💡
|
||||||
|
|
||||||
|
### 11.1 Mobile App
|
||||||
|
- Progressive Web App (PWA) support
|
||||||
|
- Add manifest.json
|
||||||
|
- Service worker for offline capability
|
||||||
|
- App install prompt
|
||||||
|
|
||||||
|
### 11.2 Multi-Server Support
|
||||||
|
- Monitor multiple Unraid servers
|
||||||
|
- Server selector dropdown
|
||||||
|
- Aggregate statistics
|
||||||
|
- Server comparison view
|
||||||
|
|
||||||
|
### 11.3 Custom Widgets
|
||||||
|
- Drag-and-drop dashboard customization
|
||||||
|
- User-configurable layout
|
||||||
|
- Widget library (add/remove sections)
|
||||||
|
- Save custom layouts
|
||||||
|
|
||||||
|
### 11.4 Scheduled Reports
|
||||||
|
- Daily/weekly summary emails
|
||||||
|
- PDF report generation
|
||||||
|
- Usage trends analysis
|
||||||
|
- Capacity planning recommendations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
### High Priority (Core Functionality)
|
||||||
|
1. ✅ Security improvements
|
||||||
|
2. Error handling and user feedback
|
||||||
|
3. Code quality improvements (reduce duplication)
|
||||||
|
4. Performance optimizations (Page Visibility API)
|
||||||
|
|
||||||
|
### Medium Priority (User Experience)
|
||||||
|
5. Docker container enhancements
|
||||||
|
6. Data visualization improvements
|
||||||
|
7. Responsive design enhancements
|
||||||
|
8. Accessibility improvements
|
||||||
|
|
||||||
|
### Low Priority (Nice to Have)
|
||||||
|
9. Additional features (themes, preferences)
|
||||||
|
10. Testing infrastructure
|
||||||
|
11. Advanced features (PWA, multi-server)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All improvements should maintain the cyberpunk aesthetic
|
||||||
|
- Keep implementation lightweight for Raspberry Pi 3 performance
|
||||||
|
- Test thoroughly on actual hardware before deployment
|
||||||
|
- Document all configuration options
|
||||||
|
- Maintain backward compatibility where possible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025-11-22
|
||||||
|
**Status**: Living document - update as improvements are implemented
|
||||||
63
deploy.sh
Executable file
63
deploy.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# deploy.sh - Git-based deployment to Unraid server
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
UNRAID_HOST="${UNRAID_HOST:-root@192.168.2.61}"
|
||||||
|
REMOTE_PATH="/boot/config/plugins/compose.manager/projects/unraid-dash"
|
||||||
|
GIT_REPO="git@git.michaelsimard.ca:msimard/unraid-dashboard.git"
|
||||||
|
|
||||||
|
echo "Deploying unraid-dash to ${UNRAID_HOST}..."
|
||||||
|
|
||||||
|
ssh ${UNRAID_HOST} bash << EOF
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Ensure SSH config for git server (port 28)
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
if ! grep -q "^Host git.michaelsimard.ca" ~/.ssh/config 2>/dev/null; then
|
||||||
|
echo "Configuring SSH for git.michaelsimard.ca (port 28)..."
|
||||||
|
cat >> ~/.ssh/config << 'SSHCONFIG'
|
||||||
|
|
||||||
|
Host git.michaelsimard.ca
|
||||||
|
Port 28
|
||||||
|
SSHCONFIG
|
||||||
|
chmod 600 ~/.ssh/config
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clone or pull repository
|
||||||
|
if [ -d "${REMOTE_PATH}/.git" ]; then
|
||||||
|
echo "Repository exists, pulling latest changes..."
|
||||||
|
cd ${REMOTE_PATH}
|
||||||
|
git pull
|
||||||
|
else
|
||||||
|
echo "Cloning repository..."
|
||||||
|
git clone ${GIT_REPO} ${REMOTE_PATH}
|
||||||
|
cd ${REMOTE_PATH}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fix file permissions for Docker build (required for /boot partition)
|
||||||
|
echo "Fixing file permissions..."
|
||||||
|
chmod -R 755 ${REMOTE_PATH}
|
||||||
|
chmod 644 ${REMOTE_PATH}/*.html ${REMOTE_PATH}/*.css ${REMOTE_PATH}/*.js ${REMOTE_PATH}/*.yml ${REMOTE_PATH}/*.md 2>/dev/null || true
|
||||||
|
|
||||||
|
# Check if config.js exists
|
||||||
|
if [ ! -f config.js ]; then
|
||||||
|
echo "WARNING: config.js file not found!"
|
||||||
|
echo "Please create ${REMOTE_PATH}/config.js with required configuration"
|
||||||
|
echo "You can copy from config.example.js and modify as needed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build and deploy
|
||||||
|
echo "Building and starting container..."
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Show status
|
||||||
|
echo ""
|
||||||
|
echo "Deployment complete!"
|
||||||
|
docker compose ps
|
||||||
|
echo ""
|
||||||
|
echo "View logs with: ssh ${UNRAID_HOST} 'cd ${REMOTE_PATH} && docker compose logs -f'"
|
||||||
|
EOF
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
unraid-dash:
|
||||||
|
build: .
|
||||||
|
container_name: unraid-dash
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9113:80"
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<button class="theme-toggle" id="theme-toggle">LIGHT MODE</button>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1 class="title">THANOS SYSTEMS MONITOR</h1>
|
<h1 class="title">THANOS SYSTEMS MONITOR</h1>
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
<div class="disk-usage-layout">
|
<div class="disk-usage-layout">
|
||||||
<div class="ring-chart-column">
|
<div class="ring-chart-column">
|
||||||
<div class="ring-chart-row-split">
|
<div class="ring-chart-row-split">
|
||||||
<div class="mini-ring-chart-container">
|
<div class="mini-ring-chart-container" id="disk-tooltip-container">
|
||||||
<div class="resource-label">TOTAL DISK</div>
|
<div class="resource-label">TOTAL DISK</div>
|
||||||
<svg class="mini-ring-chart" viewBox="0 0 200 200">
|
<svg class="mini-ring-chart" viewBox="0 0 200 200">
|
||||||
<circle class="ring-background" cx="100" cy="100" r="80" fill="none" stroke="#1a1a1a" stroke-width="20"></circle>
|
<circle class="ring-background" cx="100" cy="100" r="80" fill="none" stroke="#1a1a1a" stroke-width="20"></circle>
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
<text class="ring-fraction-small" id="disk-fraction" x="100" y="125" text-anchor="middle">0 / 0 TB</text>
|
<text class="ring-fraction-small" id="disk-fraction" x="100" y="125" text-anchor="middle">0 / 0 TB</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini-ring-chart-container">
|
<div class="mini-ring-chart-container" id="docker-tooltip-container">
|
||||||
<div class="resource-label">DOCKER</div>
|
<div class="resource-label">DOCKER</div>
|
||||||
<svg class="mini-ring-chart" viewBox="0 0 200 200">
|
<svg class="mini-ring-chart" viewBox="0 0 200 200">
|
||||||
<circle class="ring-background" cx="100" cy="100" r="80" fill="none" stroke="#1a1a1a" stroke-width="20"></circle>
|
<circle class="ring-background" cx="100" cy="100" r="80" fill="none" stroke="#1a1a1a" stroke-width="20"></circle>
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ring-chart-row-split">
|
<div class="ring-chart-row-split">
|
||||||
<div class="mini-ring-chart-container">
|
<div class="mini-ring-chart-container" id="photos-tooltip-container">
|
||||||
<div class="resource-label">PHOTOS</div>
|
<div class="resource-label">PHOTOS</div>
|
||||||
<svg class="mini-ring-chart" viewBox="0 0 200 200">
|
<svg class="mini-ring-chart" viewBox="0 0 200 200">
|
||||||
<circle class="ring-background" cx="100" cy="100" r="80" fill="none" stroke="#1a1a1a" stroke-width="20"></circle>
|
<circle class="ring-background" cx="100" cy="100" r="80" fill="none" stroke="#1a1a1a" stroke-width="20"></circle>
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
<text class="ring-fraction-small" id="photos-fraction" x="100" y="125" text-anchor="middle">0 / 0 TB</text>
|
<text class="ring-fraction-small" id="photos-fraction" x="100" y="125" text-anchor="middle">0 / 0 TB</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini-ring-chart-container">
|
<div class="mini-ring-chart-container" id="media-tooltip-container">
|
||||||
<div class="resource-label">MEDIA</div>
|
<div class="resource-label">MEDIA</div>
|
||||||
<svg class="mini-ring-chart" viewBox="0 0 200 200">
|
<svg class="mini-ring-chart" viewBox="0 0 200 200">
|
||||||
<circle class="ring-background" cx="100" cy="100" r="80" fill="none" stroke="#1a1a1a" stroke-width="20"></circle>
|
<circle class="ring-background" cx="100" cy="100" r="80" fill="none" stroke="#1a1a1a" stroke-width="20"></circle>
|
||||||
|
|||||||
52
manage.sh
Executable file
52
manage.sh
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# manage.sh - Manage unraid-dash on Unraid
|
||||||
|
|
||||||
|
UNRAID_HOST="${UNRAID_HOST:-root@192.168.2.61}"
|
||||||
|
REMOTE_PATH="/boot/config/plugins/compose.manager/projects/unraid-dash"
|
||||||
|
|
||||||
|
CMD="$1"
|
||||||
|
|
||||||
|
run_remote() {
|
||||||
|
ssh ${UNRAID_HOST} "cd ${REMOTE_PATH} && $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
logs)
|
||||||
|
run_remote "docker compose logs -f"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
run_remote "docker compose ps"
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
run_remote "docker compose restart"
|
||||||
|
echo "Dashboard restarted"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
run_remote "docker compose down"
|
||||||
|
echo "Dashboard stopped"
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
run_remote "docker compose up -d"
|
||||||
|
echo "Dashboard started"
|
||||||
|
;;
|
||||||
|
rebuild)
|
||||||
|
run_remote "docker compose up -d --build"
|
||||||
|
echo "Dashboard rebuilt and started"
|
||||||
|
;;
|
||||||
|
shell)
|
||||||
|
ssh -t ${UNRAID_HOST} "cd ${REMOTE_PATH} && bash"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {logs|status|restart|stop|start|rebuild|shell}"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " logs - View live logs"
|
||||||
|
echo " status - Show container status"
|
||||||
|
echo " restart - Restart the dashboard"
|
||||||
|
echo " stop - Stop the dashboard"
|
||||||
|
echo " start - Start the dashboard"
|
||||||
|
echo " rebuild - Rebuild and restart the dashboard"
|
||||||
|
echo " shell - SSH into Unraid at dashboard directory"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
401
script.js
401
script.js
@@ -5,6 +5,125 @@ if (typeof API_CONFIG === 'undefined') {
|
|||||||
alert('Configuration Error: config.js file is missing. Please check the console for details.');
|
alert('Configuration Error: config.js file is missing. Please check the console for details.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme Toggle Functionality
|
||||||
|
const ThemeToggle = {
|
||||||
|
init() {
|
||||||
|
console.log('Theme toggle initializing...');
|
||||||
|
// Load saved theme preference or default to dark mode
|
||||||
|
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||||
|
console.log('Saved theme:', savedTheme);
|
||||||
|
this.setTheme(savedTheme);
|
||||||
|
|
||||||
|
// Add click event listener to toggle button
|
||||||
|
const toggleButton = document.getElementById('theme-toggle');
|
||||||
|
console.log('Toggle button found:', toggleButton);
|
||||||
|
if (toggleButton) {
|
||||||
|
toggleButton.addEventListener('click', () => {
|
||||||
|
console.log('Theme toggle clicked!');
|
||||||
|
this.toggleTheme();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setTheme(theme) {
|
||||||
|
console.log('Setting theme to:', theme);
|
||||||
|
const body = document.body;
|
||||||
|
const toggleButton = document.getElementById('theme-toggle');
|
||||||
|
|
||||||
|
if (theme === 'light') {
|
||||||
|
body.classList.add('light-mode');
|
||||||
|
console.log('Light mode activated - body classes:', body.className);
|
||||||
|
if (toggleButton) toggleButton.textContent = 'DARK MODE';
|
||||||
|
} else {
|
||||||
|
body.classList.remove('light-mode');
|
||||||
|
console.log('Dark mode activated - body classes:', body.className);
|
||||||
|
if (toggleButton) toggleButton.textContent = 'LIGHT MODE';
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTheme() {
|
||||||
|
const currentTheme = document.body.classList.contains('light-mode') ? 'light' : 'dark';
|
||||||
|
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||||
|
this.setTheme(newTheme);
|
||||||
|
|
||||||
|
// Refresh dashboard to update colors
|
||||||
|
if (typeof updateDashboard === 'function') {
|
||||||
|
updateDashboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize theme on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
ThemeToggle.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Theme-aware color helper
|
||||||
|
function getThemeColors() {
|
||||||
|
const isLightMode = document.body.classList.contains('light-mode');
|
||||||
|
|
||||||
|
if (isLightMode) {
|
||||||
|
return {
|
||||||
|
normal: '#0066cc', // Blue for normal usage
|
||||||
|
warning: '#f0ad4e', // Orange for warning
|
||||||
|
critical: '#d9534f' // Red for critical
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
normal: '#00ffff', // Cyan for normal usage
|
||||||
|
warning: '#ff00ff', // Magenta for warning
|
||||||
|
critical: '#ffffff' // White for critical
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Historical data tracking
|
||||||
|
const HistoricalData = {
|
||||||
|
maxDataPoints: 20, // Keep last 20 data points
|
||||||
|
cpu: [],
|
||||||
|
memory: [],
|
||||||
|
disks: {},
|
||||||
|
|
||||||
|
addCPUData(percentage) {
|
||||||
|
this.cpu.push({ timestamp: Date.now(), value: percentage });
|
||||||
|
if (this.cpu.length > this.maxDataPoints) {
|
||||||
|
this.cpu.shift();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addMemoryData(percentage) {
|
||||||
|
this.memory.push({ timestamp: Date.now(), value: percentage });
|
||||||
|
if (this.memory.length > this.maxDataPoints) {
|
||||||
|
this.memory.shift();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addDiskData(diskName, percentage) {
|
||||||
|
if (!this.disks[diskName]) {
|
||||||
|
this.disks[diskName] = [];
|
||||||
|
}
|
||||||
|
this.disks[diskName].push({ timestamp: Date.now(), value: percentage });
|
||||||
|
if (this.disks[diskName].length > this.maxDataPoints) {
|
||||||
|
this.disks[diskName].shift();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTrend(dataArray) {
|
||||||
|
if (dataArray.length < 2) return 'stable';
|
||||||
|
|
||||||
|
const recent = dataArray.slice(-3);
|
||||||
|
const avg = recent.reduce((sum, item) => sum + item.value, 0) / recent.length;
|
||||||
|
const oldest = recent[0].value;
|
||||||
|
|
||||||
|
const change = avg - oldest;
|
||||||
|
|
||||||
|
if (Math.abs(change) < 1) return 'stable';
|
||||||
|
return change > 0 ? 'up' : 'down';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// GraphQL query executor
|
// GraphQL query executor
|
||||||
async function executeGraphQLQuery(query) {
|
async function executeGraphQLQuery(query) {
|
||||||
try {
|
try {
|
||||||
@@ -55,12 +174,136 @@ async function fetchArrayInfo() {
|
|||||||
|
|
||||||
// Fetch Docker container information
|
// Fetch Docker container information
|
||||||
async function fetchDockerContainers() {
|
async function fetchDockerContainers() {
|
||||||
const query = `query { docker { containers { id names state status autoStart } } }`;
|
const query = `query { docker { containers { id names state status autoStart ports { privatePort publicPort type } } } }`;
|
||||||
|
|
||||||
const data = await executeGraphQLQuery(query);
|
const data = await executeGraphQLQuery(query);
|
||||||
return data.docker.containers;
|
return data.docker.containers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to create trend indicator HTML
|
||||||
|
function createTrendIndicator(trend) {
|
||||||
|
const icons = {
|
||||||
|
up: '▲',
|
||||||
|
down: '▼',
|
||||||
|
stable: '━'
|
||||||
|
};
|
||||||
|
|
||||||
|
return `<span class="trend-indicator trend-${trend}">${icons[trend]}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to extract web UI ports from container ports
|
||||||
|
function getWebUIPort(ports) {
|
||||||
|
if (!ports || ports.length === 0) {
|
||||||
|
console.log('No ports available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('All ports:', JSON.stringify(ports));
|
||||||
|
|
||||||
|
// Common web UI ports (HTTP/HTTPS)
|
||||||
|
const webPorts = [80, 443, 8080, 8443, 3000, 5000, 8000, 8888, 9000];
|
||||||
|
|
||||||
|
// Find TCP ports that are commonly used for web interfaces
|
||||||
|
// Note: GraphQL returns type as uppercase "TCP" or "UDP"
|
||||||
|
const tcpPorts = ports.filter(p => p.type === 'TCP' && p.publicPort);
|
||||||
|
console.log('TCP ports with public mapping:', JSON.stringify(tcpPorts));
|
||||||
|
|
||||||
|
// Prioritize common web ports
|
||||||
|
for (const port of webPorts) {
|
||||||
|
const match = tcpPorts.find(p => p.privatePort === port);
|
||||||
|
if (match) {
|
||||||
|
console.log('Found common web port:', match.publicPort);
|
||||||
|
return match.publicPort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no common port found, return the first TCP port with a public mapping
|
||||||
|
if (tcpPorts.length > 0) {
|
||||||
|
console.log('Using first TCP port:', tcpPorts[0].publicPort);
|
||||||
|
return tcpPorts[0].publicPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('No suitable port found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create a clickable web UI link
|
||||||
|
function createWebUILink(port) {
|
||||||
|
if (!port) return '';
|
||||||
|
|
||||||
|
// Use the Unraid server IP from config
|
||||||
|
const serverHost = API_CONFIG.serverUrl.match(/http:\/\/([^:]+)/)[1];
|
||||||
|
const url = `http://${serverHost}:${port}`;
|
||||||
|
|
||||||
|
return `<a href="${url}" target="_blank" class="webui-link" title="Open Web UI">:${port}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create tooltip HTML
|
||||||
|
function createTooltip(title, data) {
|
||||||
|
const rows = Object.entries(data).map(([label, value]) =>
|
||||||
|
`<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">${label}:</span>
|
||||||
|
<span class="tooltip-value">${value}</span>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return `<div class="tooltip">
|
||||||
|
<div class="tooltip-title">${title}</div>
|
||||||
|
<div class="tooltip-content">
|
||||||
|
${rows}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic ring chart update function to reduce code duplication
|
||||||
|
function updateRingChartGeneric(elementId, containerId, title, percentage, usedKB, totalKB) {
|
||||||
|
const ring = document.getElementById(`${elementId}-ring`);
|
||||||
|
const text = document.getElementById(`${elementId}-percentage`);
|
||||||
|
const fraction = document.getElementById(`${elementId}-fraction`);
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
|
||||||
|
const circumference = 2 * Math.PI * 80;
|
||||||
|
const offset = circumference - (percentage / 100) * circumference;
|
||||||
|
|
||||||
|
ring.style.strokeDashoffset = offset;
|
||||||
|
text.textContent = percentage + '%';
|
||||||
|
|
||||||
|
const usedTB = (usedKB / 1024 / 1024 / 1024).toFixed(2);
|
||||||
|
const totalTB = (totalKB / 1024 / 1024 / 1024).toFixed(2);
|
||||||
|
const freeTB = (totalTB - usedTB).toFixed(2);
|
||||||
|
fraction.textContent = `${usedTB} / ${totalTB} TB`;
|
||||||
|
|
||||||
|
// Add tooltip
|
||||||
|
const existingTooltip = container.querySelector('.tooltip');
|
||||||
|
if (existingTooltip) {
|
||||||
|
existingTooltip.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipHTML = createTooltip(title, {
|
||||||
|
'Used': `${usedTB} TB`,
|
||||||
|
'Free': `${freeTB} TB`,
|
||||||
|
'Total': `${totalTB} TB`,
|
||||||
|
'Usage': `${percentage}%`
|
||||||
|
});
|
||||||
|
container.insertAdjacentHTML('beforeend', tooltipHTML);
|
||||||
|
|
||||||
|
// Change color based on usage (theme-aware)
|
||||||
|
const colors = getThemeColors();
|
||||||
|
let color;
|
||||||
|
|
||||||
|
if (percentage >= 90) {
|
||||||
|
color = colors.critical;
|
||||||
|
} else if (percentage >= 70) {
|
||||||
|
color = colors.warning;
|
||||||
|
} else {
|
||||||
|
color = colors.normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
ring.style.stroke = color;
|
||||||
|
text.style.fill = color;
|
||||||
|
fraction.style.fill = color;
|
||||||
|
}
|
||||||
|
|
||||||
// Update timestamp
|
// Update timestamp
|
||||||
function updateTimestamp() {
|
function updateTimestamp() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -81,8 +324,12 @@ function updateCPU(percentage) {
|
|||||||
const progressBar = document.getElementById('cpu-progress');
|
const progressBar = document.getElementById('cpu-progress');
|
||||||
const progressText = document.getElementById('cpu-text');
|
const progressText = document.getElementById('cpu-text');
|
||||||
|
|
||||||
|
// Track historical data
|
||||||
|
HistoricalData.addCPUData(percentage);
|
||||||
|
const trend = HistoricalData.getTrend(HistoricalData.cpu);
|
||||||
|
|
||||||
progressBar.style.width = percentage + '%';
|
progressBar.style.width = percentage + '%';
|
||||||
progressText.textContent = percentage + '%';
|
progressText.innerHTML = percentage + '%' + createTrendIndicator(trend);
|
||||||
|
|
||||||
// Apply color coding
|
// Apply color coding
|
||||||
progressBar.classList.remove('warning', 'critical');
|
progressBar.classList.remove('warning', 'critical');
|
||||||
@@ -98,8 +345,12 @@ function updateMemory(percentage) {
|
|||||||
const progressBar = document.getElementById('memory-progress');
|
const progressBar = document.getElementById('memory-progress');
|
||||||
const progressText = document.getElementById('memory-text');
|
const progressText = document.getElementById('memory-text');
|
||||||
|
|
||||||
|
// Track historical data
|
||||||
|
HistoricalData.addMemoryData(percentage);
|
||||||
|
const trend = HistoricalData.getTrend(HistoricalData.memory);
|
||||||
|
|
||||||
progressBar.style.width = percentage + '%';
|
progressBar.style.width = percentage + '%';
|
||||||
progressText.textContent = percentage + '%';
|
progressText.innerHTML = percentage + '%' + createTrendIndicator(trend);
|
||||||
|
|
||||||
// Apply color coding
|
// Apply color coding
|
||||||
progressBar.classList.remove('warning', 'critical');
|
progressBar.classList.remove('warning', 'critical');
|
||||||
@@ -112,132 +363,22 @@ function updateMemory(percentage) {
|
|||||||
|
|
||||||
// Update Disk Ring Chart
|
// Update Disk Ring Chart
|
||||||
function updateDiskRingChart(percentage, usedKB, totalKB) {
|
function updateDiskRingChart(percentage, usedKB, totalKB) {
|
||||||
const ring = document.getElementById('disk-ring');
|
updateRingChartGeneric('disk', 'disk-tooltip-container', 'Total Disk Usage', percentage, usedKB, totalKB);
|
||||||
const text = document.getElementById('disk-percentage');
|
|
||||||
const fraction = document.getElementById('disk-fraction');
|
|
||||||
|
|
||||||
// SVG circle circumference calculation: 2 * PI * radius (radius = 80)
|
|
||||||
const circumference = 2 * Math.PI * 80; // 502.65
|
|
||||||
const offset = circumference - (percentage / 100) * circumference;
|
|
||||||
|
|
||||||
ring.style.strokeDashoffset = offset;
|
|
||||||
text.textContent = percentage + '%';
|
|
||||||
|
|
||||||
// Calculate and display fraction (convert KB to TB, rounded to 2 decimals)
|
|
||||||
const usedTB = (usedKB / 1024 / 1024 / 1024).toFixed(2);
|
|
||||||
const totalTB = (totalKB / 1024 / 1024 / 1024).toFixed(2);
|
|
||||||
fraction.textContent = `${usedTB} / ${totalTB} TB`;
|
|
||||||
|
|
||||||
// Change color based on usage
|
|
||||||
if (percentage >= 90) {
|
|
||||||
ring.style.stroke = '#ffffff';
|
|
||||||
text.style.fill = '#ffffff';
|
|
||||||
fraction.style.fill = '#ffffff';
|
|
||||||
} else if (percentage >= 70) {
|
|
||||||
ring.style.stroke = '#ff00ff';
|
|
||||||
text.style.fill = '#ff00ff';
|
|
||||||
fraction.style.fill = '#ff00ff';
|
|
||||||
} else {
|
|
||||||
ring.style.stroke = '#00ffff';
|
|
||||||
text.style.fill = '#00ffff';
|
|
||||||
fraction.style.fill = '#00ffff';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Photos Ring Chart (Disk 7)
|
// Update Photos Ring Chart (Disk 7)
|
||||||
function updatePhotosRingChart(percentage, usedKB, totalKB) {
|
function updatePhotosRingChart(percentage, usedKB, totalKB) {
|
||||||
const ring = document.getElementById('photos-ring');
|
updateRingChartGeneric('photos', 'photos-tooltip-container', 'Photos Storage (Disk 7)', percentage, usedKB, totalKB);
|
||||||
const text = document.getElementById('photos-percentage');
|
|
||||||
const fraction = document.getElementById('photos-fraction');
|
|
||||||
|
|
||||||
const circumference = 2 * Math.PI * 80;
|
|
||||||
const offset = circumference - (percentage / 100) * circumference;
|
|
||||||
|
|
||||||
ring.style.strokeDashoffset = offset;
|
|
||||||
text.textContent = percentage + '%';
|
|
||||||
|
|
||||||
const usedTB = (usedKB / 1024 / 1024 / 1024).toFixed(2);
|
|
||||||
const totalTB = (totalKB / 1024 / 1024 / 1024).toFixed(2);
|
|
||||||
fraction.textContent = `${usedTB} / ${totalTB} TB`;
|
|
||||||
|
|
||||||
// Change color based on usage
|
|
||||||
if (percentage >= 90) {
|
|
||||||
ring.style.stroke = '#ffffff';
|
|
||||||
text.style.fill = '#ffffff';
|
|
||||||
fraction.style.fill = '#ffffff';
|
|
||||||
} else if (percentage >= 70) {
|
|
||||||
ring.style.stroke = '#ff00ff';
|
|
||||||
text.style.fill = '#ff00ff';
|
|
||||||
fraction.style.fill = '#ff00ff';
|
|
||||||
} else {
|
|
||||||
ring.style.stroke = '#00ffff';
|
|
||||||
text.style.fill = '#00ffff';
|
|
||||||
fraction.style.fill = '#00ffff';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Media Ring Chart (All disks except 6 and 7)
|
// Update Media Ring Chart (All disks except 6 and 7)
|
||||||
function updateMediaRingChart(percentage, usedKB, totalKB) {
|
function updateMediaRingChart(percentage, usedKB, totalKB) {
|
||||||
const ring = document.getElementById('media-ring');
|
updateRingChartGeneric('media', 'media-tooltip-container', 'Media Storage', percentage, usedKB, totalKB);
|
||||||
const text = document.getElementById('media-percentage');
|
|
||||||
const fraction = document.getElementById('media-fraction');
|
|
||||||
|
|
||||||
const circumference = 2 * Math.PI * 80;
|
|
||||||
const offset = circumference - (percentage / 100) * circumference;
|
|
||||||
|
|
||||||
ring.style.strokeDashoffset = offset;
|
|
||||||
text.textContent = percentage + '%';
|
|
||||||
|
|
||||||
const usedTB = (usedKB / 1024 / 1024 / 1024).toFixed(2);
|
|
||||||
const totalTB = (totalKB / 1024 / 1024 / 1024).toFixed(2);
|
|
||||||
fraction.textContent = `${usedTB} / ${totalTB} TB`;
|
|
||||||
|
|
||||||
// Change color based on usage
|
|
||||||
if (percentage >= 90) {
|
|
||||||
ring.style.stroke = '#ffffff';
|
|
||||||
text.style.fill = '#ffffff';
|
|
||||||
fraction.style.fill = '#ffffff';
|
|
||||||
} else if (percentage >= 70) {
|
|
||||||
ring.style.stroke = '#ff00ff';
|
|
||||||
text.style.fill = '#ff00ff';
|
|
||||||
fraction.style.fill = '#ff00ff';
|
|
||||||
} else {
|
|
||||||
ring.style.stroke = '#00ffff';
|
|
||||||
text.style.fill = '#00ffff';
|
|
||||||
fraction.style.fill = '#00ffff';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Docker Ring Chart (Disk 6)
|
// Update Docker Ring Chart (Disk 6)
|
||||||
function updateDockerRingChart(percentage, usedKB, totalKB) {
|
function updateDockerRingChart(percentage, usedKB, totalKB) {
|
||||||
const ring = document.getElementById('docker-ring');
|
updateRingChartGeneric('docker', 'docker-tooltip-container', 'Docker Storage (Disk 6)', percentage, usedKB, totalKB);
|
||||||
const text = document.getElementById('docker-percentage');
|
|
||||||
const fraction = document.getElementById('docker-fraction');
|
|
||||||
|
|
||||||
const circumference = 2 * Math.PI * 80;
|
|
||||||
const offset = circumference - (percentage / 100) * circumference;
|
|
||||||
|
|
||||||
ring.style.strokeDashoffset = offset;
|
|
||||||
text.textContent = percentage + '%';
|
|
||||||
|
|
||||||
const usedTB = (usedKB / 1024 / 1024 / 1024).toFixed(2);
|
|
||||||
const totalTB = (totalKB / 1024 / 1024 / 1024).toFixed(2);
|
|
||||||
fraction.textContent = `${usedTB} / ${totalTB} TB`;
|
|
||||||
|
|
||||||
// Change color based on usage
|
|
||||||
if (percentage >= 90) {
|
|
||||||
ring.style.stroke = '#ffffff';
|
|
||||||
text.style.fill = '#ffffff';
|
|
||||||
fraction.style.fill = '#ffffff';
|
|
||||||
} else if (percentage >= 70) {
|
|
||||||
ring.style.stroke = '#ff00ff';
|
|
||||||
text.style.fill = '#ff00ff';
|
|
||||||
fraction.style.fill = '#ff00ff';
|
|
||||||
} else {
|
|
||||||
ring.style.stroke = '#00ffff';
|
|
||||||
text.style.fill = '#00ffff';
|
|
||||||
fraction.style.fill = '#00ffff';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Parity status
|
// Update Parity status
|
||||||
@@ -276,6 +417,10 @@ function updateDisks(disks) {
|
|||||||
progressClass = 'warning';
|
progressClass = 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track historical data for this disk
|
||||||
|
HistoricalData.addDiskData(disk.name, usedPercentage);
|
||||||
|
const trend = HistoricalData.getTrend(HistoricalData.disks[disk.name] || []);
|
||||||
|
|
||||||
// Add label for disk6 and disk7
|
// Add label for disk6 and disk7
|
||||||
let diskLabel = disk.name;
|
let diskLabel = disk.name;
|
||||||
if (disk.name === 'disk6') {
|
if (disk.name === 'disk6') {
|
||||||
@@ -284,6 +429,14 @@ function updateDisks(disks) {
|
|||||||
diskLabel = disk.name + ' (photos)';
|
diskLabel = disk.name + ' (photos)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create tooltip with disk details
|
||||||
|
const diskTooltip = createTooltip(`${disk.name} Details`, {
|
||||||
|
'Capacity': disk.size,
|
||||||
|
'Temperature': disk.temperature,
|
||||||
|
'Usage': `${usedPercentage}%`,
|
||||||
|
'Status': disk.status || 'Active'
|
||||||
|
});
|
||||||
|
|
||||||
diskElement.innerHTML = `
|
diskElement.innerHTML = `
|
||||||
<div class="disk-header">
|
<div class="disk-header">
|
||||||
<span class="disk-name">${diskLabel}</span>
|
<span class="disk-name">${diskLabel}</span>
|
||||||
@@ -291,8 +444,9 @@ function updateDisks(disks) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-fill ${progressClass}" style="width: ${usedPercentage}%"></div>
|
<div class="progress-fill ${progressClass}" style="width: ${usedPercentage}%"></div>
|
||||||
<span class="progress-text">${usedPercentage}% USED</span>
|
<span class="progress-text">${usedPercentage}% USED ${createTrendIndicator(trend)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${diskTooltip}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.appendChild(diskElement);
|
container.appendChild(diskElement);
|
||||||
@@ -310,8 +464,14 @@ function updateDocker(containers) {
|
|||||||
const dockerElement = document.createElement('div');
|
const dockerElement = document.createElement('div');
|
||||||
dockerElement.className = 'docker-item';
|
dockerElement.className = 'docker-item';
|
||||||
|
|
||||||
|
// Extract web UI port
|
||||||
|
console.log('Container:', docker.name, 'Ports:', docker.ports);
|
||||||
|
const webUIPort = getWebUIPort(docker.ports);
|
||||||
|
console.log('Detected web UI port for', docker.name, ':', webUIPort);
|
||||||
|
const portLink = createWebUILink(webUIPort);
|
||||||
|
|
||||||
dockerElement.innerHTML = `
|
dockerElement.innerHTML = `
|
||||||
<div class="docker-name">${docker.name}</div>
|
<div class="docker-name">${docker.name}${portLink}</div>
|
||||||
<div class="docker-status ${docker.status}">${docker.status.toUpperCase()}</div>
|
<div class="docker-status ${docker.status}">${docker.status.toUpperCase()}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -449,7 +609,8 @@ async function updateDashboard() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: name,
|
name: name,
|
||||||
status: state
|
status: state,
|
||||||
|
ports: container.ports || []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
console.log('Processed docker data:', dockerData);
|
console.log('Processed docker data:', dockerData);
|
||||||
|
|||||||
82
setup-unraid.sh
Executable file
82
setup-unraid.sh
Executable file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# setup-unraid.sh - Initial Unraid setup for git-based deployment
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
UNRAID_HOST="${UNRAID_HOST:-root@192.168.2.61}"
|
||||||
|
REMOTE_PATH="/boot/config/plugins/compose.manager/projects/unraid-dash"
|
||||||
|
|
||||||
|
echo "Setting up Unraid server for git-based deployment..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Setup SSH config on Unraid for git access
|
||||||
|
echo "1. Configuring SSH for git.michaelsimard.ca..."
|
||||||
|
ssh ${UNRAID_HOST} bash << 'EOF'
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
|
||||||
|
# Add SSH config for git server
|
||||||
|
if ! grep -q "git.michaelsimard.ca" ~/.ssh/config 2>/dev/null; then
|
||||||
|
cat >> ~/.ssh/config << 'SSHCONFIG'
|
||||||
|
|
||||||
|
Host git.michaelsimard.ca
|
||||||
|
Port 28
|
||||||
|
SSHCONFIG
|
||||||
|
chmod 600 ~/.ssh/config
|
||||||
|
echo "SSH config updated"
|
||||||
|
else
|
||||||
|
echo "SSH config already contains git.michaelsimard.ca"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add host key
|
||||||
|
ssh-keyscan -p 28 git.michaelsimard.ca >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||||
|
echo "Host key added to known_hosts"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. Checking for SSH key on Unraid..."
|
||||||
|
ssh ${UNRAID_HOST} bash << 'EOF'
|
||||||
|
if [ ! -f ~/.ssh/id_rsa ] && [ ! -f ~/.ssh/id_ed25519 ]; then
|
||||||
|
echo "No SSH key found. Generating ed25519 key..."
|
||||||
|
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "unraid@unraid-dash"
|
||||||
|
echo ""
|
||||||
|
echo "=== PUBLIC KEY - Add this to your Gitea account ==="
|
||||||
|
cat ~/.ssh/id_ed25519.pub
|
||||||
|
echo "=== END PUBLIC KEY ==="
|
||||||
|
echo ""
|
||||||
|
echo "Add this key at: https://git.michaelsimard.ca/user/settings/keys"
|
||||||
|
echo "Press Enter when done..."
|
||||||
|
read
|
||||||
|
else
|
||||||
|
echo "SSH key exists"
|
||||||
|
if [ -f ~/.ssh/id_ed25519.pub ]; then
|
||||||
|
cat ~/.ssh/id_ed25519.pub
|
||||||
|
elif [ -f ~/.ssh/id_rsa.pub ]; then
|
||||||
|
cat ~/.ssh/id_rsa.pub
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Testing git access..."
|
||||||
|
if ssh ${UNRAID_HOST} "git ls-remote git@git.michaelsimard.ca:msimard/unraid-dashboard.git HEAD" &>/dev/null; then
|
||||||
|
echo "Git access successful"
|
||||||
|
else
|
||||||
|
echo "Git access failed. Ensure SSH key is added to Gitea"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4. Creating config.js file on Unraid..."
|
||||||
|
if ssh ${UNRAID_HOST} "[ -f ${REMOTE_PATH}/config.js ]"; then
|
||||||
|
echo "config.js already exists on Unraid"
|
||||||
|
else
|
||||||
|
echo "Copying local config.js to Unraid..."
|
||||||
|
ssh ${UNRAID_HOST} "mkdir -p ${REMOTE_PATH}"
|
||||||
|
scp config.js ${UNRAID_HOST}:${REMOTE_PATH}/config.js
|
||||||
|
echo "config.js copied"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Setup Complete ==="
|
||||||
|
echo "Run './deploy.sh' to deploy the dashboard"
|
||||||
406
styles.css
406
styles.css
@@ -4,6 +4,45 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CSS Variables for theming */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #000000;
|
||||||
|
--bg-secondary: #0a0a0a;
|
||||||
|
--bg-tertiary: #1a1a1a;
|
||||||
|
--color-primary: #00ffff;
|
||||||
|
--color-secondary: #00cccc;
|
||||||
|
--color-text: #00ffff;
|
||||||
|
--color-warning: #ff00ff;
|
||||||
|
--color-critical: #ffffff;
|
||||||
|
--border-color: #00ffff;
|
||||||
|
--shadow-color: rgba(0, 255, 255, 0.3);
|
||||||
|
--shadow-color-intense: rgba(0, 255, 255, 0.6);
|
||||||
|
--panel-hover-gradient: linear-gradient(45deg, #00ffff, #ff00ff, #00ffff);
|
||||||
|
--progress-gradient: linear-gradient(90deg, #00ffff, #00cccc);
|
||||||
|
--progress-warning: linear-gradient(90deg, #ff00ff, #ff00cc);
|
||||||
|
--progress-critical: linear-gradient(90deg, #ffffff, #cccccc);
|
||||||
|
--scanline-color: #00ffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode {
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f0f5fa;
|
||||||
|
--bg-tertiary: #d9e6f2;
|
||||||
|
--color-primary: #0052a3;
|
||||||
|
--color-secondary: #003d7a;
|
||||||
|
--color-text: #002952;
|
||||||
|
--color-warning: #d9534f;
|
||||||
|
--color-critical: #c9302c;
|
||||||
|
--border-color: #0066cc;
|
||||||
|
--shadow-color: rgba(0, 82, 163, 0.2);
|
||||||
|
--shadow-color-intense: rgba(0, 82, 163, 0.4);
|
||||||
|
--panel-hover-gradient: linear-gradient(45deg, #0066cc, #d9534f, #0066cc);
|
||||||
|
--progress-gradient: linear-gradient(90deg, #0066cc, #0052a3);
|
||||||
|
--progress-warning: linear-gradient(90deg, #f0ad4e, #ec971f);
|
||||||
|
--progress-critical: linear-gradient(90deg, #d9534f, #c9302c);
|
||||||
|
--scanline-color: #0066cc;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes borderGlow {
|
@keyframes borderGlow {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow: 0 0 15px rgba(0, 255, 255, 0.3), 0 0 30px rgba(0, 255, 255, 0.1);
|
box-shadow: 0 0 15px rgba(0, 255, 255, 0.3), 0 0 30px rgba(0, 255, 255, 0.1);
|
||||||
@@ -31,14 +70,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
background-color: #000000;
|
background-color: var(--bg-primary);
|
||||||
color: #00ffff;
|
color: var(--color-text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
body::before {
|
||||||
@@ -48,7 +108,7 @@ body::before {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: linear-gradient(90deg, transparent, #00ffff, transparent);
|
background: linear-gradient(90deg, transparent, var(--scanline-color), transparent);
|
||||||
animation: scanline 4s linear infinite;
|
animation: scanline 4s linear infinite;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -64,20 +124,24 @@ header {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
border-bottom: 2px solid #00ffff;
|
border-bottom: 2px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
letter-spacing: 4px;
|
letter-spacing: 4px;
|
||||||
text-shadow: 0 0 10px #00ffff, 0 0 20px #00ffff;
|
color: var(--color-primary);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.light-mode) .title {
|
||||||
|
text-shadow: 0 0 10px var(--color-primary), 0 0 20px var(--color-primary);
|
||||||
animation: titleGlow 3s ease-in-out infinite;
|
animation: titleGlow 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #00cccc;
|
color: var(--color-secondary);
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +160,16 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background-color: #0a0a0a;
|
background-color: var(--bg-secondary);
|
||||||
border: 2px solid #00ffff;
|
border: 2px solid var(--border-color);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
box-shadow: 0 0 15px rgba(0, 255, 255, 0.3);
|
box-shadow: 0 0 15px var(--shadow-color);
|
||||||
animation: borderGlow 2s ease-in-out infinite;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.light-mode) .panel {
|
||||||
|
animation: borderGlow 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel::before {
|
.panel::before {
|
||||||
@@ -111,7 +179,7 @@ header {
|
|||||||
left: -2px;
|
left: -2px;
|
||||||
right: -2px;
|
right: -2px;
|
||||||
bottom: -2px;
|
bottom: -2px;
|
||||||
background: linear-gradient(45deg, #00ffff, #ff00ff, #00ffff);
|
background: var(--panel-hover-gradient);
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
@@ -131,8 +199,12 @@ header {
|
|||||||
letter-spacing: 3px;
|
letter-spacing: 3px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
border-bottom: 1px solid #00ffff;
|
border-bottom: 1px solid var(--border-color);
|
||||||
text-shadow: 0 0 5px #00ffff;
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.light-mode) .panel-title {
|
||||||
|
text-shadow: 0 0 5px var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resource Section */
|
/* Resource Section */
|
||||||
@@ -188,6 +260,14 @@ header {
|
|||||||
|
|
||||||
.resource-item {
|
.resource-item {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode .resource-item {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-label {
|
.resource-label {
|
||||||
@@ -221,27 +301,27 @@ header {
|
|||||||
.ring-text {
|
.ring-text {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
fill: #00ffff;
|
fill: var(--color-primary);
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring-text-small {
|
.ring-text-small {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
fill: #00ffff;
|
fill: var(--color-primary);
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring-fraction {
|
.ring-fraction {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
fill: #00ffff;
|
fill: var(--color-primary);
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring-fraction-small {
|
.ring-fraction-small {
|
||||||
font-size: 0.5rem;
|
font-size: 0.5rem;
|
||||||
fill: #00ffff;
|
fill: var(--color-primary);
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -250,16 +330,17 @@ header {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background-color: #1a1a1a;
|
background-color: var(--bg-tertiary);
|
||||||
border: 1px solid #00ffff;
|
border: 1px solid var(--border-color);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #00ffff, #00cccc);
|
background: var(--progress-gradient);
|
||||||
transition: width 0.3s ease, background 0.3s ease;
|
transition: width 0.3s ease, background 0.3s ease;
|
||||||
box-shadow: 0 0 10px rgba(0, 255, 255, 0.5);
|
box-shadow: 0 0 10px var(--shadow-color);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -285,13 +366,13 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill.warning {
|
.progress-fill.warning {
|
||||||
background: linear-gradient(90deg, #ff00ff, #ff00cc);
|
background: var(--progress-warning);
|
||||||
box-shadow: 0 0 10px rgba(255, 0, 255, 0.5);
|
box-shadow: 0 0 10px var(--shadow-color-intense);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill.critical {
|
.progress-fill.critical {
|
||||||
background: linear-gradient(90deg, #ffffff, #cccccc);
|
background: var(--progress-critical);
|
||||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
|
box-shadow: 0 0 10px var(--shadow-color-intense);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-text {
|
.progress-text {
|
||||||
@@ -300,8 +381,17 @@ header {
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-shadow: 1px 1px 2px #000000;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode .progress-text {
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.light-mode) .progress-text {
|
||||||
|
text-shadow: 1px 1px 2px #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Parity Status */
|
/* Parity Status */
|
||||||
@@ -314,8 +404,17 @@ header {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: #1a1a1a;
|
background-color: var(--bg-tertiary);
|
||||||
border-left: 3px solid #00ffff;
|
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.light-mode) .status-item {
|
||||||
|
border-left: 3px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode .status-item {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-label {
|
.status-label {
|
||||||
@@ -324,20 +423,32 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-value {
|
.status-value {
|
||||||
color: #00ffff;
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-value.error {
|
.status-value.error {
|
||||||
color: #ffffff;
|
color: var(--color-critical);
|
||||||
text-shadow: 0 0 5px #ffffff;
|
}
|
||||||
|
|
||||||
|
body:not(.light-mode) .status-value.error {
|
||||||
|
text-shadow: 0 0 5px var(--color-critical);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disk Array */
|
/* Disk Array */
|
||||||
.disk-item {
|
.disk-item {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background-color: #1a1a1a;
|
background-color: var(--bg-tertiary);
|
||||||
border-left: 3px solid #00ffff;
|
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.light-mode) .disk-item {
|
||||||
|
border-left: 3px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode .disk-item {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.disk-header {
|
.disk-header {
|
||||||
@@ -353,7 +464,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.disk-info {
|
.disk-info {
|
||||||
color: #00cccc;
|
color: var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Docker Containers */
|
/* Docker Containers */
|
||||||
@@ -365,9 +476,10 @@ header {
|
|||||||
|
|
||||||
.docker-item {
|
.docker-item {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
background-color: #1a1a1a;
|
background-color: var(--bg-tertiary);
|
||||||
border: 1px solid #00ffff;
|
border: 1px solid var(--border-color);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docker-name {
|
.docker-name {
|
||||||
@@ -375,6 +487,10 @@ header {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docker-status {
|
.docker-status {
|
||||||
@@ -386,8 +502,15 @@ header {
|
|||||||
|
|
||||||
.docker-status.running {
|
.docker-status.running {
|
||||||
background-color: #00ffff;
|
background-color: #00ffff;
|
||||||
color: #000000;
|
color: #003d7a;
|
||||||
box-shadow: 0 0 5px #00ffff;
|
box-shadow: 0 0 5px #00ffff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode .docker-status.running {
|
||||||
|
background-color: #00cc99;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 0 5px rgba(0, 204, 153, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.docker-status.stopped,
|
.docker-status.stopped,
|
||||||
@@ -397,12 +520,214 @@ header {
|
|||||||
box-shadow: 0 0 5px #666666;
|
box-shadow: 0 0 5px #666666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.light-mode .docker-status.stopped,
|
||||||
|
body.light-mode .docker-status.offline {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 0 5px rgba(108, 117, 125, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.docker-status.paused {
|
.docker-status.paused {
|
||||||
background-color: #ff00ff;
|
background-color: #ff00ff;
|
||||||
color: #000000;
|
color: #003d7a;
|
||||||
box-shadow: 0 0 5px #ff00ff;
|
box-shadow: 0 0 5px #ff00ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.light-mode .docker-status.paused {
|
||||||
|
background-color: #f0ad4e;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 0 5px rgba(240, 173, 78, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webui-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webui-link:hover {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
box-shadow: 0 0 10px var(--shadow-color-intense);
|
||||||
|
text-shadow: none;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltips */
|
||||||
|
.tooltip-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 20px var(--shadow-color-intense);
|
||||||
|
min-width: 200px;
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease, background-color 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-container:hover .tooltip {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-content {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-label {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-value {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trend Indicators */
|
||||||
|
.trend-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-up {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-down {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-stable {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Ring Chart Container */
|
||||||
|
.mini-ring-chart-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-ring-chart-container:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-ring-chart-container .tooltip {
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced text transitions */
|
||||||
|
.ring-text, .ring-text-small {
|
||||||
|
transition: fill 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-fraction, .ring-fraction-small {
|
||||||
|
transition: fill 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disk item enhancements */
|
||||||
|
.disk-item {
|
||||||
|
transition: background-color 0.3s ease, border-left-color 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.light-mode) .disk-item:hover {
|
||||||
|
background-color: #252525;
|
||||||
|
border-left-color: #ff00ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode .disk-item:hover {
|
||||||
|
background-color: #d0d0d0;
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode text overrides to ensure no black text */
|
||||||
|
body.light-mode .disk-name,
|
||||||
|
body.light-mode .docker-name,
|
||||||
|
body.light-mode .status-label,
|
||||||
|
body.light-mode .resource-label {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode .disk-info {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-item .tooltip {
|
||||||
|
top: 0;
|
||||||
|
left: 100%;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Toggle Button */
|
||||||
|
.theme-toggle {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 0 10px var(--shadow-color);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
box-shadow: 0 0 20px var(--shadow-color-intense);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.light-mode) .theme-toggle:hover {
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.title {
|
.title {
|
||||||
@@ -417,4 +742,9 @@ header {
|
|||||||
.docker-grid {
|
.docker-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
min-width: 150px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user