commit a168fb3b46fddfaeb6a4bcc481f91afabe81698b Author: Florian Egger Date: Fri Sep 4 11:22:17 2026 +0200 Add static homelab dashboard with Docker and nginx setup diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1e98a66 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +*.md +.dockerignore +docker-compose.yml +Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1ebe09f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# Lightweight static host for the homelab dashboard. +# Builds a small nginx image that serves index.html + assets. +FROM nginx:1.27-alpine + +# Copy the site (static files only — no build step needed) +COPY index.html style.css script.js /usr/share/nginx/html/ +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# nginx listens on port 80 +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://127.0.0.1:80/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ab4e93 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Homelab Dashboard + +A small, self-contained single-page dashboard that links all your homelab services. +Static site (HTML + CSS + JS), served by a lightweight **nginx** image. No backend, no +database, no build step — just static files. + +## Services + +Internal: Open WebUI, ComfyUI, Gitea, Jellyfin, Immich, Home Assistant, +Audio Server / Dubplate, Nextcloud, Vaultwarden, Nginx Proxy Manager, Netbird. + +External: gitlab-ixsol, gem360. + +## Features + +- Responsive grid of service cards with icons, categories and a live "Online" indicator +- **Open** (new tab) + **Copy URL** per service +- Live search / filter across names, categories and URLs +- Live local clock +- Dark, themeable design; respects `prefers-reduced-motion` + +## Run with Docker + +```bash +docker compose up -d --build +``` + +Then open . + +### Without Docker (quick dev) + +Any static file server works: + +```bash +# from this directory +python3 -m http.server 8888 +# or: npx serve . -l 8888 +``` + +## Edit services + +All services live in one array at the top of [`script.js`](./script.js). +Add / remove / reorder entries there: + +```js +{ section: "internal", name: "My Service", url: "http://10.0.0.5:9000", cat: "infra", icon: "⚙️" } +``` + +`section` is either `"internal"` or `"external"`. +`cat` is any key from the `CATEGORIES` map (or add your own). + +## Build the image manually + +```bash +docker build -t homelab-dashboard . +docker run -d --name homelab-dashboard -p 8888:80 --restart unless-stopped homelab-dashboard +``` + +## Notes + +- The "Online" badge is cosmetic (a static site can't probe your services from the browser + due to CORS). If you want real health checks later, a tiny backend that pings each URL + would be the next step. +- Port 8888 is published by the Compose file; change it there if needed. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b407ac9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + dashboard: + build: + context: . + dockerfile: Dockerfile + image: homelab-dashboard:latest + container_name: homelab-dashboard + ports: + - "8888:80" + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "1m" + max-file: "2" diff --git a/index.html b/index.html new file mode 100644 index 0000000..4e58055 --- /dev/null +++ b/index.html @@ -0,0 +1,65 @@ + + + + + + + Homelab Dashboard + + + + + + + + +
+
+
+ +
+

Homelab

+

All your services, one place.

