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:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
0
app/__init__.py
Normal file
177
app/config.py
Normal file
177
app/config.py
Normal 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
265
app/main.py
Normal 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
0
app/routers/__init__.py
Normal file
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
391
app/services/health.py
Normal file
391
app/services/health.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
"""Enhanced services module v2 with PBS, VM/LXC, storage pools, events."""
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
import httpx
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthStatus:
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
response_time_ms: Optional[float] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NodeStatus:
|
||||||
|
name: str
|
||||||
|
ip: str
|
||||||
|
status: str
|
||||||
|
cpu_percent: Optional[float] = None
|
||||||
|
memory_percent: Optional[float] = None
|
||||||
|
memory_used_gb: Optional[float] = None
|
||||||
|
memory_total_gb: Optional[float] = None
|
||||||
|
disk_percent: Optional[float] = None
|
||||||
|
uptime_hours: Optional[float] = None
|
||||||
|
vms: List[Dict] = field(default_factory=list)
|
||||||
|
containers: List[Dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DockerContainer:
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
state: str
|
||||||
|
image: str
|
||||||
|
host: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UptimeMonitor:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
status: int
|
||||||
|
ping: Optional[int] = None
|
||||||
|
heartbeats: Optional[List[Dict]] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PBSStatus:
|
||||||
|
status: str
|
||||||
|
datastore_usage: List[Dict] = field(default_factory=list)
|
||||||
|
last_backup: Optional[str] = None
|
||||||
|
total_size_gb: float = 0
|
||||||
|
used_size_gb: float = 0
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StoragePool:
|
||||||
|
name: str
|
||||||
|
node: str
|
||||||
|
total_gb: float
|
||||||
|
used_gb: float
|
||||||
|
avail_gb: float
|
||||||
|
percent_used: float
|
||||||
|
pool_type: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StatusEvent:
|
||||||
|
timestamp: datetime
|
||||||
|
service: str
|
||||||
|
old_status: str
|
||||||
|
new_status: str
|
||||||
|
|
||||||
|
# Recent events storage (in-memory, last 20)
|
||||||
|
recent_events: deque = deque(maxlen=20)
|
||||||
|
last_status_cache: Dict[str, str] = {}
|
||||||
|
|
||||||
|
SERVICE_CHECK_OVERRIDES = {
|
||||||
|
"OPNsense": ("https://192.168.1.1:8443/", 10.0),
|
||||||
|
"Vaultwarden": ("https://vault.deathstar-home.one/", 5.0),
|
||||||
|
"Immich": ("http://192.168.1.54:2283/", 5.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def check_service(client: httpx.AsyncClient, service) -> HealthStatus:
|
||||||
|
"""Check if a service is reachable."""
|
||||||
|
global last_status_cache, recent_events
|
||||||
|
|
||||||
|
if service.name in SERVICE_CHECK_OVERRIDES:
|
||||||
|
check_url, timeout = SERVICE_CHECK_OVERRIDES[service.name]
|
||||||
|
else:
|
||||||
|
https_ports = [443, 8006, 8007, 8443, 9443]
|
||||||
|
scheme = "https" if service.port in https_ports else "http"
|
||||||
|
check_url = f"{scheme}://{service.ip}:{service.port}/"
|
||||||
|
timeout = 5.0
|
||||||
|
|
||||||
|
start = asyncio.get_event_loop().time()
|
||||||
|
try:
|
||||||
|
response = await client.get(check_url, timeout=timeout, follow_redirects=True)
|
||||||
|
elapsed = (asyncio.get_event_loop().time() - start) * 1000
|
||||||
|
new_status = "online" if response.status_code < 500 else "degraded"
|
||||||
|
result = HealthStatus(name=service.name, status=new_status, response_time_ms=round(elapsed, 1))
|
||||||
|
except:
|
||||||
|
new_status = "offline"
|
||||||
|
result = HealthStatus(name=service.name, status="offline")
|
||||||
|
|
||||||
|
# Track status changes
|
||||||
|
old_status = last_status_cache.get(service.name)
|
||||||
|
if old_status and old_status != new_status:
|
||||||
|
recent_events.append(StatusEvent(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
service=service.name,
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=new_status
|
||||||
|
))
|
||||||
|
last_status_cache[service.name] = new_status
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def check_all_services(services) -> Dict[str, HealthStatus]:
|
||||||
|
"""Check all services concurrently."""
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=10.0) as client:
|
||||||
|
tasks = [check_service(client, s) for s in services]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
return {r.name: r for r in results}
|
||||||
|
|
||||||
|
async def get_proxmox_node_metrics(client: httpx.AsyncClient, node: Dict, token: str, secret: str) -> NodeStatus:
|
||||||
|
"""Get Proxmox node metrics including VMs and containers."""
|
||||||
|
base_url = f"https://{node['ip']}:{node['port']}/api2/json"
|
||||||
|
headers = {"Authorization": f"PVEAPIToken={token}={secret}"}
|
||||||
|
|
||||||
|
result = NodeStatus(name=node["name"], ip=node["ip"], status="offline")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get node status
|
||||||
|
response = await client.get(f"{base_url}/nodes/{node['name']}/status", headers=headers, timeout=5.0)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()["data"]
|
||||||
|
cpu = data.get("cpu", 0) * 100
|
||||||
|
mem_used = data.get("memory", {}).get("used", 0)
|
||||||
|
mem_total = data.get("memory", {}).get("total", 1)
|
||||||
|
mem_pct = (mem_used / mem_total) * 100 if mem_total else 0
|
||||||
|
disk_used = data.get("rootfs", {}).get("used", 0)
|
||||||
|
disk_total = data.get("rootfs", {}).get("total", 1)
|
||||||
|
disk_pct = (disk_used / disk_total) * 100 if disk_total else 0
|
||||||
|
uptime_sec = data.get("uptime", 0)
|
||||||
|
|
||||||
|
result.status = "online"
|
||||||
|
result.cpu_percent = round(cpu, 1)
|
||||||
|
result.memory_percent = round(mem_pct, 1)
|
||||||
|
result.memory_used_gb = round(mem_used / (1024**3), 1)
|
||||||
|
result.memory_total_gb = round(mem_total / (1024**3), 1)
|
||||||
|
result.disk_percent = round(disk_pct, 1)
|
||||||
|
result.uptime_hours = round(uptime_sec / 3600, 1)
|
||||||
|
|
||||||
|
# Get VMs
|
||||||
|
vm_response = await client.get(f"{base_url}/nodes/{node['name']}/qemu", headers=headers, timeout=5.0)
|
||||||
|
if vm_response.status_code == 200:
|
||||||
|
for vm in vm_response.json().get("data", []):
|
||||||
|
result.vms.append({
|
||||||
|
"vmid": vm.get("vmid"),
|
||||||
|
"name": vm.get("name", f"VM {vm.get('vmid')}"),
|
||||||
|
"status": vm.get("status"),
|
||||||
|
"mem": round(vm.get("mem", 0) / (1024**3), 1) if vm.get("mem") else 0,
|
||||||
|
"cpu": round(vm.get("cpu", 0) * 100, 1) if vm.get("cpu") else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get containers
|
||||||
|
ct_response = await client.get(f"{base_url}/nodes/{node['name']}/lxc", headers=headers, timeout=5.0)
|
||||||
|
if ct_response.status_code == 200:
|
||||||
|
for ct in ct_response.json().get("data", []):
|
||||||
|
result.containers.append({
|
||||||
|
"vmid": ct.get("vmid"),
|
||||||
|
"name": ct.get("name", f"CT {ct.get('vmid')}"),
|
||||||
|
"status": ct.get("status"),
|
||||||
|
"mem": round(ct.get("mem", 0) / (1024**3), 1) if ct.get("mem") else 0,
|
||||||
|
"cpu": round(ct.get("cpu", 0) * 100, 1) if ct.get("cpu") else 0,
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_all_proxmox_metrics(nodes, token: str, secret: str) -> List[NodeStatus]:
|
||||||
|
"""Get metrics for all Proxmox nodes."""
|
||||||
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
|
tasks = [get_proxmox_node_metrics(client, n, token, secret) for n in nodes]
|
||||||
|
return await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
async def get_pbs_status(url: str, token: str, secret: str) -> PBSStatus:
|
||||||
|
"""Get PBS backup server status."""
|
||||||
|
result = PBSStatus(status="offline")
|
||||||
|
headers = {"Authorization": f"PBSAPIToken={token}={secret}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=10.0) as client:
|
||||||
|
# Get datastore status
|
||||||
|
ds_response = await client.get(f"{url}/api2/json/status/datastore-usage", headers=headers)
|
||||||
|
if ds_response.status_code == 200:
|
||||||
|
result.status = "online"
|
||||||
|
for ds in ds_response.json().get("data", []):
|
||||||
|
total = ds.get("total", 0)
|
||||||
|
used = ds.get("used", 0)
|
||||||
|
result.datastore_usage.append({
|
||||||
|
"name": ds.get("store"),
|
||||||
|
"total_gb": round(total / (1024**3), 1),
|
||||||
|
"used_gb": round(used / (1024**3), 1),
|
||||||
|
"percent": round((used / total) * 100, 1) if total else 0,
|
||||||
|
})
|
||||||
|
result.total_size_gb += total / (1024**3)
|
||||||
|
result.used_size_gb += used / (1024**3)
|
||||||
|
|
||||||
|
# Try to get last backup task
|
||||||
|
tasks_response = await client.get(f"{url}/api2/json/nodes/localhost/tasks", headers=headers)
|
||||||
|
if tasks_response.status_code == 200:
|
||||||
|
tasks = tasks_response.json().get("data", [])
|
||||||
|
backup_tasks = [t for t in tasks if t.get("type") == "backup"]
|
||||||
|
if backup_tasks:
|
||||||
|
last = backup_tasks[0]
|
||||||
|
result.last_backup = datetime.fromtimestamp(last.get("starttime", 0)).strftime("%Y-%m-%d %H:%M")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_storage_pools(nodes, token: str, secret: str) -> List[StoragePool]:
|
||||||
|
"""Get storage pool info from all Proxmox nodes."""
|
||||||
|
pools = []
|
||||||
|
headers = {"Authorization": f"PVEAPIToken={token}={secret}"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=10.0) as client:
|
||||||
|
for node in nodes:
|
||||||
|
try:
|
||||||
|
url = f"https://{node['ip']}:{node['port']}/api2/json/nodes/{node['name']}/storage"
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
if response.status_code == 200:
|
||||||
|
for storage in response.json().get("data", []):
|
||||||
|
if storage.get("enabled") and storage.get("total"):
|
||||||
|
total = storage.get("total", 0)
|
||||||
|
used = storage.get("used", 0)
|
||||||
|
avail = storage.get("avail", 0)
|
||||||
|
pools.append(StoragePool(
|
||||||
|
name=storage.get("storage"),
|
||||||
|
node=node["name"],
|
||||||
|
total_gb=round(total / (1024**3), 1),
|
||||||
|
used_gb=round(used / (1024**3), 1),
|
||||||
|
avail_gb=round(avail / (1024**3), 1),
|
||||||
|
percent_used=round((used / total) * 100, 1) if total else 0,
|
||||||
|
pool_type=storage.get("type", "unknown"),
|
||||||
|
))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return pools
|
||||||
|
|
||||||
|
async def get_docker_containers(hosts: List[Dict]) -> List[DockerContainer]:
|
||||||
|
"""Get Docker containers via docker-socket-proxy."""
|
||||||
|
containers = []
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
for host in hosts:
|
||||||
|
try:
|
||||||
|
url = f"http://{host['ip']}:{host['port']}/containers/json?all=true"
|
||||||
|
response = await client.get(url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
for c in response.json():
|
||||||
|
name = c.get("Names", ["/unknown"])[0].lstrip("/")
|
||||||
|
if name == "docker-socket-proxy":
|
||||||
|
continue
|
||||||
|
containers.append(DockerContainer(
|
||||||
|
name=name,
|
||||||
|
status=c.get("Status", ""),
|
||||||
|
state=c.get("State", "unknown"),
|
||||||
|
image=c.get("Image", "").split("/")[-1].split(":")[0],
|
||||||
|
host=host["name"]
|
||||||
|
))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return containers
|
||||||
|
|
||||||
|
async def get_docker_container_counts(hosts: List[Dict]) -> Dict[str, int]:
|
||||||
|
"""Get container counts per host."""
|
||||||
|
counts = {}
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
for host in hosts:
|
||||||
|
try:
|
||||||
|
url = f"http://{host['ip']}:{host['port']}/containers/json"
|
||||||
|
response = await client.get(url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
# Subtract 1 for docker-socket-proxy
|
||||||
|
count = len([c for c in response.json() if "docker-socket-proxy" not in c.get("Names", [""])[0]])
|
||||||
|
counts[host["name"]] = count
|
||||||
|
except:
|
||||||
|
counts[host["name"]] = 0
|
||||||
|
return counts
|
||||||
|
|
||||||
|
async def get_uptime_kuma_status(url: str, status_page: str = "uptime") -> Dict:
|
||||||
|
"""Get Uptime Kuma status."""
|
||||||
|
result = {"monitors": [], "summary": {"up": 0, "down": 0, "total": 0}}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
hb_response = await client.get(f"{url}/api/status-page/heartbeat/{status_page}")
|
||||||
|
info_response = await client.get(f"{url}/api/status-page/{status_page}")
|
||||||
|
|
||||||
|
if hb_response.status_code == 200 and info_response.status_code == 200:
|
||||||
|
heartbeats = hb_response.json().get("heartbeatList", {})
|
||||||
|
info = info_response.json()
|
||||||
|
|
||||||
|
for group in info.get("publicGroupList", []):
|
||||||
|
for monitor in group.get("monitorList", []):
|
||||||
|
monitor_id = str(monitor.get("id"))
|
||||||
|
monitor_heartbeats = heartbeats.get(monitor_id, [])
|
||||||
|
latest_status = 0
|
||||||
|
latest_ping = None
|
||||||
|
if monitor_heartbeats:
|
||||||
|
latest = monitor_heartbeats[-1]
|
||||||
|
latest_status = latest.get("status", 0)
|
||||||
|
latest_ping = latest.get("ping")
|
||||||
|
recent_hb = monitor_heartbeats[-20:] if monitor_heartbeats else []
|
||||||
|
result["monitors"].append(UptimeMonitor(
|
||||||
|
id=monitor.get("id"),
|
||||||
|
name=monitor.get("name"),
|
||||||
|
status=latest_status,
|
||||||
|
ping=latest_ping,
|
||||||
|
heartbeats=[{"status": h.get("status", 0), "ping": h.get("ping")} for h in recent_hb]
|
||||||
|
))
|
||||||
|
if latest_status == 1:
|
||||||
|
result["summary"]["up"] += 1
|
||||||
|
else:
|
||||||
|
result["summary"]["down"] += 1
|
||||||
|
result["summary"]["total"] += 1
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_prometheus_metrics(url: str, queries: Dict[str, str]) -> Dict[str, Any]:
|
||||||
|
"""Query Prometheus for metrics."""
|
||||||
|
results = {}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
for name, query in queries.items():
|
||||||
|
response = await client.get(f"{url}/api/v1/query", params={"query": query})
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json().get("data", {}).get("result", [])
|
||||||
|
if data:
|
||||||
|
results[name] = float(data[0].get("value", [0, 0])[1])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def get_camera_list(go2rtc_url: str) -> List[str]:
|
||||||
|
"""Get camera list from go2rtc."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(f"{go2rtc_url}/api/streams")
|
||||||
|
if response.status_code == 200:
|
||||||
|
return list(response.json().keys())
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_sabnzbd_queue(url: str, api_key: str = "") -> Dict:
|
||||||
|
"""Get Sabnzbd download queue."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
params = {"mode": "queue", "output": "json"}
|
||||||
|
if api_key:
|
||||||
|
params["apikey"] = api_key
|
||||||
|
response = await client.get(f"{url}/api", params=params)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json().get("queue", {})
|
||||||
|
return {
|
||||||
|
"speed": data.get("speed", "0 B/s"),
|
||||||
|
"size_left": data.get("sizeleft", "0 B"),
|
||||||
|
"eta": data.get("timeleft", "Unknown"),
|
||||||
|
"downloading": len(data.get("slots", [])),
|
||||||
|
"items": [
|
||||||
|
{"name": s.get("filename", "Unknown")[:40], "progress": float(s.get("percentage", 0))}
|
||||||
|
for s in data.get("slots", [])[:3]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {"speed": "N/A", "downloading": 0, "items": []}
|
||||||
|
|
||||||
|
def get_recent_events() -> List[StatusEvent]:
|
||||||
|
"""Get recent status change events."""
|
||||||
|
return list(recent_events)
|
||||||
|
|
||||||
|
def get_cluster_uptime(nodes: List[NodeStatus]) -> float:
|
||||||
|
"""Calculate total cluster uptime in hours."""
|
||||||
|
total = 0
|
||||||
|
for node in nodes:
|
||||||
|
if node.uptime_hours:
|
||||||
|
total += node.uptime_hours
|
||||||
|
return round(total, 1)
|
||||||
145
app/templates/base.html
Normal file
145
app/templates/base.html
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DeathStar Homelab</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
dark: { 900: '#0a0f1a', 800: '#111827', 700: '#1f2937', 600: '#374151' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
|
||||||
|
.status-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
|
.htmx-request { opacity: 0.5; }
|
||||||
|
body { background: linear-gradient(135deg, #0a0f1a 0%, #111827 100%); }
|
||||||
|
.card { background: rgba(31, 41, 55, 0.5); backdrop-filter: blur(10px); border: 1px solid rgba(75, 85, 99, 0.3); }
|
||||||
|
.card:hover { border-color: rgba(99, 102, 241, 0.5); }
|
||||||
|
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.5rem; }
|
||||||
|
.progress-bar { height: 4px; border-radius: 2px; background: rgba(75, 85, 99, 0.5); overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; border-radius: 2px; transition: width 0.3s ease; }
|
||||||
|
.collapsible-content { max-height: 1000px; overflow: hidden; transition: max-height 0.3s ease; }
|
||||||
|
.collapsed .collapsible-content { max-height: 0; }
|
||||||
|
.camera-thumb { aspect-ratio: 16/9; background: #1f2937; border-radius: 0.5rem; overflow: hidden; }
|
||||||
|
.camera-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
#search-modal { backdrop-filter: blur(4px); }
|
||||||
|
.favorite-card { background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%); }
|
||||||
|
.status-banner { background: linear-gradient(90deg, rgba(239, 68, 68, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%); }
|
||||||
|
.status-banner.all-good { background: linear-gradient(90deg, rgba(34, 197, 94, 0.1) 0%, rgba(34, 197, 94, 0.05) 100%); }
|
||||||
|
.response-fast { color: #10b981; }
|
||||||
|
.response-medium { color: #f59e0b; }
|
||||||
|
.response-slow { color: #ef4444; }
|
||||||
|
.svc-icon { width: 14px; height: 14px; flex-shrink: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 min-h-screen p-3">
|
||||||
|
<div class="max-w-[1920px] mx-auto">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Modal -->
|
||||||
|
<div id="search-modal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center">
|
||||||
|
<div class="bg-dark-800 rounded-xl p-4 w-full max-w-md mx-4 border border-gray-700">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||||
|
<input type="text" id="search-input" placeholder="Search services..." class="flex-1 bg-transparent border-none outline-none text-white placeholder-gray-500">
|
||||||
|
<kbd class="px-2 py-1 text-xs bg-dark-700 rounded text-gray-400">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
<div id="search-results" class="max-h-64 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Clock
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const el = document.getElementById('clock');
|
||||||
|
if (el) el.textContent = now.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
updateClock();
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const searchModal = document.getElementById('search-modal');
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchModal.classList.remove('hidden');
|
||||||
|
searchModal.classList.add('flex');
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
searchModal.classList.add('hidden');
|
||||||
|
searchModal.classList.remove('flex');
|
||||||
|
searchInput.value = '';
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
}
|
||||||
|
if (e.key === 'r' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT') {
|
||||||
|
refreshAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchModal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === searchModal) {
|
||||||
|
searchModal.classList.add('hidden');
|
||||||
|
searchModal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function(e) {
|
||||||
|
const query = e.target.value.toLowerCase();
|
||||||
|
if (query.length < 2) { searchResults.innerHTML = ''; return; }
|
||||||
|
const cards = document.querySelectorAll('.service-card');
|
||||||
|
const matches = [];
|
||||||
|
cards.forEach(card => {
|
||||||
|
const name = card.dataset.name?.toLowerCase() || '';
|
||||||
|
if (name.includes(query)) {
|
||||||
|
const url = card.querySelector('a')?.href || '#';
|
||||||
|
const status = card.querySelector('.status-dot')?.classList.contains('bg-emerald-500') ? 'online' : 'offline';
|
||||||
|
matches.push({ name: card.dataset.name, url, status });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
searchResults.innerHTML = matches.length === 0 ? '<div class="text-gray-500 text-sm py-2">No services found</div>' :
|
||||||
|
matches.map(m => `<a href="${m.url}" target="_blank" class="flex items-center gap-3 p-2 rounded hover:bg-dark-700"><span class="w-2 h-2 rounded-full ${m.status === 'online' ? 'bg-emerald-500' : 'bg-red-500'}"></span><span>${m.name}</span></a>`).join('');
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleCategory(header) {
|
||||||
|
const section = header.closest('.category-section');
|
||||||
|
section.classList.toggle('collapsed');
|
||||||
|
const icon = header.querySelector('.collapse-icon');
|
||||||
|
icon.style.transform = section.classList.contains('collapsed') ? 'rotate(-90deg)' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
htmx.trigger('#status-banner', 'refresh');
|
||||||
|
htmx.trigger('#favorites-container', 'refresh');
|
||||||
|
htmx.trigger('#nodes-container', 'refresh');
|
||||||
|
htmx.trigger('#services-container', 'refresh');
|
||||||
|
htmx.trigger('#cameras-container', 'refresh');
|
||||||
|
htmx.trigger('#downloads-container', 'refresh');
|
||||||
|
htmx.trigger('#docker-container', 'refresh');
|
||||||
|
htmx.trigger('#uptime-container', 'refresh');
|
||||||
|
htmx.trigger('#pbs-container', 'refresh');
|
||||||
|
htmx.trigger('#events-container', 'refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNodeExpand(nodeEl) {
|
||||||
|
const expanded = nodeEl.querySelector('.node-expanded');
|
||||||
|
if (expanded) expanded.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
134
app/templates/dashboard.html
Normal file
134
app/templates/dashboard.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="mb-3">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<h1 class="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">DeathStar Homelab</h1>
|
||||||
|
<button onclick="refreshAll()" class="p-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors" title="Refresh (R)">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="document.getElementById('search-modal').classList.remove('hidden'); document.getElementById('search-modal').classList.add('flex'); document.getElementById('search-input').focus();" class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors text-xs text-gray-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||||
|
<span>Search</span>
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-dark-600 rounded text-xs">/</kbd>
|
||||||
|
</button>
|
||||||
|
<a href="/settings" class="p-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors" title="Settings">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-xs text-gray-400">
|
||||||
|
<span id="clock" class="font-mono"></span>
|
||||||
|
<span>{{ online_count }}/{{ total_count }} online</span>
|
||||||
|
<span>Updated: {{ last_check }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="status-banner" hx-get="/api/status-banner" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/status_banner.html" %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Favorites -->
|
||||||
|
<section class="mb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Quick Access</h2>
|
||||||
|
</div>
|
||||||
|
<div id="favorites-container" hx-get="/api/favorites" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/favorites.html" %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Proxmox Cluster -->
|
||||||
|
<section class="mb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Proxmox Cluster: NewHope</h2>
|
||||||
|
<span class="text-xs text-gray-500">{{ cluster_uptime }}h total uptime</span>
|
||||||
|
</div>
|
||||||
|
<div id="nodes-container" hx-get="/api/nodes" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/nodes.html" %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-3">
|
||||||
|
<!-- Services (3 cols) -->
|
||||||
|
<div class="lg:col-span-3">
|
||||||
|
<section id="services-container" hx-get="/api/services" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/services.html" %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Sidebar -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- PBS Backup -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Backups</h2>
|
||||||
|
</div>
|
||||||
|
<div id="pbs-container" hx-get="/api/pbs" hx-trigger="every 60s, refresh" hx-swap="innerHTML">
|
||||||
|
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent Events -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Recent Events</h2>
|
||||||
|
</div>
|
||||||
|
<div id="events-container" hx-get="/api/events" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Downloads -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Downloads</h2>
|
||||||
|
</div>
|
||||||
|
<div id="downloads-container" hx-get="/api/downloads" hx-trigger="every 15s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/downloads.html" %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cameras -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Cameras</h2>
|
||||||
|
</div>
|
||||||
|
<div id="cameras-container" hx-get="/api/cameras" hx-trigger="every 60s, refresh" hx-swap="innerHTML">
|
||||||
|
{% include "partials/cameras.html" %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Docker -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Docker</h2>
|
||||||
|
<span class="text-xs text-gray-500">{% for name, count in docker_counts.items() %}{{ count }}{% if not loop.last %}+{% endif %}{% endfor %} containers</span>
|
||||||
|
</div>
|
||||||
|
<div id="docker-container" hx-get="/api/docker" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
|
||||||
|
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Uptime Kuma -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Uptime</h2>
|
||||||
|
</div>
|
||||||
|
<div id="uptime-container" hx-get="/api/uptime" hx-trigger="every 60s, refresh" hx-swap="innerHTML">
|
||||||
|
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
22
app/templates/partials/cameras.html
Normal file
22
app/templates/partials/cameras.html
Normal 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 %}
|
||||||
20
app/templates/partials/docker.html
Normal file
20
app/templates/partials/docker.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<div class="space-y-2">
|
||||||
|
{% for host in hosts %}
|
||||||
|
<div class="card rounded-lg p-2">
|
||||||
|
<div class="flex items-center justify-between text-xs text-gray-400 mb-2">
|
||||||
|
<span class="font-medium">{{ host.name }}</span>
|
||||||
|
<span class="text-[10px]">{{ counts.get(host.name, 0) }} running</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for container in containers if container.host == host.name %}
|
||||||
|
<div class="flex items-center gap-1.5 px-2 py-1 rounded bg-dark-700 text-xs">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full {% if container.state == 'running' %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
|
||||||
|
<span class="text-gray-300 truncate max-w-[80px]" title="{{ container.name }}">{{ container.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500 text-xs">No containers</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
40
app/templates/partials/downloads.html
Normal file
40
app/templates/partials/downloads.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<div class="card rounded-lg p-3">
|
||||||
|
{% if queue and queue.downloading > 0 %}
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-gray-400">{{ queue.downloading }} active</span>
|
||||||
|
<span class="text-xs text-emerald-400 font-mono">{{ queue.speed }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if queue.items %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for item in queue.items %}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="text-gray-300 truncate flex-1 mr-2">{{ item.name }}</span>
|
||||||
|
<span class="text-gray-500">{{ item.progress | round(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill bg-emerald-500" style="width: {{ item.progress }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if queue.eta and queue.eta != 'Unknown' %}
|
||||||
|
<div class="flex items-center justify-between mt-2 text-xs text-gray-500">
|
||||||
|
<span>ETA: {{ queue.eta }}</span>
|
||||||
|
<span>{{ queue.size_left }} left</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center gap-2 text-gray-500 text-xs">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
<span>No active downloads</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="https://sabnzbd.deathstar-home.one" target="_blank" class="block text-center text-xs text-gray-500 hover:text-green-400 mt-2">
|
||||||
|
Open Sabnzbd
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
16
app/templates/partials/events.html
Normal file
16
app/templates/partials/events.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="card rounded-lg p-3">
|
||||||
|
{% if events %}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
{% for event in events | reverse %}
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full flex-shrink-0 {% if event.new_status == 'online' %}bg-emerald-500{% elif event.new_status == 'degraded' %}bg-amber-500{% else %}bg-red-500{% endif %}"></span>
|
||||||
|
<span class="text-gray-300 truncate flex-1">{{ event.service }}</span>
|
||||||
|
<span class="text-[10px] {% if event.new_status == 'online' %}text-emerald-400{% else %}text-red-400{% endif %}">{{ event.new_status }}</span>
|
||||||
|
<span class="text-[10px] text-gray-500">{{ event.timestamp.strftime('%H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-gray-500 text-xs text-center py-2">No recent events</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
13
app/templates/partials/favorites.html
Normal file
13
app/templates/partials/favorites.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for service in favorites %}
|
||||||
|
{% set status = services_status.get(service.name) %}
|
||||||
|
<a href="{{ service.url }}" target="_blank"
|
||||||
|
class="favorite-card card flex items-center gap-2 px-3 py-2 rounded-lg hover:scale-105 transition-transform">
|
||||||
|
<span class="w-2 h-2 rounded-full {% if status and status.status == 'online' %}bg-emerald-500{% elif status and status.status == 'degraded' %}bg-amber-500{% else %}bg-red-500{% endif %}"></span>
|
||||||
|
<span class="text-sm font-medium">{{ service.name }}</span>
|
||||||
|
{% if status and status.response_time_ms %}
|
||||||
|
<span class="text-xs text-gray-500">{{ status.response_time_ms }}ms</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
66
app/templates/partials/nodes.html
Normal file
66
app/templates/partials/nodes.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||||
|
{% for node in nodes_status %}
|
||||||
|
<div class="card rounded-lg p-3 cursor-pointer" onclick="toggleNodeExpand(this)">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full {% if node.status == 'online' %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
|
||||||
|
<span class="font-semibold text-sm">{{ node.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% if node.uptime_hours %}
|
||||||
|
<span class="text-xs text-gray-500">{{ (node.uptime_hours / 24) | round(1) }}d</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if node.status == 'online' %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="text-gray-400">CPU</span>
|
||||||
|
<span class="{% if node.cpu_percent and node.cpu_percent > 80 %}text-red-400{% elif node.cpu_percent and node.cpu_percent > 60 %}text-amber-400{% else %}text-emerald-400{% endif %}">{{ node.cpu_percent | default(0) | round(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill {% if node.cpu_percent and node.cpu_percent > 80 %}bg-red-500{% elif node.cpu_percent and node.cpu_percent > 60 %}bg-amber-500{% else %}bg-emerald-500{% endif %}" style="width: {{ node.cpu_percent | default(0) }}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="text-gray-400">RAM</span>
|
||||||
|
<span class="{% if node.memory_percent and node.memory_percent > 85 %}text-red-400{% elif node.memory_percent and node.memory_percent > 70 %}text-amber-400{% else %}text-blue-400{% endif %}">{% if node.memory_used_gb %}{{ node.memory_used_gb }}{% else %}?{% endif %}/{% if node.memory_total_gb %}{{ node.memory_total_gb }}{% else %}?{% endif %}GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill {% if node.memory_percent and node.memory_percent > 85 %}bg-red-500{% elif node.memory_percent and node.memory_percent > 70 %}bg-amber-500{% else %}bg-blue-500{% endif %}" style="width: {{ node.memory_percent | default(0) }}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="text-gray-400">Disk</span>
|
||||||
|
<span class="{% if node.disk_percent and node.disk_percent > 85 %}text-red-400{% elif node.disk_percent and node.disk_percent > 70 %}text-amber-400{% else %}text-purple-400{% endif %}">{{ node.disk_percent | default(0) | round(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill {% if node.disk_percent and node.disk_percent > 85 %}bg-red-500{% elif node.disk_percent and node.disk_percent > 70 %}bg-amber-500{% else %}bg-purple-500{% endif %}" style="width: {{ node.disk_percent | default(0) }}%"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expandable VM/CT list -->
|
||||||
|
<div class="node-expanded hidden mt-2 pt-2 border-t border-gray-700">
|
||||||
|
{% if node.vms %}
|
||||||
|
<div class="text-[10px] text-gray-400 mb-1">VMs ({{ node.vms | length }})</div>
|
||||||
|
<div class="flex flex-wrap gap-1 mb-2">
|
||||||
|
{% for vm in node.vms[:5] %}
|
||||||
|
<span class="px-1 py-0.5 rounded text-[9px] {% if vm.status == 'running' %}bg-emerald-900/50 text-emerald-400{% else %}bg-gray-700 text-gray-400{% endif %}">{{ vm.name[:10] }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% if node.vms | length > 5 %}<span class="text-[9px] text-gray-500">+{{ node.vms | length - 5 }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if node.containers %}
|
||||||
|
<div class="text-[10px] text-gray-400 mb-1">LXC ({{ node.containers | length }})</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for ct in node.containers[:5] %}
|
||||||
|
<span class="px-1 py-0.5 rounded text-[9px] {% if ct.status == 'running' %}bg-blue-900/50 text-blue-400{% else %}bg-gray-700 text-gray-400{% endif %}">{{ ct.name[:10] }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% if node.containers | length > 5 %}<span class="text-[9px] text-gray-500">+{{ node.containers | length - 5 }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-red-400 text-xs py-2">Offline</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if cluster_uptime %}
|
||||||
|
<div class="text-xs text-gray-500 mt-2 text-right">Cluster total: {{ cluster_uptime }}h</div>
|
||||||
|
{% endif %}
|
||||||
31
app/templates/partials/pbs.html
Normal file
31
app/templates/partials/pbs.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<div class="card rounded-lg p-3">
|
||||||
|
{% if pbs.status == 'online' %}
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-emerald-400">Online</span>
|
||||||
|
{% if pbs.last_backup %}
|
||||||
|
<span class="text-xs text-gray-500">Last: {{ pbs.last_backup }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if pbs.datastore_usage %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for ds in pbs.datastore_usage %}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="text-gray-300">{{ ds.name }}</span>
|
||||||
|
<span class="text-gray-500">{{ ds.used_gb }}/{{ ds.total_gb }}GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill {% if ds.percent > 85 %}bg-red-500{% elif ds.percent > 70 %}bg-amber-500{% else %}bg-orange-500{% endif %}" style="width: {{ ds.percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center gap-2 text-gray-500 text-xs">
|
||||||
|
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||||
|
<span>PBS Offline</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="https://192.168.1.159:8007" target="_blank" class="block text-center text-xs text-gray-500 hover:text-orange-400 mt-2">Open PBS</a>
|
||||||
|
</div>
|
||||||
60
app/templates/partials/services.html
Normal file
60
app/templates/partials/services.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% for category_name, category_services in services_by_category.items() %}
|
||||||
|
{% set cat_info = categories.get(category_name, {}) %}
|
||||||
|
<div class="category-section mb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2 cursor-pointer select-none" onclick="toggleCategory(this)">
|
||||||
|
<svg class="w-3 h-3 text-gray-500 collapse-icon transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||||
|
<span class="w-3 h-3 rounded-full
|
||||||
|
{% if cat_info.color == 'blue' %}bg-blue-500
|
||||||
|
{% elif cat_info.color == 'purple' %}bg-purple-500
|
||||||
|
{% elif cat_info.color == 'amber' %}bg-amber-500
|
||||||
|
{% elif cat_info.color == 'emerald' %}bg-emerald-500
|
||||||
|
{% elif cat_info.color == 'cyan' %}bg-cyan-500
|
||||||
|
{% else %}bg-gray-500{% endif %}"></span>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-300">{{ category_name }}</h3>
|
||||||
|
<span class="text-xs text-gray-500">({{ category_services | length }})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapsible-content">
|
||||||
|
<div class="service-grid">
|
||||||
|
{% for service in category_services %}
|
||||||
|
{% set status = services_status.get(service.name) %}
|
||||||
|
<a href="{{ service.url }}" target="_blank"
|
||||||
|
class="service-card card rounded-lg p-2 flex items-center gap-2 hover:scale-[1.02] transition-transform"
|
||||||
|
data-name="{{ service.name }}"
|
||||||
|
data-category="{{ category_name }}">
|
||||||
|
<!-- Status dot -->
|
||||||
|
<span class="status-dot w-2 h-2 rounded-full flex-shrink-0
|
||||||
|
{% if status and status.status == 'online' %}bg-emerald-500 status-online
|
||||||
|
{% elif status and status.status == 'degraded' %}bg-amber-500 status-pulse
|
||||||
|
{% else %}bg-red-500 status-offline{% endif %}"></span>
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<svg class="svc-icon text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">{{ get_service_icon(service.icon) | safe }}</svg>
|
||||||
|
|
||||||
|
<!-- Name and response time -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-xs font-medium truncate">{{ service.name }}</div>
|
||||||
|
{% if status and status.response_time_ms %}
|
||||||
|
<div class="text-[10px] {% if status.response_time_ms < 100 %}response-fast{% elif status.response_time_ms < 500 %}response-medium{% else %}response-slow{% endif %}">
|
||||||
|
{{ status.response_time_ms }}ms
|
||||||
|
</div>
|
||||||
|
{% elif status and status.status == 'offline' %}
|
||||||
|
<div class="text-[10px] text-red-400">Offline</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Critical badge -->
|
||||||
|
{% if service.critical %}
|
||||||
|
<svg class="w-3 h-3 text-red-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Group badge -->
|
||||||
|
{% if service.group %}
|
||||||
|
<span class="text-[8px] px-1 py-0.5 rounded bg-dark-600 text-gray-400">{{ service.group[:3] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
17
app/templates/partials/status_banner.html
Normal file
17
app/templates/partials/status_banner.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% if critical_down %}
|
||||||
|
<div class="status-banner rounded-lg px-4 py-2 flex items-center justify-between border border-red-900/50">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-red-400 status-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
||||||
|
<span class="text-sm text-red-300">
|
||||||
|
<strong>Critical services down:</strong> {{ critical_down | join(", ") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a href="#services-container" class="text-xs text-red-400 hover:text-red-300">View details</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="status-banner all-good rounded-lg px-4 py-2 flex items-center gap-3 border border-emerald-900/50">
|
||||||
|
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
<span class="text-sm text-emerald-300">All critical services operational</span>
|
||||||
|
<span class="text-xs text-gray-500 ml-auto">{{ online_count }}/{{ total_count }} services online</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
45
app/templates/partials/uptime.html
Normal file
45
app/templates/partials/uptime.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<div class="card rounded-lg p-3">
|
||||||
|
{% if uptime.monitors %}
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-gray-400">{{ uptime.summary.up }}/{{ uptime.summary.total }} up</span>
|
||||||
|
{% if uptime.summary.down > 0 %}
|
||||||
|
<span class="text-xs text-red-400">{{ uptime.summary.down }} down</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
{% for monitor in uptime.monitors[:8] %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full flex-shrink-0 {% if monitor.status == 1 %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
|
||||||
|
<span class="text-xs text-gray-300 truncate flex-1">{{ monitor.name }}</span>
|
||||||
|
<!-- Sparkline -->
|
||||||
|
{% if monitor.heartbeats %}
|
||||||
|
<div class="flex items-end gap-px h-3">
|
||||||
|
{% for hb in monitor.heartbeats[-12:] %}
|
||||||
|
{% set ping_height = ((hb.ping or 50) / 5) %}
|
||||||
|
{% if ping_height > 100 %}{% set ping_height = 100 %}{% endif %}
|
||||||
|
{% if ping_height < 20 %}{% set ping_height = 20 %}{% endif %}
|
||||||
|
<div class="w-1 {% if hb.status == 1 %}bg-emerald-500/60{% else %}bg-red-500/60{% endif %}"
|
||||||
|
style="height: {{ ping_height }}%"></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if monitor.ping %}
|
||||||
|
<span class="text-[10px] text-gray-500 w-8 text-right">{{ monitor.ping }}ms</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if uptime.monitors | length > 8 %}
|
||||||
|
<div class="text-xs text-gray-500 mt-2">+{{ uptime.monitors | length - 8 }} more</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center gap-2 text-gray-500 text-xs">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
<span>No uptime data</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{{ uptime_kuma_url }}" target="_blank" class="block text-center text-xs text-gray-500 hover:text-amber-400 mt-2">
|
||||||
|
Open Uptime Kuma
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
92
app/templates/settings.html
Normal file
92
app/templates/settings.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Settings - DeathStar Homelab</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: { extend: { colors: { dark: { 900: '#0a0f1a', 800: '#111827', 700: '#1f2937', 600: '#374151' } } } }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { background: linear-gradient(135deg, #0a0f1a 0%, #111827 100%); }
|
||||||
|
.card { background: rgba(31, 41, 55, 0.5); backdrop-filter: blur(10px); border: 1px solid rgba(75, 85, 99, 0.3); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 min-h-screen p-6">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">Settings</h1>
|
||||||
|
<a href="/" class="px-4 py-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-sm text-gray-300">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if request.query_params.get('saved') %}
|
||||||
|
<div class="mb-4 p-3 rounded-lg bg-emerald-900/30 border border-emerald-700 text-emerald-300 text-sm">Settings saved successfully!</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/settings" class="space-y-6">
|
||||||
|
<!-- General Settings -->
|
||||||
|
<div class="card rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4 text-gray-200">General</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">Refresh Interval (seconds)</label>
|
||||||
|
<select name="refresh_interval" class="w-full bg-dark-700 border border-gray-600 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<option value="15" {% if settings.refresh_interval == 15 %}selected{% endif %}>15 seconds</option>
|
||||||
|
<option value="30" {% if settings.refresh_interval == 30 %}selected{% endif %}>30 seconds</option>
|
||||||
|
<option value="60" {% if settings.refresh_interval == 60 %}selected{% endif %}>60 seconds</option>
|
||||||
|
<option value="120" {% if settings.refresh_interval == 120 %}selected{% endif %}>2 minutes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">Theme</label>
|
||||||
|
<select name="theme" class="w-full bg-dark-700 border border-gray-600 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Dark</option>
|
||||||
|
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Light</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display Options -->
|
||||||
|
<div class="card rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4 text-gray-200">Display Options</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" name="show_response_times" {% if settings.show_response_times %}checked{% endif %} class="w-4 h-4 rounded bg-dark-700 border-gray-600">
|
||||||
|
<span class="text-sm text-gray-300">Show response times</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" name="show_icons" {% if settings.show_icons %}checked{% endif %} class="w-4 h-4 rounded bg-dark-700 border-gray-600">
|
||||||
|
<span class="text-sm text-gray-300">Show service icons</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Favorites -->
|
||||||
|
<div class="card rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4 text-gray-200">Favorites</h2>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">Select services to show in Quick Access</p>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-64 overflow-y-auto">
|
||||||
|
{% for service in all_services %}
|
||||||
|
<label class="flex items-center gap-2 p-2 rounded-lg bg-dark-700 cursor-pointer hover:bg-dark-600">
|
||||||
|
<input type="checkbox" name="favorites" value="{{ service.name }}"
|
||||||
|
{% if service.name in settings.favorites %}checked{% endif %}
|
||||||
|
class="w-3 h-3 rounded bg-dark-600 border-gray-500">
|
||||||
|
<span class="text-xs text-gray-300 truncate">{{ service.name }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<a href="/" class="px-4 py-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-sm text-gray-300">Cancel</a>
|
||||||
|
<button type="submit" class="px-6 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-sm text-white font-medium">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user