// --------------------------------------------------------------------------- // 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), or HTML such as // '' // --------------------------------------------------------------------------- const CATEGORIES = { ai: { label: "AI", color: "#c970b0" }, dev: { label: "Development", color: "#5c8ad8" }, media: { label: "Media", color: "#e0554a" }, photos: { label: "Photos", color: "#8ab06a" }, smartHome: { label: "Smart Home", color: "#f2a944" }, audio: { label: "Audio", color: "#f76b2f" }, storage: { label: "Storage", color: "#5c8ad8" }, security: { label: "Security", color: "#e0554a" }, infra: { label: "Infrastructure", color: "#8c8f94" }, network: { label: "Networking", color: "#f76b2f" }, external: { label: "External", color: "#c970b0" }, }; const SERVICES = [ // ----- Internal ----- { section: "internal", name: "Open WebUI", url: "http://100.103.83.12:3000", cat: "ai", icon: '', chat: true }, { 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: '', liveStatus: "netbird" }, // ----- 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 }, }; // --------------------------------------------------------------------------- // Data feed (calendar + widgets). One fetch serves both; retries shortly // after boot because the server-side updater may still be running. // --------------------------------------------------------------------------- let dataRetries = 0; async function loadData() { let data; try { const res = await fetch("data.json", { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); data = await res.json(); dataRetries = 0; } catch (err) { const msg = escapeHtml(err.message); document.getElementById("today-events").innerHTML = `

${msg}

`; document.getElementById("git-stats").innerHTML = `

${msg}

`; const nbEl = document.querySelector('[data-live-status="netbird"]'); if (nbEl) nbEl.innerHTML = `${msg}`; const calendarEl = document.getElementById("calendar"); calendarEl.innerHTML = `

Data unavailable: ${msg}

`; document.getElementById("calendar-count").textContent = "0"; if (dataRetries < 3) { dataRetries++; setTimeout(loadData, 30000); } return; } renderWidgets(data); renderCalendar(data); } function renderWidgets(data) { renderTodayWidget(data.calendar?.today || [], data.calendars || []); renderNetbirdWidget(data.netbird); renderGitWidget(data.git); if (data.openwebui?.models?.length) { populateOpenWebUIModels(data.openwebui.models); } else { document.querySelectorAll(".chat-model").forEach((select) => { select.innerHTML = ``; }); } } function renderCalendar(data) { const calendarEl = document.getElementById("calendar"); const countEl = document.getElementById("calendar-count"); const calMap = Object.fromEntries((data.calendars || []).map((c) => [c.id, c])); const upcoming = (data.calendar?.upcoming || []).slice(0, 60); if (upcoming.length === 0) { calendarEl.innerHTML = `

No upcoming events.

`; countEl.textContent = "0"; return; } const grouped = groupEventsByDay(upcoming); calendarEl.innerHTML = Object.entries(grouped) .map(([day, events]) => { return `
${escapeHtml(day)}
${events.map((e) => renderEvent(e, calMap[e.calendarId])).join("")}
`; }) .join(""); countEl.textContent = String(upcoming.length); } function groupEventsByDay(events) { const groups = {}; const fmt = new Intl.DateTimeFormat(undefined, { weekday: "short", day: "numeric", month: "short", }); for (const e of events) { const key = fmt.format(new Date(e.start)); (groups[key] ||= []).push(e); } return groups; } function renderEvent(e, cal) { const start = new Date(e.start); const end = e.end ? new Date(e.end) : null; const calColor = cal?.color || "var(--accent)"; let timeText; if (e.allDay) { timeText = "All day"; } else { const fmt = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }); if (end) { timeText = `${fmt.format(start)} – ${fmt.format(end)}`; } else { timeText = fmt.format(start); } } const location = e.location ? `
📍 ${escapeHtml(e.location)}
` : ""; const description = e.description ? `
${escapeHtml(e.description.split("\n")[0])}
` : ""; return `
${escapeHtml(timeText)}
${escapeHtml(e.summary)}
${location} ${description}
`; } // --------------------------------------------------------------------------- // Top widgets (today, netbird, git) // --------------------------------------------------------------------------- function renderTodayWidget(events, calendars) { const el = document.getElementById("today-events"); const calMap = Object.fromEntries(calendars.map((c) => [c.id, c])); if (events.length === 0) { el.innerHTML = `

