Initial commit: Homelab Dashboard with YAML configuration
Features: - Service health monitoring with response times - Proxmox cluster integration (nodes, VMs, containers) - PBS backup server monitoring - Camera viewer with WebRTC (go2rtc) - Docker container monitoring - Uptime Kuma integration - Mobile-friendly responsive design - YAML-based configuration for easy setup
This commit is contained in:
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Secrets - NEVER commit these
|
||||||
|
secrets.yaml
|
||||||
|
*.secret
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.class
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# User settings
|
||||||
|
settings.json
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
161
README.md
Normal file
161
README.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Homelab Dashboard
|
||||||
|
|
||||||
|
A modern, responsive dashboard for monitoring your homelab infrastructure.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Service Monitoring**: Track health and response times for all your services
|
||||||
|
- **Proxmox Integration**: View cluster status, nodes, VMs, and containers
|
||||||
|
- **PBS Integration**: Monitor Proxmox Backup Server status
|
||||||
|
- **Camera Viewer**: WebRTC-based live camera feeds (via go2rtc)
|
||||||
|
- **Docker Monitoring**: Track containers across multiple Docker hosts
|
||||||
|
- **Uptime Kuma Integration**: Display uptime stats from your monitoring
|
||||||
|
- **Mobile-Friendly**: Responsive design works on all devices
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Clone and Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy the dashboard to your server
|
||||||
|
scp -r dashboard/ root@your-server:/opt/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
apt install python3-venv python3-yaml
|
||||||
|
cd /opt/dashboard
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install fastapi uvicorn httpx jinja2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy the example configs
|
||||||
|
cp config.yaml.example config.yaml
|
||||||
|
cp secrets.yaml.example secrets.yaml
|
||||||
|
|
||||||
|
# Edit config.yaml with your services
|
||||||
|
nano config.yaml
|
||||||
|
|
||||||
|
# Add your API credentials to secrets.yaml
|
||||||
|
nano secrets.yaml
|
||||||
|
|
||||||
|
# Protect secrets file
|
||||||
|
chmod 600 secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test manually
|
||||||
|
source venv/bin/activate
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# Or create a systemd service (see below)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### config.yaml
|
||||||
|
|
||||||
|
The main configuration file defines:
|
||||||
|
|
||||||
|
- **dashboard**: Title, theme, refresh interval
|
||||||
|
- **proxmox**: Cluster nodes to monitor
|
||||||
|
- **pbs**: Proxmox Backup Server connection
|
||||||
|
- **cameras**: go2rtc camera streams
|
||||||
|
- **docker**: Docker hosts to monitor
|
||||||
|
- **uptime_kuma**: Uptime monitoring integration
|
||||||
|
- **categories**: Service category colors and icons
|
||||||
|
- **services**: All services to display and monitor
|
||||||
|
- **service_groups**: Logical groupings for services
|
||||||
|
|
||||||
|
See `config.yaml.example` for full documentation.
|
||||||
|
|
||||||
|
### secrets.yaml
|
||||||
|
|
||||||
|
Store sensitive credentials separately:
|
||||||
|
|
||||||
|
- Proxmox API tokens
|
||||||
|
- PBS API tokens
|
||||||
|
- OPNsense API keys
|
||||||
|
- Service-specific API keys
|
||||||
|
|
||||||
|
**Important**: Never commit `secrets.yaml` to version control!
|
||||||
|
|
||||||
|
### Service Configuration
|
||||||
|
|
||||||
|
Each service can have:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: MyService
|
||||||
|
url: https://myservice.example.com # URL to open on click
|
||||||
|
ip: 192.168.1.100 # IP for health checks
|
||||||
|
port: 8080 # Port for health checks
|
||||||
|
category: Apps # Category grouping
|
||||||
|
icon: server # Icon name
|
||||||
|
favorite: true # Show in favorites
|
||||||
|
critical: true # Mark as critical
|
||||||
|
group: "My Group" # Service group
|
||||||
|
health_check: # Optional custom health check
|
||||||
|
url: https://custom-check-url/
|
||||||
|
timeout: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Icons
|
||||||
|
|
||||||
|
server, shield, globe, archive, lock, film, star, video, tv, music,
|
||||||
|
search, download, cog, chart, heartbeat, key, git, workflow, book,
|
||||||
|
image, brain, home, camera, message, database
|
||||||
|
|
||||||
|
## Systemd Service
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/dashboard.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Homelab Dashboard
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/dashboard
|
||||||
|
ExecStart=/opt/dashboard/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Then enable and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable dashboard
|
||||||
|
systemctl start dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverse Proxy (Nginx/NPM)
|
||||||
|
|
||||||
|
Example Nginx Proxy Manager configuration:
|
||||||
|
|
||||||
|
- **Domain**: dashboard.yourdomain.com
|
||||||
|
- **Forward Host**: 192.168.1.x (dashboard IP)
|
||||||
|
- **Forward Port**: 8000
|
||||||
|
- **WebSocket Support**: Enable (for live updates)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- FastAPI
|
||||||
|
- Uvicorn
|
||||||
|
- HTTPX
|
||||||
|
- Jinja2
|
||||||
|
- PyYAML
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - Feel free to use and modify for your homelab!
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
280
app/config.py
Normal file
280
app/config.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""Dashboard configuration loader - supports YAML config files."""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# Base paths
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
CONFIG_FILE = os.path.join(BASE_DIR, "config.yaml")
|
||||||
|
SECRETS_FILE = os.path.join(BASE_DIR, "secrets.yaml")
|
||||||
|
SETTINGS_FILE = os.path.join(BASE_DIR, "settings.json")
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Service:
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
ip: str
|
||||||
|
port: int
|
||||||
|
category: str
|
||||||
|
icon: str = "server"
|
||||||
|
favorite: bool = False
|
||||||
|
critical: bool = False
|
||||||
|
group: Optional[str] = None
|
||||||
|
health_check: Optional[Dict] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthCheckConfig:
|
||||||
|
url: str
|
||||||
|
timeout: float = 5.0
|
||||||
|
|
||||||
|
# Icon SVG paths
|
||||||
|
SERVICE_ICONS = {
|
||||||
|
"shield": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>',
|
||||||
|
"globe": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/>',
|
||||||
|
"archive": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>',
|
||||||
|
"lock": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>',
|
||||||
|
"film": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"/>',
|
||||||
|
"star": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>',
|
||||||
|
"video": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>',
|
||||||
|
"tv": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>',
|
||||||
|
"music": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>',
|
||||||
|
"search": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>',
|
||||||
|
"download": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>',
|
||||||
|
"cog": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
|
||||||
|
"chart": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>',
|
||||||
|
"heartbeat": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>',
|
||||||
|
"key": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>',
|
||||||
|
"git": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>',
|
||||||
|
"workflow": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/>',
|
||||||
|
"book": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>',
|
||||||
|
"image": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>',
|
||||||
|
"brain": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>',
|
||||||
|
"home": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>',
|
||||||
|
"camera": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>',
|
||||||
|
"message": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>',
|
||||||
|
"server": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>',
|
||||||
|
"database": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Configuration Loading
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def load_yaml(path: str) -> Dict:
|
||||||
|
"""Load YAML file, return empty dict if not found."""
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to load {path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def load_config() -> Dict:
|
||||||
|
"""Load main config and merge with secrets."""
|
||||||
|
config = load_yaml(CONFIG_FILE)
|
||||||
|
secrets = load_yaml(SECRETS_FILE)
|
||||||
|
|
||||||
|
# Merge secrets into config
|
||||||
|
if secrets:
|
||||||
|
for key in ['proxmox', 'pbs', 'opnsense', 'sabnzbd']:
|
||||||
|
if key in secrets and key in config:
|
||||||
|
config[key].update(secrets[key])
|
||||||
|
elif key in secrets:
|
||||||
|
config[key] = secrets[key]
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
_config = load_config()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Configuration Values (with defaults for backward compatibility)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Dashboard settings
|
||||||
|
DASHBOARD_TITLE = _config.get('dashboard', {}).get('title', 'Homelab Dashboard')
|
||||||
|
REFRESH_INTERVAL = _config.get('dashboard', {}).get('refresh_interval', 30)
|
||||||
|
|
||||||
|
# Proxmox configuration
|
||||||
|
_proxmox = _config.get('proxmox', {})
|
||||||
|
PROXMOX_ENABLED = _proxmox.get('enabled', True)
|
||||||
|
PROXMOX_NODES = _proxmox.get('nodes', [])
|
||||||
|
PROXMOX_API_TOKEN = _proxmox.get('api_token', '')
|
||||||
|
PROXMOX_API_SECRET = _proxmox.get('api_secret', '')
|
||||||
|
|
||||||
|
# PBS configuration
|
||||||
|
_pbs = _config.get('pbs', {})
|
||||||
|
PBS_ENABLED = _pbs.get('enabled', True)
|
||||||
|
PBS_URL = _pbs.get('url', '')
|
||||||
|
PBS_API_TOKEN = _pbs.get('api_token', '')
|
||||||
|
PBS_API_SECRET = _pbs.get('api_secret', '')
|
||||||
|
|
||||||
|
# OPNsense configuration
|
||||||
|
_opnsense = _config.get('opnsense', {})
|
||||||
|
OPNSENSE_URL = _opnsense.get('url', '')
|
||||||
|
OPNSENSE_API_KEY = _opnsense.get('api_key', '')
|
||||||
|
OPNSENSE_API_SECRET = _opnsense.get('api_secret', '')
|
||||||
|
|
||||||
|
# Prometheus configuration
|
||||||
|
PROMETHEUS_URL = _config.get('prometheus', {}).get('url', '')
|
||||||
|
|
||||||
|
# Camera configuration
|
||||||
|
_cameras = _config.get('cameras', {})
|
||||||
|
CAMERAS_ENABLED = _cameras.get('enabled', False)
|
||||||
|
GO2RTC_URL = _cameras.get('go2rtc_url', '')
|
||||||
|
CAMERAS = _cameras.get('streams', [])
|
||||||
|
|
||||||
|
# Sabnzbd configuration
|
||||||
|
_sabnzbd = _config.get('sabnzbd', {})
|
||||||
|
SABNZBD_URL = _sabnzbd.get('url', '')
|
||||||
|
SABNZBD_API_KEY = _sabnzbd.get('api_key', '')
|
||||||
|
|
||||||
|
# Uptime Kuma configuration
|
||||||
|
_uptime = _config.get('uptime_kuma', {})
|
||||||
|
UPTIME_KUMA_URL = _uptime.get('url', '')
|
||||||
|
UPTIME_KUMA_STATUS_PAGE = _uptime.get('status_page', 'default')
|
||||||
|
|
||||||
|
# Docker hosts
|
||||||
|
_docker = _config.get('docker', {})
|
||||||
|
DOCKER_ENABLED = _docker.get('enabled', False)
|
||||||
|
DOCKER_HOSTS = _docker.get('hosts', [])
|
||||||
|
|
||||||
|
# Categories
|
||||||
|
CATEGORIES = _config.get('categories', {
|
||||||
|
"Infrastructure": {"color": "blue", "icon": "server"},
|
||||||
|
"Media": {"color": "purple", "icon": "film"},
|
||||||
|
"Monitoring": {"color": "amber", "icon": "chart"},
|
||||||
|
"Apps": {"color": "emerald", "icon": "cog"},
|
||||||
|
"Home": {"color": "cyan", "icon": "home"},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Service groups
|
||||||
|
SERVICE_GROUPS = _config.get('service_groups', {})
|
||||||
|
|
||||||
|
# Load services from config
|
||||||
|
def _load_services() -> List[Service]:
|
||||||
|
"""Load services from YAML config."""
|
||||||
|
services = []
|
||||||
|
for svc in _config.get('services', []):
|
||||||
|
health_check = None
|
||||||
|
if 'health_check' in svc:
|
||||||
|
health_check = svc['health_check']
|
||||||
|
|
||||||
|
services.append(Service(
|
||||||
|
name=svc['name'],
|
||||||
|
url=svc['url'],
|
||||||
|
ip=svc['ip'],
|
||||||
|
port=svc['port'],
|
||||||
|
category=svc['category'],
|
||||||
|
icon=svc.get('icon', 'server'),
|
||||||
|
favorite=svc.get('favorite', False),
|
||||||
|
critical=svc.get('critical', False),
|
||||||
|
group=svc.get('group'),
|
||||||
|
health_check=health_check,
|
||||||
|
))
|
||||||
|
return services
|
||||||
|
|
||||||
|
SERVICES = _load_services()
|
||||||
|
|
||||||
|
# Build SERVICE_CHECK_OVERRIDES from health_check configs
|
||||||
|
SERVICE_CHECK_OVERRIDES = {}
|
||||||
|
for svc in SERVICES:
|
||||||
|
if svc.health_check:
|
||||||
|
SERVICE_CHECK_OVERRIDES[svc.name] = (
|
||||||
|
svc.health_check.get('url', f"http://{svc.ip}:{svc.port}/"),
|
||||||
|
svc.health_check.get('timeout', 5.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Settings (user preferences, stored in settings.json)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"refresh_interval": REFRESH_INTERVAL,
|
||||||
|
"theme": _config.get('dashboard', {}).get('theme', 'dark'),
|
||||||
|
"favorites": _config.get('favorites', []),
|
||||||
|
"collapsed_categories": [],
|
||||||
|
"show_response_times": _config.get('dashboard', {}).get('show_response_times', True),
|
||||||
|
"show_icons": _config.get('dashboard', {}).get('show_icons', True),
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_settings():
|
||||||
|
try:
|
||||||
|
if os.path.exists(SETTINGS_FILE):
|
||||||
|
with open(SETTINGS_FILE, 'r') as f:
|
||||||
|
return {**DEFAULT_SETTINGS, **json.load(f)}
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return DEFAULT_SETTINGS.copy()
|
||||||
|
|
||||||
|
def save_settings(settings):
|
||||||
|
try:
|
||||||
|
with open(SETTINGS_FILE, 'w') as f:
|
||||||
|
json.dump(settings, f, indent=2)
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Helper Functions
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def get_services_by_category():
|
||||||
|
categories = {}
|
||||||
|
for service in SERVICES:
|
||||||
|
if service.category not in categories:
|
||||||
|
categories[service.category] = []
|
||||||
|
categories[service.category].append(service)
|
||||||
|
return categories
|
||||||
|
|
||||||
|
def get_favorites():
|
||||||
|
settings = load_settings()
|
||||||
|
fav_names = settings.get("favorites", [])
|
||||||
|
return [s for s in SERVICES if s.name in fav_names or s.favorite]
|
||||||
|
|
||||||
|
def get_critical_services():
|
||||||
|
return [s for s in SERVICES if s.critical]
|
||||||
|
|
||||||
|
def get_service_icon(icon_name):
|
||||||
|
return SERVICE_ICONS.get(icon_name, SERVICE_ICONS["server"])
|
||||||
|
|
||||||
|
def reload_config():
|
||||||
|
"""Reload configuration from files (for runtime updates)."""
|
||||||
|
global _config, SERVICES, SERVICE_CHECK_OVERRIDES, CATEGORIES, SERVICE_GROUPS
|
||||||
|
global PROXMOX_NODES, PROXMOX_API_TOKEN, PROXMOX_API_SECRET
|
||||||
|
global PBS_URL, PBS_API_TOKEN, PBS_API_SECRET
|
||||||
|
global GO2RTC_URL, CAMERAS, DOCKER_HOSTS
|
||||||
|
|
||||||
|
_config = load_config()
|
||||||
|
SERVICES = _load_services()
|
||||||
|
|
||||||
|
# Rebuild overrides
|
||||||
|
SERVICE_CHECK_OVERRIDES.clear()
|
||||||
|
for svc in SERVICES:
|
||||||
|
if svc.health_check:
|
||||||
|
SERVICE_CHECK_OVERRIDES[svc.name] = (
|
||||||
|
svc.health_check.get('url', f"http://{svc.ip}:{svc.port}/"),
|
||||||
|
svc.health_check.get('timeout', 5.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update other config values
|
||||||
|
_proxmox = _config.get('proxmox', {})
|
||||||
|
PROXMOX_NODES = _proxmox.get('nodes', [])
|
||||||
|
PROXMOX_API_TOKEN = _proxmox.get('api_token', '')
|
||||||
|
PROXMOX_API_SECRET = _proxmox.get('api_secret', '')
|
||||||
|
|
||||||
|
_pbs = _config.get('pbs', {})
|
||||||
|
PBS_URL = _pbs.get('url', '')
|
||||||
|
PBS_API_TOKEN = _pbs.get('api_token', '')
|
||||||
|
PBS_API_SECRET = _pbs.get('api_secret', '')
|
||||||
|
|
||||||
|
_cameras = _config.get('cameras', {})
|
||||||
|
GO2RTC_URL = _cameras.get('go2rtc_url', '')
|
||||||
|
CAMERAS = _cameras.get('streams', [])
|
||||||
|
|
||||||
|
DOCKER_HOSTS = _config.get('docker', {}).get('hosts', [])
|
||||||
|
CATEGORIES = _config.get('categories', CATEGORIES)
|
||||||
|
SERVICE_GROUPS = _config.get('service_groups', {})
|
||||||
298
app/main.py
Normal file
298
app/main.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"""Enhanced FastAPI dashboard v2 with all integrations."""
|
||||||
|
from fastapi import FastAPI, Request, Form
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse, RedirectResponse
|
||||||
|
import httpx
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.config import (
|
||||||
|
SERVICES, PROXMOX_NODES, PROXMOX_API_TOKEN, PROXMOX_API_SECRET,
|
||||||
|
PBS_URL, PBS_API_TOKEN, PBS_API_SECRET,
|
||||||
|
CATEGORIES, GO2RTC_URL, CAMERAS, SABNZBD_URL, SABNZBD_API_KEY,
|
||||||
|
UPTIME_KUMA_URL, UPTIME_KUMA_STATUS_PAGE, DOCKER_HOSTS,
|
||||||
|
PROMETHEUS_URL, SERVICE_ICONS, SERVICE_GROUPS,
|
||||||
|
get_services_by_category, get_favorites, get_critical_services, get_service_icon,
|
||||||
|
load_settings, save_settings, DEFAULT_SETTINGS
|
||||||
|
)
|
||||||
|
from app.services.health import (
|
||||||
|
check_all_services, get_all_proxmox_metrics, get_camera_list,
|
||||||
|
get_sabnzbd_queue, get_uptime_kuma_status, get_docker_containers,
|
||||||
|
get_docker_container_counts, get_pbs_status, get_storage_pools,
|
||||||
|
get_recent_events, get_cluster_uptime, get_prometheus_metrics
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI(title="DeathStar Dashboard", version="2.0.0")
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
# Add custom filters
|
||||||
|
templates.env.globals["get_service_icon"] = get_service_icon
|
||||||
|
templates.env.globals["SERVICE_ICONS"] = SERVICE_ICONS
|
||||||
|
|
||||||
|
last_check = {"time": None}
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
"""Main dashboard page."""
|
||||||
|
services_status = await check_all_services(SERVICES)
|
||||||
|
nodes_status = await get_all_proxmox_metrics(PROXMOX_NODES, PROXMOX_API_TOKEN, PROXMOX_API_SECRET)
|
||||||
|
docker_counts = await get_docker_container_counts(DOCKER_HOSTS)
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
online_count = sum(1 for s in services_status.values() if s.status == "online")
|
||||||
|
total_count = len(services_status)
|
||||||
|
critical_down = [s.name for s in get_critical_services()
|
||||||
|
if services_status.get(s.name) and services_status[s.name].status != "online"]
|
||||||
|
cluster_uptime = get_cluster_uptime(nodes_status)
|
||||||
|
|
||||||
|
last_check["time"] = datetime.now()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"services_by_category": get_services_by_category(),
|
||||||
|
"services_status": services_status,
|
||||||
|
"nodes_status": nodes_status,
|
||||||
|
"favorites": get_favorites(),
|
||||||
|
"categories": CATEGORIES,
|
||||||
|
"online_count": online_count,
|
||||||
|
"total_count": total_count,
|
||||||
|
"critical_down": critical_down,
|
||||||
|
"last_check": last_check["time"].strftime("%H:%M:%S") if last_check["time"] else "Never",
|
||||||
|
"cameras": CAMERAS,
|
||||||
|
"go2rtc_url": GO2RTC_URL,
|
||||||
|
"docker_counts": docker_counts,
|
||||||
|
"cluster_uptime": cluster_uptime,
|
||||||
|
"settings": settings,
|
||||||
|
"service_groups": SERVICE_GROUPS,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
@app.get("/api/services", response_class=HTMLResponse)
|
||||||
|
async def api_services(request: Request):
|
||||||
|
"""HTMX endpoint for services."""
|
||||||
|
services_status = await check_all_services(SERVICES)
|
||||||
|
return templates.TemplateResponse("partials/services.html", {
|
||||||
|
"request": request,
|
||||||
|
"services_by_category": get_services_by_category(),
|
||||||
|
"services_status": services_status,
|
||||||
|
"categories": CATEGORIES,
|
||||||
|
"service_groups": SERVICE_GROUPS,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/nodes", response_class=HTMLResponse)
|
||||||
|
async def api_nodes(request: Request):
|
||||||
|
"""HTMX endpoint for Proxmox nodes."""
|
||||||
|
nodes_status = await get_all_proxmox_metrics(PROXMOX_NODES, PROXMOX_API_TOKEN, PROXMOX_API_SECRET)
|
||||||
|
cluster_uptime = get_cluster_uptime(nodes_status)
|
||||||
|
return templates.TemplateResponse("partials/nodes.html", {
|
||||||
|
"request": request,
|
||||||
|
"nodes_status": nodes_status,
|
||||||
|
"cluster_uptime": cluster_uptime,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/nodes-expanded", response_class=HTMLResponse)
|
||||||
|
async def api_nodes_expanded(request: Request):
|
||||||
|
"""HTMX endpoint for expanded Proxmox nodes with VMs/containers."""
|
||||||
|
nodes_status = await get_all_proxmox_metrics(PROXMOX_NODES, PROXMOX_API_TOKEN, PROXMOX_API_SECRET)
|
||||||
|
return templates.TemplateResponse("partials/nodes_expanded.html", {
|
||||||
|
"request": request,
|
||||||
|
"nodes_status": nodes_status,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/favorites", response_class=HTMLResponse)
|
||||||
|
async def api_favorites(request: Request):
|
||||||
|
"""HTMX endpoint for favorites."""
|
||||||
|
services_status = await check_all_services(SERVICES)
|
||||||
|
return templates.TemplateResponse("partials/favorites.html", {
|
||||||
|
"request": request,
|
||||||
|
"favorites": get_favorites(),
|
||||||
|
"services_status": services_status,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/cameras", response_class=HTMLResponse)
|
||||||
|
async def api_cameras(request: Request):
|
||||||
|
"""HTMX endpoint for cameras."""
|
||||||
|
return templates.TemplateResponse("partials/cameras.html", {
|
||||||
|
"request": request,
|
||||||
|
"cameras": CAMERAS,
|
||||||
|
"go2rtc_url": GO2RTC_URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/downloads", response_class=HTMLResponse)
|
||||||
|
async def api_downloads(request: Request):
|
||||||
|
"""HTMX endpoint for downloads."""
|
||||||
|
queue = await get_sabnzbd_queue(SABNZBD_URL, SABNZBD_API_KEY)
|
||||||
|
return templates.TemplateResponse("partials/downloads.html", {
|
||||||
|
"request": request,
|
||||||
|
"queue": queue,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/status-banner", response_class=HTMLResponse)
|
||||||
|
async def api_status_banner(request: Request):
|
||||||
|
"""HTMX endpoint for status banner."""
|
||||||
|
services_status = await check_all_services(SERVICES)
|
||||||
|
critical_down = [s.name for s in get_critical_services()
|
||||||
|
if services_status.get(s.name) and services_status[s.name].status != "online"]
|
||||||
|
online_count = sum(1 for s in services_status.values() if s.status == "online")
|
||||||
|
last_check["time"] = datetime.now()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/status_banner.html", {
|
||||||
|
"request": request,
|
||||||
|
"critical_down": critical_down,
|
||||||
|
"online_count": online_count,
|
||||||
|
"total_count": len(services_status),
|
||||||
|
"last_check": last_check["time"].strftime("%H:%M:%S"),
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/docker", response_class=HTMLResponse)
|
||||||
|
async def api_docker(request: Request):
|
||||||
|
"""HTMX endpoint for Docker containers."""
|
||||||
|
containers = await get_docker_containers(DOCKER_HOSTS)
|
||||||
|
counts = await get_docker_container_counts(DOCKER_HOSTS)
|
||||||
|
return templates.TemplateResponse("partials/docker.html", {
|
||||||
|
"request": request,
|
||||||
|
"containers": containers,
|
||||||
|
"hosts": DOCKER_HOSTS,
|
||||||
|
"counts": counts,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/uptime", response_class=HTMLResponse)
|
||||||
|
async def api_uptime(request: Request):
|
||||||
|
"""HTMX endpoint for Uptime Kuma status."""
|
||||||
|
uptime_data = await get_uptime_kuma_status(UPTIME_KUMA_URL, UPTIME_KUMA_STATUS_PAGE)
|
||||||
|
return templates.TemplateResponse("partials/uptime.html", {
|
||||||
|
"request": request,
|
||||||
|
"uptime": uptime_data,
|
||||||
|
"uptime_kuma_url": UPTIME_KUMA_URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/pbs", response_class=HTMLResponse)
|
||||||
|
async def api_pbs(request: Request):
|
||||||
|
"""HTMX endpoint for PBS status."""
|
||||||
|
pbs_status = await get_pbs_status(PBS_URL, PBS_API_TOKEN, PBS_API_SECRET)
|
||||||
|
return templates.TemplateResponse("partials/pbs.html", {
|
||||||
|
"request": request,
|
||||||
|
"pbs": pbs_status,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/storage", response_class=HTMLResponse)
|
||||||
|
async def api_storage(request: Request):
|
||||||
|
"""HTMX endpoint for storage pools."""
|
||||||
|
pools = await get_storage_pools(PROXMOX_NODES, PROXMOX_API_TOKEN, PROXMOX_API_SECRET)
|
||||||
|
return templates.TemplateResponse("partials/storage.html", {
|
||||||
|
"request": request,
|
||||||
|
"pools": pools,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/events", response_class=HTMLResponse)
|
||||||
|
async def api_events(request: Request):
|
||||||
|
"""HTMX endpoint for recent events."""
|
||||||
|
events = get_recent_events()
|
||||||
|
return templates.TemplateResponse("partials/events.html", {
|
||||||
|
"request": request,
|
||||||
|
"events": events[-10:],
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/camera-snapshot/{camera}")
|
||||||
|
async def camera_snapshot(camera: str):
|
||||||
|
"""Proxy camera snapshot from go2rtc."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(f"{GO2RTC_URL}/api/frame.jpeg?src={camera}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([response.content]),
|
||||||
|
media_type="image/jpeg",
|
||||||
|
headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@app.post("/api/webrtc")
|
||||||
|
async def webrtc_proxy(request: Request, src: str):
|
||||||
|
"""Proxy WebRTC offers to go2rtc"""
|
||||||
|
try:
|
||||||
|
body = await request.body()
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{GO2RTC_URL}/api/webrtc?src={src}",
|
||||||
|
content=body,
|
||||||
|
headers={"Content-Type": "application/sdp"}
|
||||||
|
)
|
||||||
|
from fastapi import Response
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
media_type="application/sdp"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
from fastapi import Response
|
||||||
|
return Response(content=str(e), status_code=500)
|
||||||
|
|
||||||
|
return StreamingResponse(iter([b""]), media_type="image/jpeg")
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
async def api_status_json():
|
||||||
|
"""JSON endpoint for all status data."""
|
||||||
|
services_status = await check_all_services(SERVICES)
|
||||||
|
nodes_status = await get_all_proxmox_metrics(PROXMOX_NODES, PROXMOX_API_TOKEN, PROXMOX_API_SECRET)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"services": {name: {"status": s.status, "response_time_ms": s.response_time_ms}
|
||||||
|
for name, s in services_status.items()},
|
||||||
|
"nodes": [{"name": n.name, "status": n.status, "cpu": n.cpu_percent,
|
||||||
|
"memory": n.memory_percent, "disk": n.disk_percent, "uptime_hours": n.uptime_hours}
|
||||||
|
for n in nodes_status]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Settings page
|
||||||
|
@app.get("/cameras", response_class=HTMLResponse)
|
||||||
|
async def cameras_page(request: Request):
|
||||||
|
"""Full camera viewer page - mobile friendly."""
|
||||||
|
from datetime import datetime
|
||||||
|
return templates.TemplateResponse("cameras.html", {
|
||||||
|
"request": request,
|
||||||
|
"cameras": CAMERAS,
|
||||||
|
"go2rtc_url": GO2RTC_URL,
|
||||||
|
"now": int(datetime.now().timestamp()),
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(request: Request):
|
||||||
|
"""Settings page."""
|
||||||
|
settings = load_settings()
|
||||||
|
return templates.TemplateResponse("settings.html", {
|
||||||
|
"request": request,
|
||||||
|
"settings": settings,
|
||||||
|
"all_services": SERVICES,
|
||||||
|
"categories": CATEGORIES,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.post("/settings")
|
||||||
|
async def save_settings_handler(request: Request):
|
||||||
|
"""Save settings."""
|
||||||
|
form = await request.form()
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
# Update settings from form
|
||||||
|
settings["refresh_interval"] = int(form.get("refresh_interval", 30))
|
||||||
|
settings["theme"] = form.get("theme", "dark")
|
||||||
|
settings["show_response_times"] = form.get("show_response_times") == "on"
|
||||||
|
settings["show_icons"] = form.get("show_icons") == "on"
|
||||||
|
|
||||||
|
# Handle favorites (multi-select)
|
||||||
|
favorites = form.getlist("favorites")
|
||||||
|
if favorites:
|
||||||
|
settings["favorites"] = favorites
|
||||||
|
|
||||||
|
save_settings(settings)
|
||||||
|
return RedirectResponse(url="/settings?saved=1", status_code=303)
|
||||||
|
|
||||||
|
@app.get("/api/settings", response_class=JSONResponse)
|
||||||
|
async def get_settings_api():
|
||||||
|
"""Get settings as JSON."""
|
||||||
|
return load_settings()
|
||||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
391
app/services/health.py
Normal file
391
app/services/health.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
"""Enhanced services module v2 with PBS, VM/LXC, storage pools, events."""
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
import httpx
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthStatus:
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
response_time_ms: Optional[float] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NodeStatus:
|
||||||
|
name: str
|
||||||
|
ip: str
|
||||||
|
status: str
|
||||||
|
cpu_percent: Optional[float] = None
|
||||||
|
memory_percent: Optional[float] = None
|
||||||
|
memory_used_gb: Optional[float] = None
|
||||||
|
memory_total_gb: Optional[float] = None
|
||||||
|
disk_percent: Optional[float] = None
|
||||||
|
uptime_hours: Optional[float] = None
|
||||||
|
vms: List[Dict] = field(default_factory=list)
|
||||||
|
containers: List[Dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DockerContainer:
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
state: str
|
||||||
|
image: str
|
||||||
|
host: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UptimeMonitor:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
status: int
|
||||||
|
ping: Optional[int] = None
|
||||||
|
heartbeats: Optional[List[Dict]] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PBSStatus:
|
||||||
|
status: str
|
||||||
|
datastore_usage: List[Dict] = field(default_factory=list)
|
||||||
|
last_backup: Optional[str] = None
|
||||||
|
total_size_gb: float = 0
|
||||||
|
used_size_gb: float = 0
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StoragePool:
|
||||||
|
name: str
|
||||||
|
node: str
|
||||||
|
total_gb: float
|
||||||
|
used_gb: float
|
||||||
|
avail_gb: float
|
||||||
|
percent_used: float
|
||||||
|
pool_type: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StatusEvent:
|
||||||
|
timestamp: datetime
|
||||||
|
service: str
|
||||||
|
old_status: str
|
||||||
|
new_status: str
|
||||||
|
|
||||||
|
# Recent events storage (in-memory, last 20)
|
||||||
|
recent_events: deque = deque(maxlen=20)
|
||||||
|
last_status_cache: Dict[str, str] = {}
|
||||||
|
|
||||||
|
SERVICE_CHECK_OVERRIDES = {
|
||||||
|
"OPNsense": ("https://192.168.1.1:8443/", 10.0),
|
||||||
|
"Vaultwarden": ("https://vault.deathstar-home.one/", 5.0),
|
||||||
|
"Immich": ("http://192.168.1.54:2283/", 5.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def check_service(client: httpx.AsyncClient, service) -> HealthStatus:
|
||||||
|
"""Check if a service is reachable."""
|
||||||
|
global last_status_cache, recent_events
|
||||||
|
|
||||||
|
if service.name in SERVICE_CHECK_OVERRIDES:
|
||||||
|
check_url, timeout = SERVICE_CHECK_OVERRIDES[service.name]
|
||||||
|
else:
|
||||||
|
https_ports = [443, 8006, 8007, 8443, 9443]
|
||||||
|
scheme = "https" if service.port in https_ports else "http"
|
||||||
|
check_url = f"{scheme}://{service.ip}:{service.port}/"
|
||||||
|
timeout = 5.0
|
||||||
|
|
||||||
|
start = asyncio.get_event_loop().time()
|
||||||
|
try:
|
||||||
|
response = await client.get(check_url, timeout=timeout, follow_redirects=True)
|
||||||
|
elapsed = (asyncio.get_event_loop().time() - start) * 1000
|
||||||
|
new_status = "online" if response.status_code < 500 else "degraded"
|
||||||
|
result = HealthStatus(name=service.name, status=new_status, response_time_ms=round(elapsed, 1))
|
||||||
|
except:
|
||||||
|
new_status = "offline"
|
||||||
|
result = HealthStatus(name=service.name, status="offline")
|
||||||
|
|
||||||
|
# Track status changes
|
||||||
|
old_status = last_status_cache.get(service.name)
|
||||||
|
if old_status and old_status != new_status:
|
||||||
|
recent_events.append(StatusEvent(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
service=service.name,
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=new_status
|
||||||
|
))
|
||||||
|
last_status_cache[service.name] = new_status
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def check_all_services(services) -> Dict[str, HealthStatus]:
|
||||||
|
"""Check all services concurrently."""
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=10.0) as client:
|
||||||
|
tasks = [check_service(client, s) for s in services]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
return {r.name: r for r in results}
|
||||||
|
|
||||||
|
async def get_proxmox_node_metrics(client: httpx.AsyncClient, node: Dict, token: str, secret: str) -> NodeStatus:
|
||||||
|
"""Get Proxmox node metrics including VMs and containers."""
|
||||||
|
base_url = f"https://{node['ip']}:{node['port']}/api2/json"
|
||||||
|
headers = {"Authorization": f"PVEAPIToken={token}={secret}"}
|
||||||
|
|
||||||
|
result = NodeStatus(name=node["name"], ip=node["ip"], status="offline")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get node status
|
||||||
|
response = await client.get(f"{base_url}/nodes/{node['name']}/status", headers=headers, timeout=5.0)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()["data"]
|
||||||
|
cpu = data.get("cpu", 0) * 100
|
||||||
|
mem_used = data.get("memory", {}).get("used", 0)
|
||||||
|
mem_total = data.get("memory", {}).get("total", 1)
|
||||||
|
mem_pct = (mem_used / mem_total) * 100 if mem_total else 0
|
||||||
|
disk_used = data.get("rootfs", {}).get("used", 0)
|
||||||
|
disk_total = data.get("rootfs", {}).get("total", 1)
|
||||||
|
disk_pct = (disk_used / disk_total) * 100 if disk_total else 0
|
||||||
|
uptime_sec = data.get("uptime", 0)
|
||||||
|
|
||||||
|
result.status = "online"
|
||||||
|
result.cpu_percent = round(cpu, 1)
|
||||||
|
result.memory_percent = round(mem_pct, 1)
|
||||||
|
result.memory_used_gb = round(mem_used / (1024**3), 1)
|
||||||
|
result.memory_total_gb = round(mem_total / (1024**3), 1)
|
||||||
|
result.disk_percent = round(disk_pct, 1)
|
||||||
|
result.uptime_hours = round(uptime_sec / 3600, 1)
|
||||||
|
|
||||||
|
# Get VMs
|
||||||
|
vm_response = await client.get(f"{base_url}/nodes/{node['name']}/qemu", headers=headers, timeout=5.0)
|
||||||
|
if vm_response.status_code == 200:
|
||||||
|
for vm in vm_response.json().get("data", []):
|
||||||
|
result.vms.append({
|
||||||
|
"vmid": vm.get("vmid"),
|
||||||
|
"name": vm.get("name", f"VM {vm.get('vmid')}"),
|
||||||
|
"status": vm.get("status"),
|
||||||
|
"mem": round(vm.get("mem", 0) / (1024**3), 1) if vm.get("mem") else 0,
|
||||||
|
"cpu": round(vm.get("cpu", 0) * 100, 1) if vm.get("cpu") else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get containers
|
||||||
|
ct_response = await client.get(f"{base_url}/nodes/{node['name']}/lxc", headers=headers, timeout=5.0)
|
||||||
|
if ct_response.status_code == 200:
|
||||||
|
for ct in ct_response.json().get("data", []):
|
||||||
|
result.containers.append({
|
||||||
|
"vmid": ct.get("vmid"),
|
||||||
|
"name": ct.get("name", f"CT {ct.get('vmid')}"),
|
||||||
|
"status": ct.get("status"),
|
||||||
|
"mem": round(ct.get("mem", 0) / (1024**3), 1) if ct.get("mem") else 0,
|
||||||
|
"cpu": round(ct.get("cpu", 0) * 100, 1) if ct.get("cpu") else 0,
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_all_proxmox_metrics(nodes, token: str, secret: str) -> List[NodeStatus]:
|
||||||
|
"""Get metrics for all Proxmox nodes."""
|
||||||
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
|
tasks = [get_proxmox_node_metrics(client, n, token, secret) for n in nodes]
|
||||||
|
return await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
async def get_pbs_status(url: str, token: str, secret: str) -> PBSStatus:
|
||||||
|
"""Get PBS backup server status."""
|
||||||
|
result = PBSStatus(status="offline")
|
||||||
|
headers = {"Authorization": f"PBSAPIToken={token}:{secret}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=10.0) as client:
|
||||||
|
# Get datastore status
|
||||||
|
ds_response = await client.get(f"{url}/api2/json/status/datastore-usage", headers=headers)
|
||||||
|
if ds_response.status_code == 200:
|
||||||
|
result.status = "online"
|
||||||
|
for ds in ds_response.json().get("data", []):
|
||||||
|
total = ds.get("total", 0)
|
||||||
|
used = ds.get("used", 0)
|
||||||
|
result.datastore_usage.append({
|
||||||
|
"name": ds.get("store"),
|
||||||
|
"total_gb": round(total / (1024**3), 1),
|
||||||
|
"used_gb": round(used / (1024**3), 1),
|
||||||
|
"percent": round((used / total) * 100, 1) if total else 0,
|
||||||
|
})
|
||||||
|
result.total_size_gb += total / (1024**3)
|
||||||
|
result.used_size_gb += used / (1024**3)
|
||||||
|
|
||||||
|
# Try to get last backup task
|
||||||
|
tasks_response = await client.get(f"{url}/api2/json/nodes/localhost/tasks", headers=headers)
|
||||||
|
if tasks_response.status_code == 200:
|
||||||
|
tasks = tasks_response.json().get("data", [])
|
||||||
|
backup_tasks = [t for t in tasks if t.get("type") == "backup"]
|
||||||
|
if backup_tasks:
|
||||||
|
last = backup_tasks[0]
|
||||||
|
result.last_backup = datetime.fromtimestamp(last.get("starttime", 0)).strftime("%Y-%m-%d %H:%M")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_storage_pools(nodes, token: str, secret: str) -> List[StoragePool]:
|
||||||
|
"""Get storage pool info from all Proxmox nodes."""
|
||||||
|
pools = []
|
||||||
|
headers = {"Authorization": f"PVEAPIToken={token}={secret}"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=10.0) as client:
|
||||||
|
for node in nodes:
|
||||||
|
try:
|
||||||
|
url = f"https://{node['ip']}:{node['port']}/api2/json/nodes/{node['name']}/storage"
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
if response.status_code == 200:
|
||||||
|
for storage in response.json().get("data", []):
|
||||||
|
if storage.get("enabled") and storage.get("total"):
|
||||||
|
total = storage.get("total", 0)
|
||||||
|
used = storage.get("used", 0)
|
||||||
|
avail = storage.get("avail", 0)
|
||||||
|
pools.append(StoragePool(
|
||||||
|
name=storage.get("storage"),
|
||||||
|
node=node["name"],
|
||||||
|
total_gb=round(total / (1024**3), 1),
|
||||||
|
used_gb=round(used / (1024**3), 1),
|
||||||
|
avail_gb=round(avail / (1024**3), 1),
|
||||||
|
percent_used=round((used / total) * 100, 1) if total else 0,
|
||||||
|
pool_type=storage.get("type", "unknown"),
|
||||||
|
))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return pools
|
||||||
|
|
||||||
|
async def get_docker_containers(hosts: List[Dict]) -> List[DockerContainer]:
|
||||||
|
"""Get Docker containers via docker-socket-proxy."""
|
||||||
|
containers = []
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
for host in hosts:
|
||||||
|
try:
|
||||||
|
url = f"http://{host['ip']}:{host['port']}/containers/json?all=true"
|
||||||
|
response = await client.get(url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
for c in response.json():
|
||||||
|
name = c.get("Names", ["/unknown"])[0].lstrip("/")
|
||||||
|
if name == "docker-socket-proxy":
|
||||||
|
continue
|
||||||
|
containers.append(DockerContainer(
|
||||||
|
name=name,
|
||||||
|
status=c.get("Status", ""),
|
||||||
|
state=c.get("State", "unknown"),
|
||||||
|
image=c.get("Image", "").split("/")[-1].split(":")[0],
|
||||||
|
host=host["name"]
|
||||||
|
))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return containers
|
||||||
|
|
||||||
|
async def get_docker_container_counts(hosts: List[Dict]) -> Dict[str, int]:
|
||||||
|
"""Get container counts per host."""
|
||||||
|
counts = {}
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
for host in hosts:
|
||||||
|
try:
|
||||||
|
url = f"http://{host['ip']}:{host['port']}/containers/json"
|
||||||
|
response = await client.get(url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
# Subtract 1 for docker-socket-proxy
|
||||||
|
count = len([c for c in response.json() if "docker-socket-proxy" not in c.get("Names", [""])[0]])
|
||||||
|
counts[host["name"]] = count
|
||||||
|
except:
|
||||||
|
counts[host["name"]] = 0
|
||||||
|
return counts
|
||||||
|
|
||||||
|
async def get_uptime_kuma_status(url: str, status_page: str = "uptime") -> Dict:
|
||||||
|
"""Get Uptime Kuma status."""
|
||||||
|
result = {"monitors": [], "summary": {"up": 0, "down": 0, "total": 0}}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
hb_response = await client.get(f"{url}/api/status-page/heartbeat/{status_page}")
|
||||||
|
info_response = await client.get(f"{url}/api/status-page/{status_page}")
|
||||||
|
|
||||||
|
if hb_response.status_code == 200 and info_response.status_code == 200:
|
||||||
|
heartbeats = hb_response.json().get("heartbeatList", {})
|
||||||
|
info = info_response.json()
|
||||||
|
|
||||||
|
for group in info.get("publicGroupList", []):
|
||||||
|
for monitor in group.get("monitorList", []):
|
||||||
|
monitor_id = str(monitor.get("id"))
|
||||||
|
monitor_heartbeats = heartbeats.get(monitor_id, [])
|
||||||
|
latest_status = 0
|
||||||
|
latest_ping = None
|
||||||
|
if monitor_heartbeats:
|
||||||
|
latest = monitor_heartbeats[-1]
|
||||||
|
latest_status = latest.get("status", 0)
|
||||||
|
latest_ping = latest.get("ping")
|
||||||
|
recent_hb = monitor_heartbeats[-20:] if monitor_heartbeats else []
|
||||||
|
result["monitors"].append(UptimeMonitor(
|
||||||
|
id=monitor.get("id"),
|
||||||
|
name=monitor.get("name"),
|
||||||
|
status=latest_status,
|
||||||
|
ping=latest_ping,
|
||||||
|
heartbeats=[{"status": h.get("status", 0), "ping": h.get("ping")} for h in recent_hb]
|
||||||
|
))
|
||||||
|
if latest_status == 1:
|
||||||
|
result["summary"]["up"] += 1
|
||||||
|
else:
|
||||||
|
result["summary"]["down"] += 1
|
||||||
|
result["summary"]["total"] += 1
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_prometheus_metrics(url: str, queries: Dict[str, str]) -> Dict[str, Any]:
|
||||||
|
"""Query Prometheus for metrics."""
|
||||||
|
results = {}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
for name, query in queries.items():
|
||||||
|
response = await client.get(f"{url}/api/v1/query", params={"query": query})
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json().get("data", {}).get("result", [])
|
||||||
|
if data:
|
||||||
|
results[name] = float(data[0].get("value", [0, 0])[1])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def get_camera_list(go2rtc_url: str) -> List[str]:
|
||||||
|
"""Get camera list from go2rtc."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(f"{go2rtc_url}/api/streams")
|
||||||
|
if response.status_code == 200:
|
||||||
|
return list(response.json().keys())
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_sabnzbd_queue(url: str, api_key: str = "") -> Dict:
|
||||||
|
"""Get Sabnzbd download queue."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
params = {"mode": "queue", "output": "json"}
|
||||||
|
if api_key:
|
||||||
|
params["apikey"] = api_key
|
||||||
|
response = await client.get(f"{url}/api", params=params)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json().get("queue", {})
|
||||||
|
return {
|
||||||
|
"speed": data.get("speed", "0 B/s"),
|
||||||
|
"size_left": data.get("sizeleft", "0 B"),
|
||||||
|
"eta": data.get("timeleft", "Unknown"),
|
||||||
|
"downloading": len(data.get("slots", [])),
|
||||||
|
"items": [
|
||||||
|
{"name": s.get("filename", "Unknown")[:40], "progress": float(s.get("percentage", 0))}
|
||||||
|
for s in data.get("slots", [])[:3]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {"speed": "N/A", "downloading": 0, "items": []}
|
||||||
|
|
||||||
|
def get_recent_events() -> List[StatusEvent]:
|
||||||
|
"""Get recent status change events."""
|
||||||
|
return list(recent_events)
|
||||||
|
|
||||||
|
def get_cluster_uptime(nodes: List[NodeStatus]) -> float:
|
||||||
|
"""Calculate total cluster uptime in hours."""
|
||||||
|
total = 0
|
||||||
|
for node in nodes:
|
||||||
|
if node.uptime_hours:
|
||||||
|
total += node.uptime_hours
|
||||||
|
return round(total, 1)
|
||||||
145
app/templates/base.html
Normal file
145
app/templates/base.html
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DeathStar Homelab</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
dark: { 900: '#0a0f1a', 800: '#111827', 700: '#1f2937', 600: '#374151' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
|
||||||
|
.status-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
|
.htmx-request { opacity: 0.5; }
|
||||||
|
body { background: linear-gradient(135deg, #0a0f1a 0%, #111827 100%); }
|
||||||
|
.card { background: rgba(31, 41, 55, 0.5); backdrop-filter: blur(10px); border: 1px solid rgba(75, 85, 99, 0.3); }
|
||||||
|
.card:hover { border-color: rgba(99, 102, 241, 0.5); }
|
||||||
|
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.5rem; }
|
||||||
|
.progress-bar { height: 4px; border-radius: 2px; background: rgba(75, 85, 99, 0.5); overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; border-radius: 2px; transition: width 0.3s ease; }
|
||||||
|
.collapsible-content { max-height: 1000px; overflow: hidden; transition: max-height 0.3s ease; }
|
||||||
|
.collapsed .collapsible-content { max-height: 0; }
|
||||||
|
.camera-thumb { aspect-ratio: 16/9; background: #1f2937; border-radius: 0.5rem; overflow: hidden; }
|
||||||
|
.camera-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
#search-modal { backdrop-filter: blur(4px); }
|
||||||
|
.favorite-card { background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%); }
|
||||||
|
.status-banner { background: linear-gradient(90deg, rgba(239, 68, 68, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%); }
|
||||||
|
.status-banner.all-good { background: linear-gradient(90deg, rgba(34, 197, 94, 0.1) 0%, rgba(34, 197, 94, 0.05) 100%); }
|
||||||
|
.response-fast { color: #10b981; }
|
||||||
|
.response-medium { color: #f59e0b; }
|
||||||
|
.response-slow { color: #ef4444; }
|
||||||
|
.svc-icon { width: 14px; height: 14px; flex-shrink: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 min-h-screen p-3">
|
||||||
|
<div class="max-w-[1920px] mx-auto">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Modal -->
|
||||||
|
<div id="search-modal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center">
|
||||||
|
<div class="bg-dark-800 rounded-xl p-4 w-full max-w-md mx-4 border border-gray-700">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||||
|
<input type="text" id="search-input" placeholder="Search services..." class="flex-1 bg-transparent border-none outline-none text-white placeholder-gray-500">
|
||||||
|
<kbd class="px-2 py-1 text-xs bg-dark-700 rounded text-gray-400">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
<div id="search-results" class="max-h-64 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Clock
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const el = document.getElementById('clock');
|
||||||
|
if (el) el.textContent = now.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
updateClock();
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const searchModal = document.getElementById('search-modal');
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchModal.classList.remove('hidden');
|
||||||
|
searchModal.classList.add('flex');
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
searchModal.classList.add('hidden');
|
||||||
|
searchModal.classList.remove('flex');
|
||||||
|
searchInput.value = '';
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
}
|
||||||
|
if (e.key === 'r' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT') {
|
||||||
|
refreshAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchModal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === searchModal) {
|
||||||
|
searchModal.classList.add('hidden');
|
||||||
|
searchModal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function(e) {
|
||||||
|
const query = e.target.value.toLowerCase();
|
||||||
|
if (query.length < 2) { searchResults.innerHTML = ''; return; }
|
||||||
|
const cards = document.querySelectorAll('.service-card');
|
||||||
|
const matches = [];
|
||||||
|
cards.forEach(card => {
|
||||||
|
const name = card.dataset.name?.toLowerCase() || '';
|
||||||
|
if (name.includes(query)) {
|
||||||
|
const url = card.querySelector('a')?.href || '#';
|
||||||
|
const status = card.querySelector('.status-dot')?.classList.contains('bg-emerald-500') ? 'online' : 'offline';
|
||||||
|
matches.push({ name: card.dataset.name, url, status });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
searchResults.innerHTML = matches.length === 0 ? '<div class="text-gray-500 text-sm py-2">No services found</div>' :
|
||||||
|
matches.map(m => `<a href="${m.url}" target="_blank" class="flex items-center gap-3 p-2 rounded hover:bg-dark-700"><span class="w-2 h-2 rounded-full ${m.status === 'online' ? 'bg-emerald-500' : 'bg-red-500'}"></span><span>${m.name}</span></a>`).join('');
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleCategory(header) {
|
||||||
|
const section = header.closest('.category-section');
|
||||||
|
section.classList.toggle('collapsed');
|
||||||
|
const icon = header.querySelector('.collapse-icon');
|
||||||
|
icon.style.transform = section.classList.contains('collapsed') ? 'rotate(-90deg)' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
htmx.trigger('#status-banner', 'refresh');
|
||||||
|
htmx.trigger('#favorites-container', 'refresh');
|
||||||
|
htmx.trigger('#nodes-container', 'refresh');
|
||||||
|
htmx.trigger('#services-container', 'refresh');
|
||||||
|
htmx.trigger('#cameras-container', 'refresh');
|
||||||
|
htmx.trigger('#downloads-container', 'refresh');
|
||||||
|
htmx.trigger('#docker-container', 'refresh');
|
||||||
|
htmx.trigger('#uptime-container', 'refresh');
|
||||||
|
htmx.trigger('#pbs-container', 'refresh');
|
||||||
|
htmx.trigger('#events-container', 'refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNodeExpand(nodeEl) {
|
||||||
|
const expanded = nodeEl.querySelector('.node-expanded');
|
||||||
|
if (expanded) expanded.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
322
app/templates/cameras.html
Normal file
322
app/templates/cameras.html
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="cameras-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-4 sticky top-0 bg-dark-900/95 backdrop-blur py-2 z-10">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/" class="text-gray-400 hover:text-white p-2 -ml-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h1 class="text-lg font-semibold text-white">Cameras</h1>
|
||||||
|
<span class="text-xs text-gray-500">{{ cameras | length }} cameras</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- View Mode -->
|
||||||
|
<div class="flex bg-dark-700 rounded-lg p-0.5">
|
||||||
|
<button onclick="setViewMode('grid')" id="btn-grid" class="view-btn active px-2 py-1 text-xs rounded-md">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="setViewMode('list')" id="btn-list" class="view-btn px-2 py-1 text-xs rounded-md">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Reconnect -->
|
||||||
|
<button onclick="reconnectAll()" class="p-2 text-gray-400 hover:text-white hover:bg-dark-700 rounded-lg" title="Reconnect all streams">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera Grid -->
|
||||||
|
<div id="camera-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{% for camera in cameras %}
|
||||||
|
<div class="camera-card" data-camera="{{ camera }}">
|
||||||
|
<div class="aspect-video bg-dark-800 rounded-t-xl overflow-hidden relative cursor-pointer" onclick="openFullscreen('{{ camera }}')">
|
||||||
|
<video id="video-{{ camera }}"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
autoplay muted playsinline
|
||||||
|
poster="{{ go2rtc_url }}/api/frame.jpeg?src={{ camera }}"></video>
|
||||||
|
|
||||||
|
<!-- Status indicator -->
|
||||||
|
<div id="status-{{ camera }}" class="absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></div>
|
||||||
|
|
||||||
|
<!-- Live indicator -->
|
||||||
|
<div class="absolute top-2 left-2">
|
||||||
|
<span class="live-badge flex items-center gap-1 px-1.5 py-0.5 bg-red-600/90 rounded text-[10px] text-white font-medium">
|
||||||
|
<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span>
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between bg-dark-700 rounded-b-xl px-3 py-2">
|
||||||
|
<span class="text-sm text-white font-medium truncate">{{ camera | replace("_", " ") }}</span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button onclick="event.stopPropagation(); reconnectCamera('{{ camera }}')" class="p-1.5 text-gray-400 hover:text-cyan-400 hover:bg-dark-600 rounded" title="Reconnect">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="event.stopPropagation(); openFullscreen('{{ camera }}')" class="p-1.5 text-gray-400 hover:text-white hover:bg-dark-600 rounded" title="Fullscreen">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fullscreen Modal -->
|
||||||
|
<div id="fullscreen-modal" class="fixed inset-0 bg-black z-50 hidden">
|
||||||
|
<div class="absolute top-4 left-4 right-4 flex items-center justify-between z-10">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 id="modal-title" class="text-white text-lg font-semibold drop-shadow-lg"></h2>
|
||||||
|
<span class="live-badge flex items-center gap-1 px-2 py-0.5 bg-red-600 rounded text-xs text-white font-medium">
|
||||||
|
<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span>
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeFullscreen()" class="p-2 bg-gray-800/80 hover:bg-gray-700 rounded-lg text-white">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
|
<video id="modal-video" class="max-w-full max-h-full" autoplay playsinline></video>
|
||||||
|
</div>
|
||||||
|
<button onclick="prevCamera()" class="absolute left-2 top-1/2 -translate-y-1/2 p-3 bg-black/50 hover:bg-black/70 rounded-full text-white">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="nextCamera()" class="absolute right-2 top-1/2 -translate-y-1/2 p-3 bg-black/50 hover:bg-black/70 rounded-full text-white">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 bg-black/60 rounded-full text-white text-sm">
|
||||||
|
<span id="modal-counter">1 / 12</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cameras-page { padding-bottom: env(safe-area-inset-bottom); }
|
||||||
|
.view-btn { color: #9ca3af; transition: all 0.15s; }
|
||||||
|
.view-btn.active { background: #374151; color: white; }
|
||||||
|
.view-btn:hover { color: white; }
|
||||||
|
|
||||||
|
@supports (padding: max(0px)) {
|
||||||
|
.cameras-page {
|
||||||
|
padding-left: max(0.75rem, env(safe-area-inset-left));
|
||||||
|
padding-right: max(0.75rem, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const cameras = {{ cameras | tojson }};
|
||||||
|
let currentCameraIndex = 0;
|
||||||
|
let peerConnections = {};
|
||||||
|
let fullscreenPC = null;
|
||||||
|
|
||||||
|
// Initialize WebRTC stream for a camera
|
||||||
|
async function initCameraStream(cameraId) {
|
||||||
|
const video = document.getElementById('video-' + cameraId);
|
||||||
|
const status = document.getElementById('status-' + cameraId);
|
||||||
|
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Close existing connection if any
|
||||||
|
if (peerConnections[cameraId]) {
|
||||||
|
peerConnections[cameraId].close();
|
||||||
|
}
|
||||||
|
|
||||||
|
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse';
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
peerConnections[cameraId] = pc;
|
||||||
|
|
||||||
|
pc.ontrack = (event) => {
|
||||||
|
video.srcObject = event.streams[0];
|
||||||
|
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-green-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
|
||||||
|
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-red-500';
|
||||||
|
setTimeout(() => initCameraStream(cameraId), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
|
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
const response = await fetch('/api/webrtc?src=' + cameraId, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/sdp' },
|
||||||
|
body: offer.sdp
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
|
|
||||||
|
const answer = await response.text();
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect ' + cameraId + ':', error);
|
||||||
|
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-red-500';
|
||||||
|
setTimeout(() => initCameraStream(cameraId), 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectCamera(cameraId) {
|
||||||
|
initCameraStream(cameraId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectAll() {
|
||||||
|
cameras.forEach(cam => initCameraStream(cam));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setViewMode(mode) {
|
||||||
|
const grid = document.getElementById('camera-grid');
|
||||||
|
const btnGrid = document.getElementById('btn-grid');
|
||||||
|
const btnList = document.getElementById('btn-list');
|
||||||
|
|
||||||
|
if (mode === 'list') {
|
||||||
|
grid.className = 'flex flex-col gap-3';
|
||||||
|
btnList.classList.add('active');
|
||||||
|
btnGrid.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
grid.className = 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3';
|
||||||
|
btnGrid.classList.add('active');
|
||||||
|
btnList.classList.remove('active');
|
||||||
|
}
|
||||||
|
localStorage.setItem('cameraViewMode', mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFullscreen(camera) {
|
||||||
|
currentCameraIndex = cameras.indexOf(camera);
|
||||||
|
const modal = document.getElementById('fullscreen-modal');
|
||||||
|
const video = document.getElementById('modal-video');
|
||||||
|
const title = document.getElementById('modal-title');
|
||||||
|
const counter = document.getElementById('modal-counter');
|
||||||
|
|
||||||
|
title.textContent = camera.replace(/_/g, ' ');
|
||||||
|
counter.textContent = (currentCameraIndex + 1) + ' / ' + cameras.length;
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
await initFullscreenStream(camera, video);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initFullscreenStream(cameraId, video) {
|
||||||
|
if (fullscreenPC) {
|
||||||
|
fullscreenPC.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
fullscreenPC = pc;
|
||||||
|
|
||||||
|
pc.ontrack = (event) => {
|
||||||
|
video.srcObject = event.streams[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
|
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/webrtc?src=' + cameraId, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/sdp' },
|
||||||
|
body: offer.sdp
|
||||||
|
});
|
||||||
|
|
||||||
|
const answer = await response.text();
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fullscreen stream error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFullscreen() {
|
||||||
|
const modal = document.getElementById('fullscreen-modal');
|
||||||
|
const video = document.getElementById('modal-video');
|
||||||
|
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
|
||||||
|
if (fullscreenPC) {
|
||||||
|
fullscreenPC.close();
|
||||||
|
fullscreenPC = null;
|
||||||
|
}
|
||||||
|
video.srcObject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevCamera() {
|
||||||
|
currentCameraIndex = (currentCameraIndex - 1 + cameras.length) % cameras.length;
|
||||||
|
openFullscreen(cameras[currentCameraIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextCamera() {
|
||||||
|
currentCameraIndex = (currentCameraIndex + 1) % cameras.length;
|
||||||
|
openFullscreen(cameras[currentCameraIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (!document.getElementById('fullscreen-modal').classList.contains('hidden')) {
|
||||||
|
if (e.key === 'Escape') closeFullscreen();
|
||||||
|
if (e.key === 'ArrowLeft') prevCamera();
|
||||||
|
if (e.key === 'ArrowRight') nextCamera();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let touchStartX = 0;
|
||||||
|
document.getElementById('fullscreen-modal').addEventListener('touchstart', (e) => {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
});
|
||||||
|
document.getElementById('fullscreen-modal').addEventListener('touchend', (e) => {
|
||||||
|
const diff = touchStartX - e.changedTouches[0].clientX;
|
||||||
|
if (Math.abs(diff) > 50) {
|
||||||
|
if (diff > 0) nextCamera();
|
||||||
|
else prevCamera();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const savedView = localStorage.getItem('cameraViewMode');
|
||||||
|
if (savedView) setViewMode(savedView);
|
||||||
|
|
||||||
|
// Stagger camera connections to avoid overwhelming the server
|
||||||
|
cameras.forEach((cam, index) => {
|
||||||
|
setTimeout(() => initCameraStream(cam), index * 500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
134
app/templates/dashboard.html
Normal file
134
app/templates/dashboard.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="mb-3">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<h1 class="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">DeathStar Homelab</h1>
|
||||||
|
<button onclick="refreshAll()" class="p-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors" title="Refresh (R)">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="document.getElementById('search-modal').classList.remove('hidden'); document.getElementById('search-modal').classList.add('flex'); document.getElementById('search-input').focus();" class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors text-xs text-gray-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||||
|
<span>Search</span>
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-dark-600 rounded text-xs">/</kbd>
|
||||||
|
</button>
|
||||||
|
<a href="/settings" class="p-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors" title="Settings">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-xs text-gray-400">
|
||||||
|
<span id="clock" class="font-mono"></span>
|
||||||
|
<span>{{ online_count }}/{{ total_count }} online</span>
|
||||||
|
<span>Updated: {{ last_check }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="status-banner" hx-get="/api/status-banner" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/status_banner.html" %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Favorites -->
|
||||||
|
<section class="mb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Quick Access</h2>
|
||||||
|
</div>
|
||||||
|
<div id="favorites-container" hx-get="/api/favorites" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/favorites.html" %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Proxmox Cluster -->
|
||||||
|
<section class="mb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Proxmox Cluster: NewHope</h2>
|
||||||
|
<span class="text-xs text-gray-500">{{ cluster_uptime }}h total uptime</span>
|
||||||
|
</div>
|
||||||
|
<div id="nodes-container" hx-get="/api/nodes" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/nodes.html" %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-3">
|
||||||
|
<!-- Services (3 cols) -->
|
||||||
|
<div class="lg:col-span-3">
|
||||||
|
<section id="services-container" hx-get="/api/services" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/services.html" %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Sidebar -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- PBS Backup -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Backups</h2>
|
||||||
|
</div>
|
||||||
|
<div id="pbs-container" hx-get="/api/pbs" hx-trigger="every 60s, refresh" hx-swap="innerHTML">
|
||||||
|
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent Events -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Recent Events</h2>
|
||||||
|
</div>
|
||||||
|
<div id="events-container" hx-get="/api/events" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Downloads -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Downloads</h2>
|
||||||
|
</div>
|
||||||
|
<div id="downloads-container" hx-get="/api/downloads" hx-trigger="every 15s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/downloads.html" %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cameras -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Cameras</h2>
|
||||||
|
</div>
|
||||||
|
<div id="cameras-container" hx-get="/api/cameras" hx-trigger="every 60s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/cameras.html" %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Docker -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Docker</h2>
|
||||||
|
<span class="text-xs text-gray-500">{% for name, count in docker_counts.items() %}{{ count }}{% if not loop.last %}+{% endif %}{% endfor %} containers</span>
|
||||||
|
</div>
|
||||||
|
<div id="docker-container" hx-get="/api/docker" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Uptime Kuma -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Uptime</h2>
|
||||||
|
</div>
|
||||||
|
<div id="uptime-container" hx-get="/api/uptime" hx-trigger="every 60s, refresh" hx-swap="innerHTML">
|
||||||
|
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
20
app/templates/partials/cameras.html
Normal file
20
app/templates/partials/cameras.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
{% for camera in cameras[:4] %}
|
||||||
|
<div class="camera-thumb relative group">
|
||||||
|
<img src="/api/camera-snapshot/{{ camera }}"
|
||||||
|
alt="{{ camera }}"
|
||||||
|
class="w-full h-full"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 56%22><rect fill=%22%231f2937%22 width=%22100%22 height=%2256%22/><text x=%2250%22 y=%2228%22 text-anchor=%22middle%22 fill=%22%236b7280%22 font-size=%228%22>No Signal</text></svg>'">
|
||||||
|
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<a href="{{ go2rtc_url }}/stream.html?src={{ camera }}" target="_blank" class="text-white text-xs hover:text-cyan-400">
|
||||||
|
{{ camera }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-1 left-1 text-[10px] text-white bg-black/50 px-1 rounded">{{ camera }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<a href="/cameras" class="block text-center text-xs text-gray-500 hover:text-cyan-400 mt-2">
|
||||||
|
View all {{ cameras | length }} cameras
|
||||||
|
</a>
|
||||||
20
app/templates/partials/docker.html
Normal file
20
app/templates/partials/docker.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<div class="space-y-2">
|
||||||
|
{% for host in hosts %}
|
||||||
|
<div class="card rounded-lg p-2">
|
||||||
|
<div class="flex items-center justify-between text-xs text-gray-400 mb-2">
|
||||||
|
<span class="font-medium">{{ host.name }}</span>
|
||||||
|
<span class="text-[10px]">{{ counts.get(host.name, 0) }} running</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for container in containers if container.host == host.name %}
|
||||||
|
<div class="flex items-center gap-1.5 px-2 py-1 rounded bg-dark-700 text-xs">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full {% if container.state == 'running' %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
|
||||||
|
<span class="text-gray-300 truncate max-w-[80px]" title="{{ container.name }}">{{ container.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500 text-xs">No containers</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
40
app/templates/partials/downloads.html
Normal file
40
app/templates/partials/downloads.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<div class="card rounded-lg p-3">
|
||||||
|
{% if queue and queue.downloading > 0 %}
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-gray-400">{{ queue.downloading }} active</span>
|
||||||
|
<span class="text-xs text-emerald-400 font-mono">{{ queue.speed }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if queue.items %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for item in queue.items %}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="text-gray-300 truncate flex-1 mr-2">{{ item.name }}</span>
|
||||||
|
<span class="text-gray-500">{{ item.progress | round(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill bg-emerald-500" style="width: {{ item.progress }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if queue.eta and queue.eta != 'Unknown' %}
|
||||||
|
<div class="flex items-center justify-between mt-2 text-xs text-gray-500">
|
||||||
|
<span>ETA: {{ queue.eta }}</span>
|
||||||
|
<span>{{ queue.size_left }} left</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center gap-2 text-gray-500 text-xs">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
<span>No active downloads</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="https://sabnzbd.deathstar-home.one" target="_blank" class="block text-center text-xs text-gray-500 hover:text-green-400 mt-2">
|
||||||
|
Open Sabnzbd
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
16
app/templates/partials/events.html
Normal file
16
app/templates/partials/events.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="card rounded-lg p-3">
|
||||||
|
{% if events %}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
{% for event in events | reverse %}
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full flex-shrink-0 {% if event.new_status == 'online' %}bg-emerald-500{% elif event.new_status == 'degraded' %}bg-amber-500{% else %}bg-red-500{% endif %}"></span>
|
||||||
|
<span class="text-gray-300 truncate flex-1">{{ event.service }}</span>
|
||||||
|
<span class="text-[10px] {% if event.new_status == 'online' %}text-emerald-400{% else %}text-red-400{% endif %}">{{ event.new_status }}</span>
|
||||||
|
<span class="text-[10px] text-gray-500">{{ event.timestamp.strftime('%H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-gray-500 text-xs text-center py-2">No recent events</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
13
app/templates/partials/favorites.html
Normal file
13
app/templates/partials/favorites.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for service in favorites %}
|
||||||
|
{% set status = services_status.get(service.name) %}
|
||||||
|
<a href="{{ service.url }}" target="_blank"
|
||||||
|
class="favorite-card card flex items-center gap-2 px-3 py-2 rounded-lg hover:scale-105 transition-transform">
|
||||||
|
<span class="w-2 h-2 rounded-full {% if status and status.status == 'online' %}bg-emerald-500{% elif status and status.status == 'degraded' %}bg-amber-500{% else %}bg-red-500{% endif %}"></span>
|
||||||
|
<span class="text-sm font-medium">{{ service.name }}</span>
|
||||||
|
{% if status and status.response_time_ms %}
|
||||||
|
<span class="text-xs text-gray-500">{{ status.response_time_ms }}ms</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
66
app/templates/partials/nodes.html
Normal file
66
app/templates/partials/nodes.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||||
|
{% for node in nodes_status %}
|
||||||
|
<div class="card rounded-lg p-3 cursor-pointer" onclick="toggleNodeExpand(this)">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full {% if node.status == 'online' %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
|
||||||
|
<span class="font-semibold text-sm">{{ node.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% if node.uptime_hours %}
|
||||||
|
<span class="text-xs text-gray-500">{{ (node.uptime_hours / 24) | round(1) }}d</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if node.status == 'online' %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="text-gray-400">CPU</span>
|
||||||
|
<span class="{% if node.cpu_percent and node.cpu_percent > 80 %}text-red-400{% elif node.cpu_percent and node.cpu_percent > 60 %}text-amber-400{% else %}text-emerald-400{% endif %}">{{ node.cpu_percent | default(0) | round(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill {% if node.cpu_percent and node.cpu_percent > 80 %}bg-red-500{% elif node.cpu_percent and node.cpu_percent > 60 %}bg-amber-500{% else %}bg-emerald-500{% endif %}" style="width: {{ node.cpu_percent | default(0) }}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="text-gray-400">RAM</span>
|
||||||
|
<span class="{% if node.memory_percent and node.memory_percent > 85 %}text-red-400{% elif node.memory_percent and node.memory_percent > 70 %}text-amber-400{% else %}text-blue-400{% endif %}">{% if node.memory_used_gb %}{{ node.memory_used_gb }}{% else %}?{% endif %}/{% if node.memory_total_gb %}{{ node.memory_total_gb }}{% else %}?{% endif %}GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill {% if node.memory_percent and node.memory_percent > 85 %}bg-red-500{% elif node.memory_percent and node.memory_percent > 70 %}bg-amber-500{% else %}bg-blue-500{% endif %}" style="width: {{ node.memory_percent | default(0) }}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="text-gray-400">Disk</span>
|
||||||
|
<span class="{% if node.disk_percent and node.disk_percent > 85 %}text-red-400{% elif node.disk_percent and node.disk_percent > 70 %}text-amber-400{% else %}text-purple-400{% endif %}">{{ node.disk_percent | default(0) | round(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill {% if node.disk_percent and node.disk_percent > 85 %}bg-red-500{% elif node.disk_percent and node.disk_percent > 70 %}bg-amber-500{% else %}bg-purple-500{% endif %}" style="width: {{ node.disk_percent | default(0) }}%"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expandable VM/CT list -->
|
||||||
|
<div class="node-expanded hidden mt-2 pt-2 border-t border-gray-700">
|
||||||
|
{% if node.vms %}
|
||||||
|
<div class="text-[10px] text-gray-400 mb-1">VMs ({{ node.vms | length }})</div>
|
||||||
|
<div class="flex flex-wrap gap-1 mb-2">
|
||||||
|
{% for vm in node.vms[:5] %}
|
||||||
|
<span class="px-1 py-0.5 rounded text-[9px] {% if vm.status == 'running' %}bg-emerald-900/50 text-emerald-400{% else %}bg-gray-700 text-gray-400{% endif %}">{{ vm.name[:10] }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% if node.vms | length > 5 %}<span class="text-[9px] text-gray-500">+{{ node.vms | length - 5 }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if node.containers %}
|
||||||
|
<div class="text-[10px] text-gray-400 mb-1">LXC ({{ node.containers | length }})</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for ct in node.containers[:5] %}
|
||||||
|
<span class="px-1 py-0.5 rounded text-[9px] {% if ct.status == 'running' %}bg-blue-900/50 text-blue-400{% else %}bg-gray-700 text-gray-400{% endif %}">{{ ct.name[:10] }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% if node.containers | length > 5 %}<span class="text-[9px] text-gray-500">+{{ node.containers | length - 5 }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-red-400 text-xs py-2">Offline</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if cluster_uptime %}
|
||||||
|
<div class="text-xs text-gray-500 mt-2 text-right">Cluster total: {{ cluster_uptime }}h</div>
|
||||||
|
{% endif %}
|
||||||
31
app/templates/partials/pbs.html
Normal file
31
app/templates/partials/pbs.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<div class="card rounded-lg p-3">
|
||||||
|
{% if pbs.status == 'online' %}
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-emerald-400">Online</span>
|
||||||
|
{% if pbs.last_backup %}
|
||||||
|
<span class="text-xs text-gray-500">Last: {{ pbs.last_backup }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if pbs.datastore_usage %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for ds in pbs.datastore_usage %}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="text-gray-300">{{ ds.name }}</span>
|
||||||
|
<span class="text-gray-500">{{ ds.used_gb }}/{{ ds.total_gb }}GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill {% if ds.percent > 85 %}bg-red-500{% elif ds.percent > 70 %}bg-amber-500{% else %}bg-orange-500{% endif %}" style="width: {{ ds.percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center gap-2 text-gray-500 text-xs">
|
||||||
|
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||||
|
<span>PBS Offline</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="https://192.168.1.159:8007" target="_blank" class="block text-center text-xs text-gray-500 hover:text-orange-400 mt-2">Open PBS</a>
|
||||||
|
</div>
|
||||||
60
app/templates/partials/services.html
Normal file
60
app/templates/partials/services.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% for category_name, category_services in services_by_category.items() %}
|
||||||
|
{% set cat_info = categories.get(category_name, {}) %}
|
||||||
|
<div class="category-section mb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2 cursor-pointer select-none" onclick="toggleCategory(this)">
|
||||||
|
<svg class="w-3 h-3 text-gray-500 collapse-icon transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||||
|
<span class="w-3 h-3 rounded-full
|
||||||
|
{% if cat_info.color == 'blue' %}bg-blue-500
|
||||||
|
{% elif cat_info.color == 'purple' %}bg-purple-500
|
||||||
|
{% elif cat_info.color == 'amber' %}bg-amber-500
|
||||||
|
{% elif cat_info.color == 'emerald' %}bg-emerald-500
|
||||||
|
{% elif cat_info.color == 'cyan' %}bg-cyan-500
|
||||||
|
{% else %}bg-gray-500{% endif %}"></span>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-300">{{ category_name }}</h3>
|
||||||
|
<span class="text-xs text-gray-500">({{ category_services | length }})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapsible-content">
|
||||||
|
<div class="service-grid">
|
||||||
|
{% for service in category_services %}
|
||||||
|
{% set status = services_status.get(service.name) %}
|
||||||
|
<a href="{{ service.url }}" target="_blank"
|
||||||
|
class="service-card card rounded-lg p-2 flex items-center gap-2 hover:scale-[1.02] transition-transform"
|
||||||
|
data-name="{{ service.name }}"
|
||||||
|
data-category="{{ category_name }}">
|
||||||
|
<!-- Status dot -->
|
||||||
|
<span class="status-dot w-2 h-2 rounded-full flex-shrink-0
|
||||||
|
{% if status and status.status == 'online' %}bg-emerald-500 status-online
|
||||||
|
{% elif status and status.status == 'degraded' %}bg-amber-500 status-pulse
|
||||||
|
{% else %}bg-red-500 status-offline{% endif %}"></span>
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<svg class="svc-icon text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">{{ get_service_icon(service.icon) | safe }}</svg>
|
||||||
|
|
||||||
|
<!-- Name and response time -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-xs font-medium truncate">{{ service.name }}</div>
|
||||||
|
{% if status and status.response_time_ms %}
|
||||||
|
<div class="text-[10px] {% if status.response_time_ms < 100 %}response-fast{% elif status.response_time_ms < 500 %}response-medium{% else %}response-slow{% endif %}">
|
||||||
|
{{ status.response_time_ms }}ms
|
||||||
|
</div>
|
||||||
|
{% elif status and status.status == 'offline' %}
|
||||||
|
<div class="text-[10px] text-red-400">Offline</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Critical badge -->
|
||||||
|
{% if service.critical %}
|
||||||
|
<svg class="w-3 h-3 text-red-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Group badge -->
|
||||||
|
{% if service.group %}
|
||||||
|
<span class="text-[8px] px-1 py-0.5 rounded bg-dark-600 text-gray-400">{{ service.group[:3] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
17
app/templates/partials/status_banner.html
Normal file
17
app/templates/partials/status_banner.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% if critical_down %}
|
||||||
|
<div class="status-banner rounded-lg px-4 py-2 flex items-center justify-between border border-red-900/50">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-red-400 status-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
||||||
|
<span class="text-sm text-red-300">
|
||||||
|
<strong>Critical services down:</strong> {{ critical_down | join(", ") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a href="#services-container" class="text-xs text-red-400 hover:text-red-300">View details</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="status-banner all-good rounded-lg px-4 py-2 flex items-center gap-3 border border-emerald-900/50">
|
||||||
|
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
<span class="text-sm text-emerald-300">All critical services operational</span>
|
||||||
|
<span class="text-xs text-gray-500 ml-auto">{{ online_count }}/{{ total_count }} services online</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
45
app/templates/partials/uptime.html
Normal file
45
app/templates/partials/uptime.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<div class="card rounded-lg p-3">
|
||||||
|
{% if uptime.monitors %}
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-gray-400">{{ uptime.summary.up }}/{{ uptime.summary.total }} up</span>
|
||||||
|
{% if uptime.summary.down > 0 %}
|
||||||
|
<span class="text-xs text-red-400">{{ uptime.summary.down }} down</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
{% for monitor in uptime.monitors[:8] %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full flex-shrink-0 {% if monitor.status == 1 %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
|
||||||
|
<span class="text-xs text-gray-300 truncate flex-1">{{ monitor.name }}</span>
|
||||||
|
<!-- Sparkline -->
|
||||||
|
{% if monitor.heartbeats %}
|
||||||
|
<div class="flex items-end gap-px h-3">
|
||||||
|
{% for hb in monitor.heartbeats[-12:] %}
|
||||||
|
{% set ping_height = ((hb.ping or 50) / 5) %}
|
||||||
|
{% if ping_height > 100 %}{% set ping_height = 100 %}{% endif %}
|
||||||
|
{% if ping_height < 20 %}{% set ping_height = 20 %}{% endif %}
|
||||||
|
<div class="w-1 {% if hb.status == 1 %}bg-emerald-500/60{% else %}bg-red-500/60{% endif %}"
|
||||||
|
style="height: {{ ping_height }}%"></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if monitor.ping %}
|
||||||
|
<span class="text-[10px] text-gray-500 w-8 text-right">{{ monitor.ping }}ms</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if uptime.monitors | length > 8 %}
|
||||||
|
<div class="text-xs text-gray-500 mt-2">+{{ uptime.monitors | length - 8 }} more</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center gap-2 text-gray-500 text-xs">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
<span>No uptime data</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{{ uptime_kuma_url }}" target="_blank" class="block text-center text-xs text-gray-500 hover:text-amber-400 mt-2">
|
||||||
|
Open Uptime Kuma
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
92
app/templates/settings.html
Normal file
92
app/templates/settings.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Settings - DeathStar Homelab</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: { extend: { colors: { dark: { 900: '#0a0f1a', 800: '#111827', 700: '#1f2937', 600: '#374151' } } } }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { background: linear-gradient(135deg, #0a0f1a 0%, #111827 100%); }
|
||||||
|
.card { background: rgba(31, 41, 55, 0.5); backdrop-filter: blur(10px); border: 1px solid rgba(75, 85, 99, 0.3); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 min-h-screen p-6">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">Settings</h1>
|
||||||
|
<a href="/" class="px-4 py-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-sm text-gray-300">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if request.query_params.get('saved') %}
|
||||||
|
<div class="mb-4 p-3 rounded-lg bg-emerald-900/30 border border-emerald-700 text-emerald-300 text-sm">Settings saved successfully!</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/settings" class="space-y-6">
|
||||||
|
<!-- General Settings -->
|
||||||
|
<div class="card rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4 text-gray-200">General</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">Refresh Interval (seconds)</label>
|
||||||
|
<select name="refresh_interval" class="w-full bg-dark-700 border border-gray-600 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<option value="15" {% if settings.refresh_interval == 15 %}selected{% endif %}>15 seconds</option>
|
||||||
|
<option value="30" {% if settings.refresh_interval == 30 %}selected{% endif %}>30 seconds</option>
|
||||||
|
<option value="60" {% if settings.refresh_interval == 60 %}selected{% endif %}>60 seconds</option>
|
||||||
|
<option value="120" {% if settings.refresh_interval == 120 %}selected{% endif %}>2 minutes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">Theme</label>
|
||||||
|
<select name="theme" class="w-full bg-dark-700 border border-gray-600 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Dark</option>
|
||||||
|
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Light</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display Options -->
|
||||||
|
<div class="card rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4 text-gray-200">Display Options</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" name="show_response_times" {% if settings.show_response_times %}checked{% endif %} class="w-4 h-4 rounded bg-dark-700 border-gray-600">
|
||||||
|
<span class="text-sm text-gray-300">Show response times</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" name="show_icons" {% if settings.show_icons %}checked{% endif %} class="w-4 h-4 rounded bg-dark-700 border-gray-600">
|
||||||
|
<span class="text-sm text-gray-300">Show service icons</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Favorites -->
|
||||||
|
<div class="card rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4 text-gray-200">Favorites</h2>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">Select services to show in Quick Access</p>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-64 overflow-y-auto">
|
||||||
|
{% for service in all_services %}
|
||||||
|
<label class="flex items-center gap-2 p-2 rounded-lg bg-dark-700 cursor-pointer hover:bg-dark-600">
|
||||||
|
<input type="checkbox" name="favorites" value="{{ service.name }}"
|
||||||
|
{% if service.name in settings.favorites %}checked{% endif %}
|
||||||
|
class="w-3 h-3 rounded bg-dark-600 border-gray-500">
|
||||||
|
<span class="text-xs text-gray-300 truncate">{{ service.name }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<a href="/" class="px-4 py-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-sm text-gray-300">Cancel</a>
|
||||||
|
<button type="submit" class="px-6 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-sm text-white font-medium">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
215
config.yaml.example
Normal file
215
config.yaml.example
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Dashboard Configuration
|
||||||
|
# ============================================================
|
||||||
|
# Copy this file to config.yaml and customize for your setup
|
||||||
|
# Sensitive values (API keys, secrets) go in secrets.yaml
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Dashboard settings
|
||||||
|
dashboard:
|
||||||
|
title: "My Homelab"
|
||||||
|
refresh_interval: 30 # seconds
|
||||||
|
theme: dark # dark or light
|
||||||
|
show_response_times: true
|
||||||
|
show_icons: true
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Proxmox Cluster Configuration
|
||||||
|
# ============================================================
|
||||||
|
proxmox:
|
||||||
|
enabled: true
|
||||||
|
nodes:
|
||||||
|
- name: node1
|
||||||
|
ip: 192.168.1.10
|
||||||
|
port: 8006
|
||||||
|
- name: node2
|
||||||
|
ip: 192.168.1.11
|
||||||
|
port: 8006
|
||||||
|
# API credentials go in secrets.yaml
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Proxmox Backup Server (PBS)
|
||||||
|
# ============================================================
|
||||||
|
pbs:
|
||||||
|
enabled: true
|
||||||
|
url: https://192.168.1.20:8007
|
||||||
|
# API credentials go in secrets.yaml
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Camera Configuration (go2rtc)
|
||||||
|
# ============================================================
|
||||||
|
cameras:
|
||||||
|
enabled: true
|
||||||
|
go2rtc_url: http://192.168.1.30:1985
|
||||||
|
# List camera stream names as configured in go2rtc
|
||||||
|
streams:
|
||||||
|
- FrontDoor
|
||||||
|
- Backyard
|
||||||
|
- Driveway
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Docker Hosts
|
||||||
|
# ============================================================
|
||||||
|
docker:
|
||||||
|
enabled: true
|
||||||
|
hosts:
|
||||||
|
- name: docker1
|
||||||
|
ip: 192.168.1.40
|
||||||
|
port: 2375 # Docker API port (ensure it's secured!)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Uptime Kuma
|
||||||
|
# ============================================================
|
||||||
|
uptime_kuma:
|
||||||
|
enabled: true
|
||||||
|
url: http://192.168.1.50:3001
|
||||||
|
status_page: default # Status page slug
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Categories
|
||||||
|
# ============================================================
|
||||||
|
# Define service categories with colors
|
||||||
|
categories:
|
||||||
|
Infrastructure:
|
||||||
|
color: blue
|
||||||
|
icon: server
|
||||||
|
Media:
|
||||||
|
color: purple
|
||||||
|
icon: film
|
||||||
|
Monitoring:
|
||||||
|
color: amber
|
||||||
|
icon: chart
|
||||||
|
Apps:
|
||||||
|
color: emerald
|
||||||
|
icon: cog
|
||||||
|
Home:
|
||||||
|
color: cyan
|
||||||
|
icon: home
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Services
|
||||||
|
# ============================================================
|
||||||
|
# Define all services to monitor
|
||||||
|
#
|
||||||
|
# Fields:
|
||||||
|
# name: Display name
|
||||||
|
# url: URL to open when clicked (can be external domain)
|
||||||
|
# ip: Internal IP for health checks
|
||||||
|
# port: Port for health checks
|
||||||
|
# category: Must match a category defined above
|
||||||
|
# icon: Icon name (see available icons below)
|
||||||
|
# favorite: Show in favorites section (optional)
|
||||||
|
# critical: Mark as critical service (optional)
|
||||||
|
# group: Group related services together (optional)
|
||||||
|
# health_check: Custom health check config (optional)
|
||||||
|
# url: Override URL for health check
|
||||||
|
# timeout: Custom timeout in seconds
|
||||||
|
#
|
||||||
|
# Available icons:
|
||||||
|
# server, shield, globe, archive, lock, film, star, video,
|
||||||
|
# tv, music, search, download, cog, chart, heartbeat, key,
|
||||||
|
# git, workflow, book, image, brain, home, camera, message, database
|
||||||
|
#
|
||||||
|
services:
|
||||||
|
# Infrastructure
|
||||||
|
- name: Router
|
||||||
|
url: https://192.168.1.1
|
||||||
|
ip: 192.168.1.1
|
||||||
|
port: 443
|
||||||
|
category: Infrastructure
|
||||||
|
icon: shield
|
||||||
|
critical: true
|
||||||
|
|
||||||
|
- name: Reverse Proxy
|
||||||
|
url: https://192.168.1.2:81
|
||||||
|
ip: 192.168.1.2
|
||||||
|
port: 81
|
||||||
|
category: Infrastructure
|
||||||
|
icon: globe
|
||||||
|
critical: true
|
||||||
|
|
||||||
|
# Media
|
||||||
|
- name: Plex
|
||||||
|
url: https://plex.example.com
|
||||||
|
ip: 192.168.1.100
|
||||||
|
port: 32400
|
||||||
|
category: Media
|
||||||
|
icon: film
|
||||||
|
favorite: true
|
||||||
|
|
||||||
|
- name: Radarr
|
||||||
|
url: https://radarr.example.com
|
||||||
|
ip: 192.168.1.101
|
||||||
|
port: 7878
|
||||||
|
category: Media
|
||||||
|
icon: film
|
||||||
|
group: Arr Stack
|
||||||
|
|
||||||
|
- name: Sonarr
|
||||||
|
url: https://sonarr.example.com
|
||||||
|
ip: 192.168.1.102
|
||||||
|
port: 8989
|
||||||
|
category: Media
|
||||||
|
icon: tv
|
||||||
|
group: Arr Stack
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
- name: Grafana
|
||||||
|
url: https://grafana.example.com
|
||||||
|
ip: 192.168.1.150
|
||||||
|
port: 3000
|
||||||
|
category: Monitoring
|
||||||
|
icon: chart
|
||||||
|
favorite: true
|
||||||
|
|
||||||
|
- name: Uptime Kuma
|
||||||
|
url: https://status.example.com
|
||||||
|
ip: 192.168.1.151
|
||||||
|
port: 3001
|
||||||
|
category: Monitoring
|
||||||
|
icon: heartbeat
|
||||||
|
|
||||||
|
# Apps
|
||||||
|
- name: Vaultwarden
|
||||||
|
url: https://vault.example.com
|
||||||
|
ip: 192.168.1.200
|
||||||
|
port: 80
|
||||||
|
category: Apps
|
||||||
|
icon: key
|
||||||
|
favorite: true
|
||||||
|
health_check:
|
||||||
|
url: https://vault.example.com/
|
||||||
|
timeout: 5
|
||||||
|
|
||||||
|
# Home Automation
|
||||||
|
- name: Home Assistant
|
||||||
|
url: https://ha.example.com
|
||||||
|
ip: 192.168.1.50
|
||||||
|
port: 8123
|
||||||
|
category: Home
|
||||||
|
icon: home
|
||||||
|
favorite: true
|
||||||
|
critical: true
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Service Groups
|
||||||
|
# ============================================================
|
||||||
|
# Group related services for organization
|
||||||
|
service_groups:
|
||||||
|
Arr Stack:
|
||||||
|
- Radarr
|
||||||
|
- Sonarr
|
||||||
|
- Prowlarr
|
||||||
|
- Sabnzbd
|
||||||
|
Media Players:
|
||||||
|
- Plex
|
||||||
|
- Jellyfin
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Default favorites (can be customized in UI)
|
||||||
|
# ============================================================
|
||||||
|
favorites:
|
||||||
|
- Plex
|
||||||
|
- Grafana
|
||||||
|
- Vaultwarden
|
||||||
|
- Home Assistant
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi>=0.100.0
|
||||||
|
uvicorn>=0.23.0
|
||||||
|
httpx>=0.24.0
|
||||||
|
jinja2>=3.1.0
|
||||||
|
pyyaml>=6.0
|
||||||
36
secrets.yaml.example
Normal file
36
secrets.yaml.example
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Dashboard Secrets Configuration
|
||||||
|
# ============================================================
|
||||||
|
# Copy this file to secrets.yaml and fill in your credentials
|
||||||
|
# IMPORTANT: Never commit secrets.yaml to version control!
|
||||||
|
# Add secrets.yaml to your .gitignore
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Proxmox API credentials
|
||||||
|
# Create a token: Datacenter > Permissions > API Tokens
|
||||||
|
proxmox:
|
||||||
|
api_token: root@pam!dashboard
|
||||||
|
api_secret: your-proxmox-api-secret-here
|
||||||
|
|
||||||
|
# Proxmox Backup Server credentials
|
||||||
|
# Create a token: Configuration > Access Control > API Token
|
||||||
|
pbs:
|
||||||
|
api_token: root@pam!dashboard
|
||||||
|
api_secret: your-pbs-api-secret-here
|
||||||
|
|
||||||
|
# OPNsense API credentials (optional)
|
||||||
|
# System > Access > Users > [user] > API keys
|
||||||
|
opnsense:
|
||||||
|
api_key: your-opnsense-api-key
|
||||||
|
api_secret: your-opnsense-api-secret
|
||||||
|
|
||||||
|
# Sabnzbd API key (optional)
|
||||||
|
# Config > General > API Key
|
||||||
|
sabnzbd:
|
||||||
|
api_key: your-sabnzbd-api-key
|
||||||
|
|
||||||
|
# Additional service API keys as needed
|
||||||
|
# Add custom keys here following the pattern:
|
||||||
|
# service_name:
|
||||||
|
# api_key: your-key
|
||||||
|
# api_secret: your-secret
|
||||||
Reference in New Issue
Block a user