+
+
+
+ +
+ --:--:-- + +
+
+
+ +
+ +
+ +
+ +
+
+ + + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..fbc12a8 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,26 @@ +# Custom nginx config (used by the Dockerfile). +# Serves the static dashboard with sensible caching + security headers. + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ =404; + } + + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + # Hide nginx version + server_tokens off; + + access_log off; + error_log /var/log/nginx/error.log warn; +} diff --git a/script.js b/script.js new file mode 100644 index 0000000..9baef11 --- /dev/null +++ b/script.js @@ -0,0 +1,200 @@ +// --------------------------------------------------------------------------- +// Homelab Dashboard +// Edit this array to add, remove, or reorganise services. +// section : "internal" | "external" +// name : display name +// url : full URL (with http:// or https://) +// cat : a key from the CATEGORIES map below +// icon : an emoji (or any short text) +// --------------------------------------------------------------------------- + +const CATEGORIES = { + ai: { label: "AI", color: "#a855f7" }, + dev: { label: "Development", color: "#3b82f6" }, + media: { label: "Media", color: "#ec4899" }, + photos: { label: "Photos", color: "#14b8a6" }, + smartHome: { label: "Smart Home", color: "#10b981" }, + audio: { label: "Audio", color: "#f97316" }, + storage: { label: "Storage", color: "#6366f1" }, + security: { label: "Security", color: "#f43f5e" }, + infra: { label: "Infrastructure", color: "#f59e0b" }, + network: { label: "Networking", color: "#0ea5e9" }, + external: { label: "External", color: "#22d3ee" }, +}; + +const SERVICES = [ + // ----- Internal ----- + { section: "internal", name: "Open WebUI", url: "http://100.103.83.12:3000", cat: "ai", icon: "🤖" }, + { section: "internal", name: "ComfyUI", url: "http://100.103.83.12:8288", cat: "ai", icon: "🎨" }, + { section: "internal", name: "Gitea", url: "http://100.103.83.12:3003", cat: "dev", icon: "🐙" }, + { section: "internal", name: "Jellyfin", url: "http://100.103.83.12:8096", cat: "media", icon: "🎬" }, + { section: "internal", name: "Immich", url: "http://100.103.83.12:2283", cat: "photos", icon: "📸" }, + { section: "internal", name: "Home Assistant", url: "http://192.168.178.45:8123", cat: "smartHome", icon: "🏠" }, + { section: "internal", name: "Audio Server / Dubplate", url: "http://192.168.178.100:8180", cat: "audio", icon: "🎧" }, + { section: "internal", name: "Nextcloud", url: "https://cloud.gg36", cat: "storage", icon: "☁️" }, + { section: "internal", name: "Vaultwarden", url: "https://vault.gg36.at", cat: "security", icon: "🔐" }, + { section: "internal", name: "Nginx Proxy Manager", url: "http://192.168.178.194:81", cat: "infra", icon: "⚙️" }, + { section: "internal", name: "Netbird", url: "https://netbird.gg36.at", cat: "network", icon: "🛰️" }, + + // ----- External ----- + { section: "external", name: "gitlab-ixsol", url: "https://gitlab.ixsol.wien", cat: "external", icon: "🐙" }, + { section: "external", name: "gem360", url: "https://gem360.ixslo.wien", cat: "external", icon: "🎮" }, +]; + +const SECTION_META = { + internal: { title: "Internal Services", order: 1 }, + external: { title: "External", order: 2 }, +}; + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +const app = document.getElementById("app"); +const searchInput = document.getElementById("search"); +const statsEl = document.getElementById("stats"); + +function escapeHtml(str) { + return String(str) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function cardTemplate(s, idx) { + const meta = CATEGORIES[s.cat] || { label: s.cat || "Service", color: "var(--accent)" }; + const urlHost = s.url.replace(/^https?:\/\//, "").replace(/\/$/, ""); + return ` +
+
+ + Online +
+
+ ${escapeHtml(meta.label)} +

${escapeHtml(s.name)}

+ ${escapeHtml(urlHost)} +
+
+ + Open + + + +
+
`; +} + +function render() { + const grouped = {}; + for (const s of SERVICES) (grouped[s.section] ||= []).push(s); + + const sections = Object.keys(grouped).sort( + (a, b) => (SECTION_META[a]?.order ?? 99) - (SECTION_META[b]?.order ?? 99), + ); + + app.innerHTML = sections + .map((key) => { + const meta = SECTION_META[key] || { title: key }; + const cards = grouped[key].map((s, i) => cardTemplate(s, i)).join(""); + return ` +
+

${escapeHtml(meta.title)} ${grouped[key].length}