No events today.

`; return; } el.innerHTML = events .sort((a, b) => new Date(a.start) - new Date(b.start)) .map((e) => { const cal = calMap[e.calendarId]; const color = cal?.color || "var(--accent)"; const time = e.allDay ? "All day" : new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(e.start)); const startT = new Date(e.start).getTime(); const endT = e.end ? new Date(e.end).getTime() : startT; const nowT = Date.now(); let state = ""; if (!e.allDay) { if (nowT > endT) state = "past"; else if (nowT >= startT) state = "now"; } return `
${escapeHtml(time)} ${escapeHtml(e.summary)}
`; }) .join(""); } function renderNetbirdWidget(status) { // Renders into the Netbird service card (data-live-status="netbird"). const el = document.querySelector('[data-live-status="netbird"]'); if (!el) return; if (!status) { el.innerHTML = `Status unavailable`; return; } const online = status.status === "online"; const dotClass = online ? "online" : "offline"; const text = online ? "Reachable" : status.error || "Unreachable"; const latency = status.latencyMs ? ` · ${status.latencyMs} ms` : ""; el.innerHTML = ` ${escapeHtml(text)}${latency}`; } function renderGitWidget(git) { const el = document.getElementById("git-stats"); if (!git) { el.innerHTML = `

Git stats unavailable.

`; return; } const gitea = git.gitea || { repos: 0, recentCommits: [], error: null }; const gitlab = git.gitlab || { projects: 0, recentCommits: [], error: null }; const giteaError = gitea.error ? `
Gitea: ${escapeHtml(gitea.error)}
` : ""; const gitlabError = gitlab.error ? `
GitLab: ${escapeHtml(gitlab.error)}
` : ""; const allCommits = [...gitea.recentCommits, ...gitlab.recentCommits] .sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0)) .slice(0, 8); let commitsHtml; if (allCommits.length === 0) { commitsHtml = `
No recent commits visible.
`; } else { // The list is capped to ~3 visible rows via CSS (max-height) and scrolls. commitsHtml = ``; } el.innerHTML = `
Gitea ${gitea.repos || 0} GitLab ${gitlab.projects || 0} Commits ${gitea.recentCommits.length + gitlab.recentCommits.length}
${giteaError} ${gitlabError} ${commitsHtml}`; } // --------------------------------------------------------------------------- // GPU widget (live rocm-smi stats via /api/gpu-stats) // --------------------------------------------------------------------------- function renderGpuWidget(g) { const el = document.getElementById("gpu-stats"); if (!g || g.error) { el.innerHTML = `

${escapeHtml(g?.error || "GPU stats unavailable")}

`; return; } const util = Math.max(0, Math.min(100, g.utilization ?? 0)); const memPct = g.memory_total ? Math.max(0, Math.min(100, Math.round((g.memory_used / g.memory_total) * 100))) : 0; const gb = (mb) => (mb / 1024).toFixed(1); const temp = g.temperature ?? null; let tempClass = ""; if (temp != null) { if (temp >= 80) tempClass = "hot"; else if (temp >= 65) tempClass = "warm"; } el.innerHTML = `
${escapeHtml(g.name || "GPU")}
Utilization ${util}%
VRAM ${gb(g.memory_used || 0)} / ${gb(g.memory_total || 0)} GB
${temp != null ? `${temp}°C` : ""} ${g.power_w != null ? `${Math.round(g.power_w)} W` : ""}
`; } async function loadGpuStats() { try { const res = await fetch("/api/gpu-stats", { cache: "no-store" }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); renderGpuWidget(data); } catch (err) { document.getElementById("gpu-stats").innerHTML = `

${escapeHtml(err.message)}

