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>