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