Initial commit: Homelab Dashboard with YAML configuration

Features:
- Service health monitoring with response times
- Proxmox cluster integration (nodes, VMs, containers)
- PBS backup server monitoring
- Camera viewer with WebRTC (go2rtc)
- Docker container monitoring
- Uptime Kuma integration
- Mobile-friendly responsive design
- YAML-based configuration for easy setup
This commit is contained in:
Dashboard
2026-02-02 20:27:05 +00:00
commit 89cdb022f3
25 changed files with 2437 additions and 0 deletions

145
app/templates/base.html Normal file
View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DeathStar Homelab</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
dark: { 900: '#0a0f1a', 800: '#111827', 700: '#1f2937', 600: '#374151' }
}
}
}
}
</script>
<style>
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.status-pulse { animation: pulse-glow 2s ease-in-out infinite; }
.htmx-request { opacity: 0.5; }
body { background: linear-gradient(135deg, #0a0f1a 0%, #111827 100%); }
.card { background: rgba(31, 41, 55, 0.5); backdrop-filter: blur(10px); border: 1px solid rgba(75, 85, 99, 0.3); }
.card:hover { border-color: rgba(99, 102, 241, 0.5); }
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.5rem; }
.progress-bar { height: 4px; border-radius: 2px; background: rgba(75, 85, 99, 0.5); overflow: hidden; }
.progress-fill { height: 100%; border-radius: 2px; transition: width 0.3s ease; }
.collapsible-content { max-height: 1000px; overflow: hidden; transition: max-height 0.3s ease; }
.collapsed .collapsible-content { max-height: 0; }
.camera-thumb { aspect-ratio: 16/9; background: #1f2937; border-radius: 0.5rem; overflow: hidden; }
.camera-thumb img { width: 100%; height: 100%; object-fit: cover; }
#search-modal { backdrop-filter: blur(4px); }
.favorite-card { background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%); }
.status-banner { background: linear-gradient(90deg, rgba(239, 68, 68, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%); }
.status-banner.all-good { background: linear-gradient(90deg, rgba(34, 197, 94, 0.1) 0%, rgba(34, 197, 94, 0.05) 100%); }
.response-fast { color: #10b981; }
.response-medium { color: #f59e0b; }
.response-slow { color: #ef4444; }
.svc-icon { width: 14px; height: 14px; flex-shrink: 0; }
</style>
</head>
<body class="text-gray-100 min-h-screen p-3">
<div class="max-w-[1920px] mx-auto">
{% block content %}{% endblock %}
</div>
<!-- Search Modal -->
<div id="search-modal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center">
<div class="bg-dark-800 rounded-xl p-4 w-full max-w-md mx-4 border border-gray-700">
<div class="flex items-center gap-3 mb-3">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
<input type="text" id="search-input" placeholder="Search services..." class="flex-1 bg-transparent border-none outline-none text-white placeholder-gray-500">
<kbd class="px-2 py-1 text-xs bg-dark-700 rounded text-gray-400">ESC</kbd>
</div>
<div id="search-results" class="max-h-64 overflow-y-auto"></div>
</div>
</div>
<script>
// Clock
function updateClock() {
const now = new Date();
const el = document.getElementById('clock');
if (el) el.textContent = now.toLocaleTimeString();
}
setInterval(updateClock, 1000);
updateClock();
// Search
const searchModal = document.getElementById('search-modal');
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
document.addEventListener('keydown', function(e) {
if (e.key === '/' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
searchModal.classList.remove('hidden');
searchModal.classList.add('flex');
searchInput.focus();
}
if (e.key === 'Escape') {
searchModal.classList.add('hidden');
searchModal.classList.remove('flex');
searchInput.value = '';
searchResults.innerHTML = '';
}
if (e.key === 'r' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT') {
refreshAll();
}
});
searchModal.addEventListener('click', function(e) {
if (e.target === searchModal) {
searchModal.classList.add('hidden');
searchModal.classList.remove('flex');
}
});
searchInput.addEventListener('input', function(e) {
const query = e.target.value.toLowerCase();
if (query.length < 2) { searchResults.innerHTML = ''; return; }
const cards = document.querySelectorAll('.service-card');
const matches = [];
cards.forEach(card => {
const name = card.dataset.name?.toLowerCase() || '';
if (name.includes(query)) {
const url = card.querySelector('a')?.href || '#';
const status = card.querySelector('.status-dot')?.classList.contains('bg-emerald-500') ? 'online' : 'offline';
matches.push({ name: card.dataset.name, url, status });
}
});
searchResults.innerHTML = matches.length === 0 ? '<div class="text-gray-500 text-sm py-2">No services found</div>' :
matches.map(m => `<a href="${m.url}" target="_blank" class="flex items-center gap-3 p-2 rounded hover:bg-dark-700"><span class="w-2 h-2 rounded-full ${m.status === 'online' ? 'bg-emerald-500' : 'bg-red-500'}"></span><span>${m.name}</span></a>`).join('');
});
function toggleCategory(header) {
const section = header.closest('.category-section');
section.classList.toggle('collapsed');
const icon = header.querySelector('.collapse-icon');
icon.style.transform = section.classList.contains('collapsed') ? 'rotate(-90deg)' : '';
}
function refreshAll() {
htmx.trigger('#status-banner', 'refresh');
htmx.trigger('#favorites-container', 'refresh');
htmx.trigger('#nodes-container', 'refresh');
htmx.trigger('#services-container', 'refresh');
htmx.trigger('#cameras-container', 'refresh');
htmx.trigger('#downloads-container', 'refresh');
htmx.trigger('#docker-container', 'refresh');
htmx.trigger('#uptime-container', 'refresh');
htmx.trigger('#pbs-container', 'refresh');
htmx.trigger('#events-container', 'refresh');
}
function toggleNodeExpand(nodeEl) {
const expanded = nodeEl.querySelector('.node-expanded');
if (expanded) expanded.classList.toggle('hidden');
}
</script>
</body>
</html>

322
app/templates/cameras.html Normal file
View File

@@ -0,0 +1,322 @@
{% extends "base.html" %}
{% block content %}
<div class="cameras-page">
<!-- Header -->
<div class="flex items-center justify-between mb-4 sticky top-0 bg-dark-900/95 backdrop-blur py-2 z-10">
<div class="flex items-center gap-3">
<a href="/" class="text-gray-400 hover:text-white p-2 -ml-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<h1 class="text-lg font-semibold text-white">Cameras</h1>
<span class="text-xs text-gray-500">{{ cameras | length }} cameras</span>
</div>
<div class="flex items-center gap-2">
<!-- View Mode -->
<div class="flex bg-dark-700 rounded-lg p-0.5">
<button onclick="setViewMode('grid')" id="btn-grid" class="view-btn active px-2 py-1 text-xs rounded-md">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
</button>
<button onclick="setViewMode('list')" id="btn-list" class="view-btn px-2 py-1 text-xs rounded-md">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
<!-- Reconnect -->
<button onclick="reconnectAll()" class="p-2 text-gray-400 hover:text-white hover:bg-dark-700 rounded-lg" title="Reconnect all streams">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</div>
</div>
<!-- Camera Grid -->
<div id="camera-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{% for camera in cameras %}
<div class="camera-card" data-camera="{{ camera }}">
<div class="aspect-video bg-dark-800 rounded-t-xl overflow-hidden relative cursor-pointer" onclick="openFullscreen('{{ camera }}')">
<video id="video-{{ camera }}"
class="w-full h-full object-cover"
autoplay muted playsinline
poster="{{ go2rtc_url }}/api/frame.jpeg?src={{ camera }}"></video>
<!-- Status indicator -->
<div id="status-{{ camera }}" class="absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></div>
<!-- Live indicator -->
<div class="absolute top-2 left-2">
<span class="live-badge flex items-center gap-1 px-1.5 py-0.5 bg-red-600/90 rounded text-[10px] text-white font-medium">
<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span>
LIVE
</span>
</div>
</div>
<div class="flex items-center justify-between bg-dark-700 rounded-b-xl px-3 py-2">
<span class="text-sm text-white font-medium truncate">{{ camera | replace("_", " ") }}</span>
<div class="flex gap-1">
<button onclick="event.stopPropagation(); reconnectCamera('{{ camera }}')" class="p-1.5 text-gray-400 hover:text-cyan-400 hover:bg-dark-600 rounded" title="Reconnect">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
<button onclick="event.stopPropagation(); openFullscreen('{{ camera }}')" class="p-1.5 text-gray-400 hover:text-white hover:bg-dark-600 rounded" title="Fullscreen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
</svg>
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Fullscreen Modal -->
<div id="fullscreen-modal" class="fixed inset-0 bg-black z-50 hidden">
<div class="absolute top-4 left-4 right-4 flex items-center justify-between z-10">
<div class="flex items-center gap-3">
<h2 id="modal-title" class="text-white text-lg font-semibold drop-shadow-lg"></h2>
<span class="live-badge flex items-center gap-1 px-2 py-0.5 bg-red-600 rounded text-xs text-white font-medium">
<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span>
LIVE
</span>
</div>
<button onclick="closeFullscreen()" class="p-2 bg-gray-800/80 hover:bg-gray-700 rounded-lg text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="w-full h-full flex items-center justify-center">
<video id="modal-video" class="max-w-full max-h-full" autoplay playsinline></video>
</div>
<button onclick="prevCamera()" class="absolute left-2 top-1/2 -translate-y-1/2 p-3 bg-black/50 hover:bg-black/70 rounded-full text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<button onclick="nextCamera()" class="absolute right-2 top-1/2 -translate-y-1/2 p-3 bg-black/50 hover:bg-black/70 rounded-full text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 bg-black/60 rounded-full text-white text-sm">
<span id="modal-counter">1 / 12</span>
</div>
</div>
<style>
.cameras-page { padding-bottom: env(safe-area-inset-bottom); }
.view-btn { color: #9ca3af; transition: all 0.15s; }
.view-btn.active { background: #374151; color: white; }
.view-btn:hover { color: white; }
@supports (padding: max(0px)) {
.cameras-page {
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.75rem, env(safe-area-inset-right));
}
}
</style>
<script>
const cameras = {{ cameras | tojson }};
let currentCameraIndex = 0;
let peerConnections = {};
let fullscreenPC = null;
// Initialize WebRTC stream for a camera
async function initCameraStream(cameraId) {
const video = document.getElementById('video-' + cameraId);
const status = document.getElementById('status-' + cameraId);
if (!video) return;
try {
// Close existing connection if any
if (peerConnections[cameraId]) {
peerConnections[cameraId].close();
}
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse';
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
peerConnections[cameraId] = pc;
pc.ontrack = (event) => {
video.srcObject = event.streams[0];
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-green-500';
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-red-500';
setTimeout(() => initCameraStream(cameraId), 5000);
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const response = await fetch('/api/webrtc?src=' + cameraId, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp
});
if (!response.ok) throw new Error('HTTP ' + response.status);
const answer = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
} catch (error) {
console.error('Failed to connect ' + cameraId + ':', error);
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-red-500';
setTimeout(() => initCameraStream(cameraId), 10000);
}
}
function reconnectCamera(cameraId) {
initCameraStream(cameraId);
}
function reconnectAll() {
cameras.forEach(cam => initCameraStream(cam));
}
function setViewMode(mode) {
const grid = document.getElementById('camera-grid');
const btnGrid = document.getElementById('btn-grid');
const btnList = document.getElementById('btn-list');
if (mode === 'list') {
grid.className = 'flex flex-col gap-3';
btnList.classList.add('active');
btnGrid.classList.remove('active');
} else {
grid.className = 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3';
btnGrid.classList.add('active');
btnList.classList.remove('active');
}
localStorage.setItem('cameraViewMode', mode);
}
async function openFullscreen(camera) {
currentCameraIndex = cameras.indexOf(camera);
const modal = document.getElementById('fullscreen-modal');
const video = document.getElementById('modal-video');
const title = document.getElementById('modal-title');
const counter = document.getElementById('modal-counter');
title.textContent = camera.replace(/_/g, ' ');
counter.textContent = (currentCameraIndex + 1) + ' / ' + cameras.length;
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
await initFullscreenStream(camera, video);
}
async function initFullscreenStream(cameraId, video) {
if (fullscreenPC) {
fullscreenPC.close();
}
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
fullscreenPC = pc;
pc.ontrack = (event) => {
video.srcObject = event.streams[0];
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
try {
const response = await fetch('/api/webrtc?src=' + cameraId, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp
});
const answer = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
} catch (error) {
console.error('Fullscreen stream error:', error);
}
}
function closeFullscreen() {
const modal = document.getElementById('fullscreen-modal');
const video = document.getElementById('modal-video');
modal.classList.add('hidden');
document.body.style.overflow = '';
if (fullscreenPC) {
fullscreenPC.close();
fullscreenPC = null;
}
video.srcObject = null;
}
function prevCamera() {
currentCameraIndex = (currentCameraIndex - 1 + cameras.length) % cameras.length;
openFullscreen(cameras[currentCameraIndex]);
}
function nextCamera() {
currentCameraIndex = (currentCameraIndex + 1) % cameras.length;
openFullscreen(cameras[currentCameraIndex]);
}
document.addEventListener('keydown', (e) => {
if (!document.getElementById('fullscreen-modal').classList.contains('hidden')) {
if (e.key === 'Escape') closeFullscreen();
if (e.key === 'ArrowLeft') prevCamera();
if (e.key === 'ArrowRight') nextCamera();
}
});
let touchStartX = 0;
document.getElementById('fullscreen-modal').addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
});
document.getElementById('fullscreen-modal').addEventListener('touchend', (e) => {
const diff = touchStartX - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) {
if (diff > 0) nextCamera();
else prevCamera();
}
});
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
const savedView = localStorage.getItem('cameraViewMode');
if (savedView) setViewMode(savedView);
// Stagger camera connections to avoid overwhelming the server
cameras.forEach((cam, index) => {
setTimeout(() => initCameraStream(cam), index * 500);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block content %}
<!-- Header -->
<header class="mb-3">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-4">
<h1 class="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">DeathStar Homelab</h1>
<button onclick="refreshAll()" class="p-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors" title="Refresh (R)">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
</button>
<button onclick="document.getElementById('search-modal').classList.remove('hidden'); document.getElementById('search-modal').classList.add('flex'); document.getElementById('search-input').focus();" class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors text-xs text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
<span>Search</span>
<kbd class="px-1.5 py-0.5 bg-dark-600 rounded text-xs">/</kbd>
</button>
<a href="/settings" class="p-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors" title="Settings">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</a>
</div>
<div class="flex items-center gap-4 text-xs text-gray-400">
<span id="clock" class="font-mono"></span>
<span>{{ online_count }}/{{ total_count }} online</span>
<span>Updated: {{ last_check }}</span>
</div>
</div>
<div id="status-banner" hx-get="/api/status-banner" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
{% include "partials/status_banner.html" %}
</div>
</header>
<!-- Favorites -->
<section class="mb-3">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>
<h2 class="text-sm font-semibold text-gray-300">Quick Access</h2>
</div>
<div id="favorites-container" hx-get="/api/favorites" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
{% include "partials/favorites.html" %}
</div>
</section>
<!-- Proxmox Cluster -->
<section class="mb-3">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"></path></svg>
<h2 class="text-sm font-semibold text-gray-300">Proxmox Cluster: NewHope</h2>
<span class="text-xs text-gray-500">{{ cluster_uptime }}h total uptime</span>
</div>
<div id="nodes-container" hx-get="/api/nodes" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
{% include "partials/nodes.html" %}
</div>
</section>
<!-- Main Content -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-3">
<!-- Services (3 cols) -->
<div class="lg:col-span-3">
<section id="services-container" hx-get="/api/services" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
{% include "partials/services.html" %}
</section>
</div>
<!-- Right Sidebar -->
<div class="space-y-3">
<!-- PBS Backup -->
<section>
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
<h2 class="text-sm font-semibold text-gray-300">Backups</h2>
</div>
<div id="pbs-container" hx-get="/api/pbs" hx-trigger="every 60s, refresh" hx-swap="innerHTML">
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
</div>
</section>
<!-- Recent Events -->
<section>
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<h2 class="text-sm font-semibold text-gray-300">Recent Events</h2>
</div>
<div id="events-container" hx-get="/api/events" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
</div>
</section>
<!-- Downloads -->
<section>
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
<h2 class="text-sm font-semibold text-gray-300">Downloads</h2>
</div>
<div id="downloads-container" hx-get="/api/downloads" hx-trigger="every 15s, refresh" hx-swap="innerHTML">
{% include "partials/downloads.html" %}
</div>
</section>
<!-- Cameras -->
<section>
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
<h2 class="text-sm font-semibold text-gray-300">Cameras</h2>
</div>
<div id="cameras-container" hx-get="/api/cameras" hx-trigger="every 60s, refresh" hx-swap="innerHTML">
{% include "partials/cameras.html" %}
</div>
</section>
<!-- Docker -->
<section>
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
<h2 class="text-sm font-semibold text-gray-300">Docker</h2>
<span class="text-xs text-gray-500">{% for name, count in docker_counts.items() %}{{ count }}{% if not loop.last %}+{% endif %}{% endfor %} containers</span>
</div>
<div id="docker-container" hx-get="/api/docker" hx-trigger="every 30s, refresh" hx-swap="innerHTML">
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
</div>
</section>
<!-- Uptime Kuma -->
<section>
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
<h2 class="text-sm font-semibold text-gray-300">Uptime</h2>
</div>
<div id="uptime-container" hx-get="/api/uptime" hx-trigger="every 60s, refresh" hx-swap="innerHTML">
<div class="card rounded-lg p-3 text-gray-500 text-xs">Loading...</div>
</div>
</section>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
<div class="grid grid-cols-2 gap-2">
{% for camera in cameras[:4] %}
<div class="camera-thumb relative group">
<img src="/api/camera-snapshot/{{ camera }}"
alt="{{ camera }}"
class="w-full h-full"
loading="lazy"
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 56%22><rect fill=%22%231f2937%22 width=%22100%22 height=%2256%22/><text x=%2250%22 y=%2228%22 text-anchor=%22middle%22 fill=%22%236b7280%22 font-size=%228%22>No Signal</text></svg>'">
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<a href="{{ go2rtc_url }}/stream.html?src={{ camera }}" target="_blank" class="text-white text-xs hover:text-cyan-400">
{{ camera }}
</a>
</div>
<div class="absolute bottom-1 left-1 text-[10px] text-white bg-black/50 px-1 rounded">{{ camera }}</div>
</div>
{% endfor %}
</div>
<a href="/cameras" class="block text-center text-xs text-gray-500 hover:text-cyan-400 mt-2">
View all {{ cameras | length }} cameras
</a>

View File

@@ -0,0 +1,20 @@
<div class="space-y-2">
{% for host in hosts %}
<div class="card rounded-lg p-2">
<div class="flex items-center justify-between text-xs text-gray-400 mb-2">
<span class="font-medium">{{ host.name }}</span>
<span class="text-[10px]">{{ counts.get(host.name, 0) }} running</span>
</div>
<div class="flex flex-wrap gap-1">
{% for container in containers if container.host == host.name %}
<div class="flex items-center gap-1.5 px-2 py-1 rounded bg-dark-700 text-xs">
<span class="w-1.5 h-1.5 rounded-full {% if container.state == 'running' %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
<span class="text-gray-300 truncate max-w-[80px]" title="{{ container.name }}">{{ container.name }}</span>
</div>
{% else %}
<span class="text-gray-500 text-xs">No containers</span>
{% endfor %}
</div>
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,40 @@
<div class="card rounded-lg p-3">
{% if queue and queue.downloading > 0 %}
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400">{{ queue.downloading }} active</span>
<span class="text-xs text-emerald-400 font-mono">{{ queue.speed }}</span>
</div>
{% if queue.items %}
<div class="space-y-2">
{% for item in queue.items %}
<div>
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-gray-300 truncate flex-1 mr-2">{{ item.name }}</span>
<span class="text-gray-500">{{ item.progress | round(1) }}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill bg-emerald-500" style="width: {{ item.progress }}%"></div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if queue.eta and queue.eta != 'Unknown' %}
<div class="flex items-center justify-between mt-2 text-xs text-gray-500">
<span>ETA: {{ queue.eta }}</span>
<span>{{ queue.size_left }} left</span>
</div>
{% endif %}
{% else %}
<div class="flex items-center gap-2 text-gray-500 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>No active downloads</span>
</div>
{% endif %}
<a href="https://sabnzbd.deathstar-home.one" target="_blank" class="block text-center text-xs text-gray-500 hover:text-green-400 mt-2">
Open Sabnzbd
</a>
</div>

View File

@@ -0,0 +1,16 @@
<div class="card rounded-lg p-3">
{% if events %}
<div class="space-y-1.5">
{% for event in events | reverse %}
<div class="flex items-center gap-2 text-xs">
<span class="w-1.5 h-1.5 rounded-full flex-shrink-0 {% if event.new_status == 'online' %}bg-emerald-500{% elif event.new_status == 'degraded' %}bg-amber-500{% else %}bg-red-500{% endif %}"></span>
<span class="text-gray-300 truncate flex-1">{{ event.service }}</span>
<span class="text-[10px] {% if event.new_status == 'online' %}text-emerald-400{% else %}text-red-400{% endif %}">{{ event.new_status }}</span>
<span class="text-[10px] text-gray-500">{{ event.timestamp.strftime('%H:%M') }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-gray-500 text-xs text-center py-2">No recent events</div>
{% endif %}
</div>

View File

@@ -0,0 +1,13 @@
<div class="flex flex-wrap gap-2">
{% for service in favorites %}
{% set status = services_status.get(service.name) %}
<a href="{{ service.url }}" target="_blank"
class="favorite-card card flex items-center gap-2 px-3 py-2 rounded-lg hover:scale-105 transition-transform">
<span class="w-2 h-2 rounded-full {% if status and status.status == 'online' %}bg-emerald-500{% elif status and status.status == 'degraded' %}bg-amber-500{% else %}bg-red-500{% endif %}"></span>
<span class="text-sm font-medium">{{ service.name }}</span>
{% if status and status.response_time_ms %}
<span class="text-xs text-gray-500">{{ status.response_time_ms }}ms</span>
{% endif %}
</a>
{% endfor %}
</div>

View File

@@ -0,0 +1,66 @@
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2">
{% for node in nodes_status %}
<div class="card rounded-lg p-3 cursor-pointer" onclick="toggleNodeExpand(this)">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full {% if node.status == 'online' %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
<span class="font-semibold text-sm">{{ node.name }}</span>
</div>
{% if node.uptime_hours %}
<span class="text-xs text-gray-500">{{ (node.uptime_hours / 24) | round(1) }}d</span>
{% endif %}
</div>
{% if node.status == 'online' %}
<div class="mb-2">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-gray-400">CPU</span>
<span class="{% if node.cpu_percent and node.cpu_percent > 80 %}text-red-400{% elif node.cpu_percent and node.cpu_percent > 60 %}text-amber-400{% else %}text-emerald-400{% endif %}">{{ node.cpu_percent | default(0) | round(1) }}%</span>
</div>
<div class="progress-bar"><div class="progress-fill {% if node.cpu_percent and node.cpu_percent > 80 %}bg-red-500{% elif node.cpu_percent and node.cpu_percent > 60 %}bg-amber-500{% else %}bg-emerald-500{% endif %}" style="width: {{ node.cpu_percent | default(0) }}%"></div></div>
</div>
<div class="mb-2">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-gray-400">RAM</span>
<span class="{% if node.memory_percent and node.memory_percent > 85 %}text-red-400{% elif node.memory_percent and node.memory_percent > 70 %}text-amber-400{% else %}text-blue-400{% endif %}">{% if node.memory_used_gb %}{{ node.memory_used_gb }}{% else %}?{% endif %}/{% if node.memory_total_gb %}{{ node.memory_total_gb }}{% else %}?{% endif %}GB</span>
</div>
<div class="progress-bar"><div class="progress-fill {% if node.memory_percent and node.memory_percent > 85 %}bg-red-500{% elif node.memory_percent and node.memory_percent > 70 %}bg-amber-500{% else %}bg-blue-500{% endif %}" style="width: {{ node.memory_percent | default(0) }}%"></div></div>
</div>
<div>
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-gray-400">Disk</span>
<span class="{% if node.disk_percent and node.disk_percent > 85 %}text-red-400{% elif node.disk_percent and node.disk_percent > 70 %}text-amber-400{% else %}text-purple-400{% endif %}">{{ node.disk_percent | default(0) | round(1) }}%</span>
</div>
<div class="progress-bar"><div class="progress-fill {% if node.disk_percent and node.disk_percent > 85 %}bg-red-500{% elif node.disk_percent and node.disk_percent > 70 %}bg-amber-500{% else %}bg-purple-500{% endif %}" style="width: {{ node.disk_percent | default(0) }}%"></div></div>
</div>
<!-- Expandable VM/CT list -->
<div class="node-expanded hidden mt-2 pt-2 border-t border-gray-700">
{% if node.vms %}
<div class="text-[10px] text-gray-400 mb-1">VMs ({{ node.vms | length }})</div>
<div class="flex flex-wrap gap-1 mb-2">
{% for vm in node.vms[:5] %}
<span class="px-1 py-0.5 rounded text-[9px] {% if vm.status == 'running' %}bg-emerald-900/50 text-emerald-400{% else %}bg-gray-700 text-gray-400{% endif %}">{{ vm.name[:10] }}</span>
{% endfor %}
{% if node.vms | length > 5 %}<span class="text-[9px] text-gray-500">+{{ node.vms | length - 5 }}</span>{% endif %}
</div>
{% endif %}
{% if node.containers %}
<div class="text-[10px] text-gray-400 mb-1">LXC ({{ node.containers | length }})</div>
<div class="flex flex-wrap gap-1">
{% for ct in node.containers[:5] %}
<span class="px-1 py-0.5 rounded text-[9px] {% if ct.status == 'running' %}bg-blue-900/50 text-blue-400{% else %}bg-gray-700 text-gray-400{% endif %}">{{ ct.name[:10] }}</span>
{% endfor %}
{% if node.containers | length > 5 %}<span class="text-[9px] text-gray-500">+{{ node.containers | length - 5 }}</span>{% endif %}
</div>
{% endif %}
</div>
{% else %}
<div class="text-center text-red-400 text-xs py-2">Offline</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if cluster_uptime %}
<div class="text-xs text-gray-500 mt-2 text-right">Cluster total: {{ cluster_uptime }}h</div>
{% endif %}

View File

@@ -0,0 +1,31 @@
<div class="card rounded-lg p-3">
{% if pbs.status == 'online' %}
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-emerald-400">Online</span>
{% if pbs.last_backup %}
<span class="text-xs text-gray-500">Last: {{ pbs.last_backup }}</span>
{% endif %}
</div>
{% if pbs.datastore_usage %}
<div class="space-y-2">
{% for ds in pbs.datastore_usage %}
<div>
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-gray-300">{{ ds.name }}</span>
<span class="text-gray-500">{{ ds.used_gb }}/{{ ds.total_gb }}GB</span>
</div>
<div class="progress-bar">
<div class="progress-fill {% if ds.percent > 85 %}bg-red-500{% elif ds.percent > 70 %}bg-amber-500{% else %}bg-orange-500{% endif %}" style="width: {{ ds.percent }}%"></div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="flex items-center gap-2 text-gray-500 text-xs">
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
<span>PBS Offline</span>
</div>
{% endif %}
<a href="https://192.168.1.159:8007" target="_blank" class="block text-center text-xs text-gray-500 hover:text-orange-400 mt-2">Open PBS</a>
</div>

View File

@@ -0,0 +1,60 @@
{% for category_name, category_services in services_by_category.items() %}
{% set cat_info = categories.get(category_name, {}) %}
<div class="category-section mb-3">
<div class="flex items-center gap-2 mb-2 cursor-pointer select-none" onclick="toggleCategory(this)">
<svg class="w-3 h-3 text-gray-500 collapse-icon transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
<span class="w-3 h-3 rounded-full
{% if cat_info.color == 'blue' %}bg-blue-500
{% elif cat_info.color == 'purple' %}bg-purple-500
{% elif cat_info.color == 'amber' %}bg-amber-500
{% elif cat_info.color == 'emerald' %}bg-emerald-500
{% elif cat_info.color == 'cyan' %}bg-cyan-500
{% else %}bg-gray-500{% endif %}"></span>
<h3 class="text-sm font-semibold text-gray-300">{{ category_name }}</h3>
<span class="text-xs text-gray-500">({{ category_services | length }})</span>
</div>
<div class="collapsible-content">
<div class="service-grid">
{% for service in category_services %}
{% set status = services_status.get(service.name) %}
<a href="{{ service.url }}" target="_blank"
class="service-card card rounded-lg p-2 flex items-center gap-2 hover:scale-[1.02] transition-transform"
data-name="{{ service.name }}"
data-category="{{ category_name }}">
<!-- Status dot -->
<span class="status-dot w-2 h-2 rounded-full flex-shrink-0
{% if status and status.status == 'online' %}bg-emerald-500 status-online
{% elif status and status.status == 'degraded' %}bg-amber-500 status-pulse
{% else %}bg-red-500 status-offline{% endif %}"></span>
<!-- Icon -->
<svg class="svc-icon text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">{{ get_service_icon(service.icon) | safe }}</svg>
<!-- Name and response time -->
<div class="flex-1 min-w-0">
<div class="text-xs font-medium truncate">{{ service.name }}</div>
{% if status and status.response_time_ms %}
<div class="text-[10px] {% if status.response_time_ms < 100 %}response-fast{% elif status.response_time_ms < 500 %}response-medium{% else %}response-slow{% endif %}">
{{ status.response_time_ms }}ms
</div>
{% elif status and status.status == 'offline' %}
<div class="text-[10px] text-red-400">Offline</div>
{% endif %}
</div>
<!-- Critical badge -->
{% if service.critical %}
<svg class="w-3 h-3 text-red-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>
{% endif %}
<!-- Group badge -->
{% if service.group %}
<span class="text-[8px] px-1 py-0.5 rounded bg-dark-600 text-gray-400">{{ service.group[:3] }}</span>
{% endif %}
</a>
{% endfor %}
</div>
</div>
</div>
{% endfor %}

View File

@@ -0,0 +1,17 @@
{% if critical_down %}
<div class="status-banner rounded-lg px-4 py-2 flex items-center justify-between border border-red-900/50">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-red-400 status-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
<span class="text-sm text-red-300">
<strong>Critical services down:</strong> {{ critical_down | join(", ") }}
</span>
</div>
<a href="#services-container" class="text-xs text-red-400 hover:text-red-300">View details</a>
</div>
{% else %}
<div class="status-banner all-good rounded-lg px-4 py-2 flex items-center gap-3 border border-emerald-900/50">
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span class="text-sm text-emerald-300">All critical services operational</span>
<span class="text-xs text-gray-500 ml-auto">{{ online_count }}/{{ total_count }} services online</span>
</div>
{% endif %}

View File

@@ -0,0 +1,45 @@
<div class="card rounded-lg p-3">
{% if uptime.monitors %}
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400">{{ uptime.summary.up }}/{{ uptime.summary.total }} up</span>
{% if uptime.summary.down > 0 %}
<span class="text-xs text-red-400">{{ uptime.summary.down }} down</span>
{% endif %}
</div>
<div class="space-y-1.5">
{% for monitor in uptime.monitors[:8] %}
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full flex-shrink-0 {% if monitor.status == 1 %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
<span class="text-xs text-gray-300 truncate flex-1">{{ monitor.name }}</span>
<!-- Sparkline -->
{% if monitor.heartbeats %}
<div class="flex items-end gap-px h-3">
{% for hb in monitor.heartbeats[-12:] %}
{% set ping_height = ((hb.ping or 50) / 5) %}
{% if ping_height > 100 %}{% set ping_height = 100 %}{% endif %}
{% if ping_height < 20 %}{% set ping_height = 20 %}{% endif %}
<div class="w-1 {% if hb.status == 1 %}bg-emerald-500/60{% else %}bg-red-500/60{% endif %}"
style="height: {{ ping_height }}%"></div>
{% endfor %}
</div>
{% endif %}
{% if monitor.ping %}
<span class="text-[10px] text-gray-500 w-8 text-right">{{ monitor.ping }}ms</span>
{% endif %}
</div>
{% endfor %}
</div>
{% if uptime.monitors | length > 8 %}
<div class="text-xs text-gray-500 mt-2">+{{ uptime.monitors | length - 8 }} more</div>
{% endif %}
{% else %}
<div class="flex items-center gap-2 text-gray-500 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>No uptime data</span>
</div>
{% endif %}
<a href="{{ uptime_kuma_url }}" target="_blank" class="block text-center text-xs text-gray-500 hover:text-amber-400 mt-2">
Open Uptime Kuma
</a>
</div>

View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - DeathStar Homelab</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { dark: { 900: '#0a0f1a', 800: '#111827', 700: '#1f2937', 600: '#374151' } } } }
}
</script>
<style>
body { background: linear-gradient(135deg, #0a0f1a 0%, #111827 100%); }
.card { background: rgba(31, 41, 55, 0.5); backdrop-filter: blur(10px); border: 1px solid rgba(75, 85, 99, 0.3); }
</style>
</head>
<body class="text-gray-100 min-h-screen p-6">
<div class="max-w-2xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">Settings</h1>
<a href="/" class="px-4 py-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-sm text-gray-300">Back to Dashboard</a>
</div>
{% if request.query_params.get('saved') %}
<div class="mb-4 p-3 rounded-lg bg-emerald-900/30 border border-emerald-700 text-emerald-300 text-sm">Settings saved successfully!</div>
{% endif %}
<form method="POST" action="/settings" class="space-y-6">
<!-- General Settings -->
<div class="card rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 text-gray-200">General</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-2">Refresh Interval (seconds)</label>
<select name="refresh_interval" class="w-full bg-dark-700 border border-gray-600 rounded-lg px-3 py-2 text-sm">
<option value="15" {% if settings.refresh_interval == 15 %}selected{% endif %}>15 seconds</option>
<option value="30" {% if settings.refresh_interval == 30 %}selected{% endif %}>30 seconds</option>
<option value="60" {% if settings.refresh_interval == 60 %}selected{% endif %}>60 seconds</option>
<option value="120" {% if settings.refresh_interval == 120 %}selected{% endif %}>2 minutes</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">Theme</label>
<select name="theme" class="w-full bg-dark-700 border border-gray-600 rounded-lg px-3 py-2 text-sm">
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Dark</option>
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Light</option>
</select>
</div>
</div>
</div>
<!-- Display Options -->
<div class="card rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 text-gray-200">Display Options</h2>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="show_response_times" {% if settings.show_response_times %}checked{% endif %} class="w-4 h-4 rounded bg-dark-700 border-gray-600">
<span class="text-sm text-gray-300">Show response times</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="show_icons" {% if settings.show_icons %}checked{% endif %} class="w-4 h-4 rounded bg-dark-700 border-gray-600">
<span class="text-sm text-gray-300">Show service icons</span>
</label>
</div>
</div>
<!-- Favorites -->
<div class="card rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 text-gray-200">Favorites</h2>
<p class="text-xs text-gray-500 mb-3">Select services to show in Quick Access</p>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-64 overflow-y-auto">
{% for service in all_services %}
<label class="flex items-center gap-2 p-2 rounded-lg bg-dark-700 cursor-pointer hover:bg-dark-600">
<input type="checkbox" name="favorites" value="{{ service.name }}"
{% if service.name in settings.favorites %}checked{% endif %}
class="w-3 h-3 rounded bg-dark-600 border-gray-500">
<span class="text-xs text-gray-300 truncate">{{ service.name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="flex justify-end gap-3">
<a href="/" class="px-4 py-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-sm text-gray-300">Cancel</a>
<button type="submit" class="px-6 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-sm text-white font-medium">Save Settings</button>
</div>
</form>
</div>
</body>
</html>