`; } } // --------------------------------------------------------------------------- // Ollama stats (loaded models + GPU/CPU split) via /api/ollama // --------------------------------------------------------------------------- function renderOllamaStats(data) { const el = document.getElementById("ollama-stats"); const models = data?.models || []; if (models.length === 0) { el.innerHTML = `
No model loaded
`; return; } const gib = (bytes) => `${(bytes / 1073741824).toFixed(1)} GiB`; el.innerHTML = models .map((m) => { const size = m.size || 0; const vram = m.size_vram ?? 0; const gpuPct = size > 0 ? Math.max(0, Math.min(100, Math.round((vram / size) * 100))) : 0; const cpuPct = 100 - gpuPct; const name = (m.name || m.model || "model").replace(":latest", ""); return `
${escapeHtml(name)} ${gib(size)}
GPU ${gpuPct}% · CPU ${cpuPct}%
`; }) .join(""); } async function loadOllamaStats() { try { const res = await fetch("/api/ollama", { cache: "no-store" }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); renderOllamaStats(data); } catch (err) { document.getElementById("ollama-stats").innerHTML = `

${escapeHtml(err.message)}

`; } } // --------------------------------------------------------------------------- // 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(/\/$/, ""); const chatForm = s.chat ? `
` : ""; return `
Checking…
${escapeHtml(meta.label)}

${escapeHtml(s.name)}

${escapeHtml(urlHost)}
${s.liveStatus ? `
Checking status…
` : ""}
Open
${chatForm}
`; } 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(); bindOpenWebUIChat(); } 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); }); }); } function populateOpenWebUIModels(models) { document.querySelectorAll(".chat-model").forEach((select) => { const current = select.value; select.innerHTML = models .map((m) => ``) .join(""); if (!select.value && models[0]) select.value = models[0].id; }); } function bindOpenWebUIChat() { document.querySelectorAll(".card-chat").forEach((container) => { const select = container.querySelector(".chat-model"); const input = container.querySelector(".chat-prompt"); const sendBtn = container.querySelector(".chat-send"); const responseEl = container.querySelector(".chat-response"); async function send() { const model = select?.value; const prompt = input?.value.trim(); if (!prompt) return; if (!model) { responseEl.textContent = "No model available — set OPENWEBUI_TOKEN on the container."; responseEl.classList.add("error"); return; } responseEl.textContent = "Thinking…"; responseEl.classList.remove("error"); try { const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, prompt }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); responseEl.textContent = data.message || "(no response)"; } catch (e) { responseEl.textContent = e.message; responseEl.classList.add("error"); } } sendBtn?.addEventListener("click", send); input?.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }); }); } // --------------------------------------------------------------------------- // Service health badges (server-side checks via health.json) // --------------------------------------------------------------------------- function applyHealth(health) { const services = health?.services || {}; document.querySelectorAll(".status[data-status-url]").forEach((badge) => { const url = badge.getAttribute("data-status-url"); const h = services[url]; const text = badge.querySelector(".status-text"); if (!text) return; if (!h) { badge.className = "status unknown"; text.textContent = "Unknown"; badge.title = "No health data yet"; return; } if (h.status === "online") { badge.className = "status online"; text.textContent = "Online"; badge.title = h.latencyMs != null ? `${h.latencyMs} ms` : ""; } else if (h.status === "error") { badge.className = "status error"; text.textContent = "Error"; badge.title = `HTTP ${h.code}`; } else { badge.className = "status offline"; text.textContent = "Offline"; badge.title = h.error || "Unreachable"; } }); } async function loadHealth() { try { const res = await fetch("health.json", { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); applyHealth(await res.json()); } catch { // keep the current badges; the next poll will retry } } // --------------------------------------------------------------------------- // 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); loadData(); setInterval(loadData, 5 * 60 * 1000); // refresh data every 5 min loadGpuStats(); setInterval(loadGpuStats, 5000); // live GPU stats every 5 s loadOllamaStats(); setInterval(loadOllamaStats, 5000); // live Ollama loaded models every 5 s loadHealth(); setInterval(loadHealth, 30000); // service health badges every 30 s