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:
Dashboard
2026-02-02 20:27:05 +00:00
commit 89cdb022f3
25 changed files with 2437 additions and 0 deletions

30
.gitignore vendored Normal file
View 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
View 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
View File

280
app/config.py Normal file
View 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
View 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
View File

0
app/services/__init__.py Normal file
View File

391
app/services/health.py Normal file
View 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
View 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
View 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 %}

View 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 %}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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>

View 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
View 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
View 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
View 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