Files
dashboard/script.js
T
fegger e766fcd95c Add themed widgets, calendar, and Open WebUI chat to the dashboard
- Apply GTK-like color scheme (dark greys, #f76b2f accent) across
  cards, buttons, glows and category colors
- Service cards use the official logo icons; Netbird card shows live
  reachability + latency instead of a separate top widget
- Top widgets: Today (calendar events with in-progress highlighting),
  Git activity (3 rows visible, scrollable), GPU with live ROCm stats
  and loaded Ollama models incl. GPU/CPU split
- Open WebUI card: inline prompt line with model selection
- Calendar section grouping events by day below the services
- data.json fetch with boot retry; GPU/Ollama poll every 5 s
- Today entries wrap long titles; README documents widgets,
  environment variables and the .env secrets pattern
2026-09-04 14:07:22 +02:00

617 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------------------------------------------------------------------------
// 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
// '<img class="card-logo" src="icons/jellyfin.svg" alt="" />'
// ---------------------------------------------------------------------------
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: '<img class="card-logo" src="icons/openwebui.svg" alt="" />', chat: true },
{ section: "internal", name: "ComfyUI", url: "http://100.103.83.12:8288", cat: "ai", icon: '<img class="card-logo" src="icons/comfyui.svg" alt="" />' },
{ section: "internal", name: "Gitea", url: "http://100.103.83.12:3003", cat: "dev", icon: '<img class="card-logo" src="icons/gitea.svg" alt="" />' },
{ section: "internal", name: "Jellyfin", url: "http://100.103.83.12:8096", cat: "media", icon: '<img class="card-logo" src="icons/jellyfin.svg" alt="" />' },
{ section: "internal", name: "Immich", url: "http://100.103.83.12:2283", cat: "photos", icon: '<img class="card-logo" src="icons/immich.svg" alt="" />' },
{ section: "internal", name: "Home Assistant", url: "http://192.168.178.45:8123", cat: "smartHome", icon: '<img class="card-logo" src="icons/homeassistant.svg" alt="" />' },
{ section: "internal", name: "Audio Server / Dubplate", url: "http://192.168.178.100:8180", cat: "audio", icon: '<img class="card-logo" src="icons/audio.svg" alt="" />' },
{ section: "internal", name: "Nextcloud", url: "https://cloud.gg36", cat: "storage", icon: '<img class="card-logo" src="icons/nextcloud.svg" alt="" />' },
{ section: "internal", name: "Vaultwarden", url: "https://vault.gg36.at", cat: "security", icon: '<img class="card-logo" src="icons/vaultwarden.svg" alt="" />' },
{ 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: '<img class="card-logo" src="icons/netbird.svg" alt="" />', liveStatus: "netbird" },
// ----- External -----
{ section: "external", name: "gitlab-ixsol", url: "https://gitlab.ixsol.wien", cat: "external", icon: '<img class="card-logo" src="icons/gitlab.svg" alt="" />' },
{ section: "external", name: "gem360", url: "https://gem360.ixslo.wien", cat: "external", icon: '<img class="card-logo" src="icons/odoo.svg" alt="" />' },
];
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 = `<p class="widget-error">${msg}</p>`;
document.getElementById("git-stats").innerHTML = `<p class="widget-error">${msg}</p>`;
const nbEl = document.querySelector('[data-live-status="netbird"]');
if (nbEl) nbEl.innerHTML = `<span class="widget-error">${msg}</span>`;
const calendarEl = document.getElementById("calendar");
calendarEl.innerHTML = `<p class="calendar-error">Data unavailable: ${msg}</p>`;
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 = `<option value="" disabled selected>No models — set OPENWEBUI_TOKEN</option>`;
});
}
}
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 = `<p class="calendar-empty">No upcoming events.</p>`;
countEl.textContent = "0";
return;
}
const grouped = groupEventsByDay(upcoming);
calendarEl.innerHTML = Object.entries(grouped)
.map(([day, events]) => {
return `
<div class="calendar-day">
<div class="calendar-day-header">${escapeHtml(day)}</div>
<div class="calendar-events">
${events.map((e) => renderEvent(e, calMap[e.calendarId])).join("")}
</div>
</div>`;
})
.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 ? `<div class="event-location">📍 ${escapeHtml(e.location)}</div>` : "";
const description = e.description
? `<div class="event-description">${escapeHtml(e.description.split("\n")[0])}</div>`
: "";
return `
<article class="calendar-event" style="--cal:${calColor}">
<div class="event-dot" aria-hidden="true"></div>
<div class="event-body">
<div class="event-time">${escapeHtml(timeText)}</div>
<div class="event-title">${escapeHtml(e.summary)}</div>
${location}
${description}
</div>
</article>`;
}
// ---------------------------------------------------------------------------
// 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 = `<p class="widget-empty">No events today.</p>`;
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 `
<div class="today-event ${state}" style="--cal:${color}">
<span class="today-dot" aria-hidden="true"></span>
<span class="today-time">${escapeHtml(time)}</span>
<span class="today-summary">${escapeHtml(e.summary)}</span>
</div>`;
})
.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 = `<span class="widget-error">Status unavailable</span>`;
return;
}
const online = status.status === "online";
const dotClass = online ? "online" : "offline";
const text = online ? "Reachable" : status.error || "Unreachable";
const latency = status.latencyMs ? `<span class="netbird-latency"> · ${status.latencyMs} ms</span>` : "";
el.innerHTML = `
<span class="netbird-dot ${dotClass}" aria-hidden="true"></span>
<span class="netbird-text">${escapeHtml(text)}${latency}</span>`;
}
function renderGitWidget(git) {
const el = document.getElementById("git-stats");
if (!git) {
el.innerHTML = `<p class="widget-empty">Git stats unavailable.</p>`;
return;
}
const gitea = git.gitea || { repos: 0, recentCommits: [], error: null };
const gitlab = git.gitlab || { projects: 0, recentCommits: [], error: null };
const giteaError = gitea.error ? `<div class="git-error">Gitea: ${escapeHtml(gitea.error)}</div>` : "";
const gitlabError = gitlab.error ? `<div class="git-error">GitLab: ${escapeHtml(gitlab.error)}</div>` : "";
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 = `<div class="widget-empty">No recent commits visible.</div>`;
} else {
// The list is capped to ~3 visible rows via CSS (max-height) and scrolls.
commitsHtml = `<ul class="git-commits">
${allCommits
.map((c) => {
const repo = escapeHtml(c.repo).split("/").pop();
const msg = escapeHtml(c.message);
const author = escapeHtml(c.author);
const when = c.date
? new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(new Date(c.date))
: "";
return `<li class="git-commit" title="${escapeHtml(c.repo)}">
<span class="git-repo">${repo}</span>
<span class="git-msg">${msg}</span>
<span class="git-meta">${author}${when ? ` · ${when}` : ""}</span>
</li>`;
})
.join("")}
</ul>`;
}
el.innerHTML = `
<div class="git-summary">
<span class="git-badge gitea">Gitea ${gitea.repos || 0}</span>
<span class="git-badge gitlab">GitLab ${gitlab.projects || 0}</span>
<span class="git-badge total">Commits ${gitea.recentCommits.length + gitlab.recentCommits.length}</span>
</div>
${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 = `<p class="widget-error">${escapeHtml(g?.error || "GPU stats unavailable")}</p>`;
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 = `
<div class="gpu-name" title="${escapeHtml(g.name || "GPU")}">${escapeHtml(g.name || "GPU")}</div>
<div class="gpu-row">
<span class="gpu-label">Utilization</span>
<span class="gpu-val">${util}%</span>
</div>
<div class="gpu-bar"><div class="gpu-bar-fill util" style="width:${util}%"></div></div>
<div class="gpu-row">
<span class="gpu-label">VRAM</span>
<span class="gpu-val">${gb(g.memory_used || 0)} / ${gb(g.memory_total || 0)} GB</span>
</div>
<div class="gpu-bar"><div class="gpu-bar-fill mem" style="width:${memPct}%"></div></div>
<div class="gpu-meta">
${temp != null ? `<span class="gpu-temp ${tempClass}">${temp}°C</span>` : ""}
${g.power_w != null ? `<span>${Math.round(g.power_w)} W</span>` : ""}
</div>`;
}
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 =
`<p class="widget-error">${escapeHtml(err.message)}</p>`;
}
}
// ---------------------------------------------------------------------------
// 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 = `<div class="widget-empty">No model loaded</div>`;
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 `
<div class="ollama-model">
<div class="ollama-model-head">
<span class="ollama-name" title="${escapeHtml(m.name || "")}">${escapeHtml(name)}</span>
<span class="ollama-size">${gib(size)}</span>
</div>
<div class="ollama-split">
<span class="ollama-gpu">GPU ${gpuPct}%</span> · <span class="ollama-cpu">CPU ${cpuPct}%</span>
</div>
</div>`;
})
.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 =
`<p class="widget-error">${escapeHtml(err.message)}</p>`;
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
const app = document.getElementById("app");
const searchInput = document.getElementById("search");
const statsEl = document.getElementById("stats");
function escapeHtml(str) {
return String(str)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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
? `<div class="card-chat" data-chat-url="${escapeHtml(s.url)}">
<div class="chat-row">
<select class="chat-model" aria-label="Model">
<option value="" disabled selected>Loading models…</option>
</select>
<input type="text" class="chat-prompt" placeholder="Ask something…" autocomplete="off" />
<button class="chat-send" type="button" title="Send prompt">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
</svg>
</button>
</div>
<div class="chat-response" aria-live="polite"></div>
</div>`
: "";
return `
<article class="card" data-search="${escapeHtml((s.name + " " + meta.label + " " + s.url).toLowerCase())}"
style="--cat:${meta.color}; animation-delay:${Math.min(idx * 35, 400)}ms">
<div class="card-head">
<div class="card-icon" aria-hidden="true">${s.icon || "🔗"}</div>
<span class="status"><span class="dot"></span>Online</span>
</div>
<div class="card-body">
<span class="card-cat">${escapeHtml(meta.label)}</span>
<h3 class="card-name">${escapeHtml(s.name)}</h3>
<span class="card-url" title="${escapeHtml(s.url)}">${escapeHtml(urlHost)}</span>
</div>
${s.liveStatus ? `<div class="card-live-status" data-live-status="${escapeHtml(s.liveStatus)}"><span class="widget-loading">Checking status…</span></div>` : ""}
<div class="card-actions">
<a class="btn btn-primary" href="${escapeHtml(s.url)}" target="_blank" rel="noopener noreferrer">
Open
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
d="M7 17 17 7M9 7h8v8"/>
</svg>
</a>
<button class="btn btn-ghost" type="button" data-copy="${escapeHtml(s.url)}" title="Copy URL"
aria-label="Copy URL for ${escapeHtml(s.name)}">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<path fill="none" stroke="currentColor" stroke-width="2" d="M5 15V5a2 2 0 0 1 2-2h10"/>
</svg>
</button>
</div>
${chatForm}
</article>`;
}
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 `
<section class="section">
<h2 class="section-title">${escapeHtml(meta.title)} <span class="count">${grouped[key].length}</span></h2>
<div class="grid">${cards}</div>
</section>`;
})
.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 = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>`;
setTimeout(() => (btn.innerHTML = original), 1300);
});
});
}
function populateOpenWebUIModels(models) {
document.querySelectorAll(".chat-model").forEach((select) => {
const current = select.value;
select.innerHTML = models
.map((m) => `<option value="${escapeHtml(m.id)}"${m.id === current ? " selected" : ""}>${escapeHtml(m.name)}</option>`)
.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();
}
});
});
}
// ---------------------------------------------------------------------------
// 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