// --------------------------------------------------------------------------- // 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);