Initial commit: Homelab Infrastructure Dashboard

Features:
- Real-time Proxmox cluster monitoring (nodes, LXC containers)
- Camera integration with go2rtc streams
- Arr stack download progress monitoring
- PBS backup status
- Docker container status
- Uptime Kuma service health
- FastAPI backend with HTMX frontend
This commit is contained in:
2026-01-30 21:03:25 -06:00
commit c952a3b56e
21 changed files with 1560 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
ENV/
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# Logs
*.log
# Local config
config.local.py

0
app/__init__.py Normal file
View File

177
app/config.py Normal file
View File

@@ -0,0 +1,177 @@
"""Enhanced Dashboard configuration v2."""
from dataclasses import dataclass, field
from typing import Optional, List, Dict
import json
import os
@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
# Proxmox configuration
PROXMOX_NODES = [
{"name": "empire", "ip": "192.168.1.75", "port": 8006},
{"name": "republic", "ip": "192.168.1.80", "port": 8006},
{"name": "Jedi", "ip": "192.168.1.40", "port": 8006},
{"name": "hoth", "ip": "192.168.1.76", "port": 8006},
]
PROXMOX_API_TOKEN = "root@pam!dashboard"
PROXMOX_API_SECRET = "edb7e222-f45d-4b15-8089-0c7eee397ab5"
# PBS configuration
PBS_URL = "https://192.168.1.159:8007"
PBS_API_TOKEN = "root@pam!dashboard"
PBS_API_SECRET = "edb7e222-f45d-4b15-8089-0c7eee397ab5"
# OPNsense configuration
OPNSENSE_URL = "https://192.168.1.1"
OPNSENSE_API_KEY = ""
OPNSENSE_API_SECRET = ""
# Prometheus configuration
PROMETHEUS_URL = "http://192.168.1.163:9090"
# Camera configuration
GO2RTC_URL = "http://192.168.1.241:1985"
CAMERAS = ["FPE", "Front_Porch", "Driveway", "Driveway_door", "Backyard", "House_side", "Street_side", "Porch_Downstairs"]
# Sabnzbd configuration
SABNZBD_URL = "http://192.168.1.66:7777"
SABNZBD_API_KEY = ""
# Uptime Kuma configuration
UPTIME_KUMA_URL = "http://192.168.1.155:3001"
UPTIME_KUMA_STATUS_PAGE = "uptime"
# Docker hosts
DOCKER_HOSTS = [
{"name": "c3p0", "ip": "192.168.1.54", "port": 2375},
{"name": "frigate", "ip": "192.168.1.241", "port": 2375},
]
# Service groups
SERVICE_GROUPS = {
"Arr Stack": ["Radarr", "Sonarr", "Lidarr", "Prowlarr", "Sabnzbd"],
"Media Players": ["Jellyfin", "Jellyseerr", "Tdarr"],
"Cameras": ["Frigate", "go2rtc"],
}
# 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"/>',
}
# Services
SERVICES = [
Service("OPNsense", "https://192.168.1.1", "192.168.1.1", 443, "Infrastructure", "shield", critical=True),
Service("NPM", "https://192.168.1.38:81", "192.168.1.38", 81, "Infrastructure", "globe", critical=True),
Service("PBS", "https://192.168.1.159:8007", "192.168.1.159", 8007, "Infrastructure", "archive", critical=True),
Service("Authentik", "https://auth.deathstar-home.one", "192.168.1.200", 9000, "Infrastructure", "lock"),
Service("Jellyfin", "https://jellyfin.deathstar-home.one", "192.168.1.49", 8096, "Media", "film", favorite=True, group="Media Players"),
Service("Jellyseerr", "https://request.deathstar-home.one", "192.168.1.12", 5055, "Media", "star", favorite=True, group="Media Players"),
Service("Radarr", "https://radarr.deathstar-home.one", "192.168.1.56", 7878, "Media", "film", group="Arr Stack"),
Service("Sonarr", "https://sonarr.deathstar-home.one", "192.168.1.62", 8989, "Media", "tv", group="Arr Stack"),
Service("Lidarr", "https://lidarr.deathstar-home.one", "192.168.1.23", 8686, "Media", "music", group="Arr Stack"),
Service("Prowlarr", "https://prowlarr.deathstar-home.one", "192.168.1.58", 9696, "Media", "search", group="Arr Stack"),
Service("Sabnzbd", "https://sabnzbd.deathstar-home.one", "192.168.1.66", 7777, "Media", "download", group="Arr Stack"),
Service("Tdarr", "https://tdarr.deathstar-home.one", "192.168.1.182", 8265, "Media", "cog", group="Media Players"),
Service("Grafana", "https://grafana.deathstar-home.one", "192.168.1.163", 3000, "Monitoring", "chart", favorite=True),
Service("Uptime Kuma", "https://status.deathstar-home.one", "192.168.1.155", 3001, "Monitoring", "heartbeat"),
Service("Vaultwarden", "https://vault.deathstar-home.one", "192.168.1.154", 80, "Apps", "key", favorite=True),
Service("Gitea", "https://git.deathstar-home.one", "192.168.1.135", 3000, "Apps", "git"),
Service("n8n", "https://n8n.deathstar-home.one", "192.168.1.254", 5678, "Apps", "workflow"),
Service("BookStack", "https://docs.deathstar-home.one", "192.168.1.194", 80, "Apps", "book"),
Service("Immich", "https://cloud.deathstar-home.one", "192.168.1.54", 2283, "Apps", "image", favorite=True),
Service("Open WebUI", "https://ai.deathstar-home.one", "192.168.1.63", 8080, "Apps", "brain"),
Service("Home Assistant", "https://astro.deathstar-home.one", "192.168.1.50", 8123, "Home", "home", favorite=True, critical=True),
Service("Frigate", "https://frigate.deathstar-home.one", "192.168.1.241", 5000, "Home", "camera", group="Cameras"),
Service("go2rtc", "http://192.168.1.241:1985", "192.168.1.241", 1985, "Home", "video", group="Cameras"),
Service("Matrix", "https://chat.deathstar-home.one", "192.168.1.162", 8080, "Home", "message"),
]
CATEGORIES = {
"Infrastructure": {"color": "blue", "icon": "server"},
"Media": {"color": "purple", "icon": "film"},
"Monitoring": {"color": "amber", "icon": "activity"},
"Apps": {"color": "emerald", "icon": "grid"},
"Home": {"color": "cyan", "icon": "home"},
}
SETTINGS_FILE = "/opt/dashboard/settings.json"
DEFAULT_SETTINGS = {
"refresh_interval": 30,
"theme": "dark",
"favorites": ["Jellyfin", "Jellyseerr", "Grafana", "Vaultwarden", "Immich", "Home Assistant"],
"collapsed_categories": [],
"show_response_times": True,
"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
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"])

265
app/main.py Normal file
View File

@@ -0,0 +1,265 @@
"""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
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("/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>

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,22 @@
<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>
{% if cameras | length > 4 %}
<a href="{{ go2rtc_url }}" target="_blank" class="block text-center text-xs text-gray-500 hover:text-cyan-400 mt-2">
View all {{ cameras | length }} cameras
</a>
{% endif %}

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>

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
jinja2==3.1.4
httpx==0.27.2
python-dotenv==1.0.1