+
${cards}
+
`; + }) + .join(""); + + statsEl.textContent = `${SERVICES.length} services · ${grouped.internal?.length || 0} internal · ${ + grouped.external?.length || 0 + } external`; + + bindCopyButtons(); +} + +function bindCopyButtons() { + app.querySelectorAll("[data-copy]").forEach((btn) => { + btn.addEventListener("click", async () => { + const url = btn.getAttribute("data-copy"); + try { + await navigator.clipboard.writeText(url); + } catch { + const ta = document.createElement("textarea"); + ta.value = url; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + ta.remove(); + } + const original = btn.innerHTML; + btn.innerHTML = ``; + setTimeout(() => (btn.innerHTML = original), 1300); + }); + }); +} + +// --------------------------------------------------------------------------- +// Search / filter +// --------------------------------------------------------------------------- + +function filter() { + const q = searchInput.value.trim().toLowerCase(); + const cards = app.querySelectorAll(".card"); + let visible = 0; + cards.forEach((card) => { + const match = !q || card.getAttribute("data-search").includes(q); + card.classList.toggle("hidden", !match); + if (match) visible++; + }); + + // hide sections with no visible cards + app.querySelectorAll(".section").forEach((sec) => { + const hasVisible = sec.querySelector(".card:not(.hidden)"); + sec.style.display = hasVisible ? "" : "none"; + }); + + let noRes = document.querySelector(".no-results"); + if (visible === 0) { + if (!noRes) { + noRes = document.createElement("div"); + noRes.className = "no-results"; + noRes.textContent = "No services match your search."; + app.appendChild(noRes); + } + } else if (noRes) { + noRes.remove(); + } +} + +// --------------------------------------------------------------------------- +// Clock +// --------------------------------------------------------------------------- + +function tickClock() { + const now = new Date(); + const time = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const date = now.toLocaleDateString([], { weekday: "short", day: "numeric", month: "short", year: "numeric" }); + document.getElementById("clock-time").textContent = time; + document.getElementById("clock-date").textContent = date; +} + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +render(); +tickClock(); +setInterval(tickClock, 1000); +searchInput.addEventListener("input", filter); diff --git a/style.css b/style.css new file mode 100644 index 0000000..04081c2 --- /dev/null +++ b/style.css @@ -0,0 +1,448 @@ +:root { + --bg: #070b14; + --bg-soft: #0b1220; + --surface: rgba(255, 255, 255, 0.035); + --surface-hover: rgba(255, 255, 255, 0.06); + --border: rgba(255, 255, 255, 0.09); + --border-hover: rgba(255, 255, 255, 0.18); + --text: #e7ecf5; + --muted: #8a94a8; + --muted-2: #636e84; + --accent: #22d3ee; + --accent-2: #a855f7; + --online: #34d399; + --radius: 18px; + --radius-sm: 12px; + --shadow: 0 18px 50px -20px rgba(0, 0, 0, 0.7); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + position: relative; + overflow-x: hidden; +} + +/* ambient glow */ +.bg-glow { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(60% 50% at 12% -5%, rgba(34, 211, 238, 0.14), transparent 60%), + radial-gradient(55% 45% at 95% 0%, rgba(168, 85, 247, 0.14), transparent 60%), + radial-gradient(70% 60% at 50% 120%, rgba(52, 211, 153, 0.08), transparent 60%); +} + +.shell { + position: relative; + z-index: 1; + max-width: 1240px; + margin: 0 auto; + padding: clamp(20px, 4vw, 44px) clamp(16px, 4vw, 40px) 60px; +} + +/* ---------- header ---------- */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; + margin-bottom: 34px; +} + +.brand { + display: flex; + align-items: center; + gap: 16px; +} + +.logo-dot { + width: 46px; + height: 46px; + border-radius: 14px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + box-shadow: 0 10px 30px -8px rgba(34, 211, 238, 0.6); + position: relative; + flex: none; +} + +.logo-dot::after { + content: ""; + position: absolute; + inset: 10px; + border-radius: 8px; + background: rgba(7, 11, 20, 0.55); + backdrop-filter: blur(2px); +} + +.brand-text h1 { + margin: 0; + font-size: clamp(22px, 3.2vw, 30px); + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.05; +} + +.subtitle { + margin: 4px 0 0; + color: var(--muted); + font-size: 14px; +} + +.top-right { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.search { + display: flex; + align-items: center; + gap: 9px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + padding: 10px 16px; + min-width: 220px; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.search:focus-within { + border-color: var(--border-hover); + background: var(--surface-hover); +} + +.search svg { + color: var(--muted); + flex: none; +} + +.search input { + border: none; + background: transparent; + color: var(--text); + font: inherit; + font-size: 14px; + outline: none; + width: 100%; +} + +.search input::placeholder { + color: var(--muted-2); +} + +.clock { + text-align: right; + line-height: 1.15; +} + +#clock-time { + display: block; + font-size: 20px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; +} + +#clock-date { + display: block; + font-size: 12.5px; + color: var(--muted); +} + +/* ---------- sections ---------- */ +main#app { + display: grid; + gap: 30px; +} + +.section-title { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 16px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--muted); +} + +.section-title::after { + content: ""; + height: 1px; + flex: 1; + background: linear-gradient(90deg, var(--border), transparent); +} + +.section-title .count { + font-variant-numeric: tabular-nums; + color: var(--muted-2); + letter-spacing: 0; +} + +/* ---------- grid ---------- */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +/* ---------- card ---------- */ +.card { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px 18px 16px; + display: flex; + flex-direction: column; + gap: 14px; + overflow: hidden; + transition: + transform 0.18s ease, + border-color 0.2s ease, + background 0.2s ease, + box-shadow 0.2s ease; + opacity: 0; + transform: translateY(10px); + animation: rise 0.5s ease forwards; +} + +@keyframes rise { + to { + opacity: 1; + transform: translateY(0); + } +} + +.card::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 3px; + background: linear-gradient(90deg, var(--cat, var(--accent)), transparent 70%); + opacity: 0; + transition: opacity 0.2s ease; +} + +.card:hover { + transform: translateY(-4px); + border-color: var(--border-hover); + background: var(--surface-hover); + box-shadow: var(--shadow); +} + +.card:hover::before { + opacity: 1; +} + +.card.hidden { + display: none; +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.card-icon { + width: 46px; + height: 46px; + border-radius: var(--radius-sm); + display: grid; + place-items: center; + font-size: 22px; + background: color-mix(in srgb, var(--cat, var(--accent)) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--cat, var(--accent)) 34%, transparent); + flex: none; +} + +.status { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 500; + color: var(--muted); + padding: 5px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + white-space: nowrap; +} + +.status .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--online); + box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.18); + animation: pulse 2.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.18); + } + 50% { + box-shadow: 0 0 0 5px rgba(52, 211, 153, 0.05); + } +} + +.card-body { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.card-name { + font-size: 16.5px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.card-cat { + align-self: flex-start; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: color-mix(in srgb, var(--cat, var(--accent)) 80%, white); +} + +.card-url { + font-size: 13px; + color: var(--muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-actions { + display: flex; + gap: 8px; + margin-top: 2px; +} + +.btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + font: inherit; + font-size: 13.5px; + font-weight: 600; + padding: 10px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--text); + cursor: pointer; + text-decoration: none; + transition: + background 0.18s ease, + border-color 0.18s ease, + transform 0.12s ease; +} + +.btn:hover { + background: var(--surface-hover); + border-color: var(--border-hover); +} + +.btn:active { + transform: scale(0.98); +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + border-color: transparent; + color: #05070d; +} + +.btn-primary:hover { + background: linear-gradient(135deg, #38dbf2, #b56bff); +} + +.btn-ghost { + flex: 0 0 auto; + width: 42px; + padding: 10px; +} + +.btn svg { + flex: none; +} + +/* ---------- empty / footer ---------- */ +.no-results { + color: var(--muted); + padding: 20px 4px; + font-size: 14px; +} + +.footer { + margin-top: 40px; + text-align: center; + color: var(--muted-2); + font-size: 13px; +} + +/* ---------- responsive ---------- */ +@media (max-width: 640px) { + .topbar { + flex-direction: column; + align-items: stretch; + } + .top-right { + justify-content: space-between; + } + .search { + min-width: 0; + flex: 1; + } + .clock { + text-align: left; + } + #clock-time { + font-size: 18px; + } +} + +@media (prefers-reduced-motion: reduce) { + .card { + animation: none; + opacity: 1; + transform: none; + } + .status .dot { + animation: none; + } + .card:hover { + transform: none; + } +}