Files
HODA/internal/server/web/static/app.js
T
TMCandClaude Opus 5.5 9d6a921c34
CI / build (push) Successful in 30s
Start HODA: a homelab dashboard built from Docker labels
Apps opt in with sema.dashboard.* labels in their own compose file. The
primary reads its host's containers through a read-only socket proxy,
follows Docker events, and merges in what the agent on the second host
reports. Icons come from the icon label, the dashboard-icons set or the
app's favicon, with a letter as the fallback.

Go with only the standard library, a distroless image, and a Gitea
Actions workflow copied from FiMa's: checks in the Dockerfile's check
stage, a Trivy scan, and a push to git.mariosem.es/msemes/hoda.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:18:20 +01:00

226 lines
7.0 KiB
JavaScript

// HODA dashboard: loads /api/apps, shows the apps as tiles grouped by their
// group label, and keeps them current by polling with ETags. Search filters as
// you type; "/" focuses it and Enter opens the first match.
(() => {
"use strict";
const POLL_MS = 10_000;
const STATE_LABELS = { running: "Running", unhealthy: "Unhealthy", stopped: "Stopped" };
const groupsEl = document.getElementById("groups");
const bannersEl = document.getElementById("banners");
const searchEl = document.getElementById("search");
const linkTarget = document.body.dataset.linkTarget === "_self" ? "_self" : "_blank";
const labelPrefix = document.body.dataset.labelPrefix || "sema.dashboard";
let data = null;
let etag = "";
let inFlight = false;
let offline = false;
async function load() {
if (inFlight) return;
inFlight = true;
try {
const res = await fetch("/api/apps", {
cache: "no-store",
headers: etag ? { "If-None-Match": etag } : {},
});
if (res.status === 304) {
setOffline(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.json();
etag = res.headers.get("ETag") || "";
offline = false;
render();
} catch {
setOffline(true);
} finally {
inFlight = false;
groupsEl.removeAttribute("aria-busy");
}
}
function setOffline(value) {
if (offline === value) return;
offline = value;
renderBanners();
}
function render() {
renderBanners();
if (!data) return;
const focusedId = document.activeElement?.dataset?.id;
const terms = searchEl.value.toLowerCase().split(/\s+/).filter(Boolean);
const visible = data.apps.filter((app) => matches(app, terms));
const sections = [];
if (data.apps.length === 0) {
sections.push(emptyState());
} else if (visible.length === 0) {
sections.push(el("p", "note", `No apps match “${searchEl.value.trim()}”.`));
} else {
for (const [group, list] of groupBy(visible)) {
const section = el("section", "group");
const grid = el("div", "grid");
grid.append(...list.map(tile));
section.append(el("h2", "group-name", group), grid);
sections.push(section);
}
}
groupsEl.replaceChildren(...sections);
if (focusedId) groupsEl.querySelector(`[data-id="${CSS.escape(focusedId)}"]`)?.focus();
}
function renderBanners() {
const banners = [];
if (offline) banners.push(el("p", "banner", "Can't reach HODA right now. Retrying every 10 seconds."));
for (const host of data?.hosts ?? []) {
if (host.ok) continue;
const since = host.lastOk ? `, showing its apps from ${clock(host.lastOk)}` : "";
banners.push(el("p", "banner", `Can't reach ${host.name}${since}.`));
}
bannersEl.replaceChildren(...banners);
}
function matches(app, terms) {
if (terms.length === 0) return true;
const text = [app.name, app.description, app.group, app.host].join(" ").toLowerCase();
return terms.every((term) => text.includes(term));
}
// The server sends apps in display order, so grouping keeps that order.
function groupBy(apps) {
const groups = new Map();
for (const app of apps) {
if (!groups.has(app.group)) groups.set(app.group, []);
groups.get(app.group).push(app);
}
return groups;
}
function tile(app) {
const state = STATE_LABELS[app.state] ? app.state : "stopped";
const a = el("a", `tile state-${state}`);
a.href = app.url;
a.dataset.id = app.id;
a.title = `${app.name} on ${app.host}`;
if (linkTarget === "_blank") {
a.target = "_blank";
a.rel = "noopener noreferrer";
}
const text = el("span", "text");
text.append(el("span", "name", app.name));
if (app.description) text.append(el("span", "desc", app.description));
const dot = el("span", "dot");
dot.title = STATE_LABELS[state];
a.append(icon(app), text, dot, el("span", "visually-hidden", `, ${STATE_LABELS[state]}`));
return a;
}
function icon(app) {
if (!app.icon) return letter(app);
const img = el("img", "icon");
img.src = app.icon;
img.alt = "";
img.width = 40;
img.height = 40;
img.decoding = "async";
img.addEventListener("error", () => img.replaceWith(letter(app)), { once: true });
return img;
}
function letter(app) {
const first = (app.name.match(/[\p{L}\p{N}]/u) || ["?"])[0].toUpperCase();
const span = el("span", "icon letter", first);
span.style.setProperty("--hue", String(hue(app.name)));
span.setAttribute("aria-hidden", "true");
return span;
}
// A hue derived from the name, so a letter tile keeps its colour across reloads.
function hue(text) {
let h = 0;
for (const ch of text) h = (h * 31 + ch.codePointAt(0)) >>> 0;
return h % 360;
}
function emptyState() {
const snippet = [
"labels:",
` ${labelPrefix}.enable: "true"`,
` ${labelPrefix}.name: My app`,
` ${labelPrefix}.url: https://myapp.example.com`,
].join("\n");
const copy = el("button", "copy", "Copy");
copy.type = "button";
copy.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(snippet);
copy.textContent = "Copied";
} catch {
copy.textContent = "Copy failed";
}
});
const box = el("div", "empty");
box.append(
el("h2", "", "No apps yet"),
el("p", "", "Add these labels to a service in its docker-compose file, then run docker compose up -d."),
el("pre", "snippet", snippet),
copy,
);
return box;
}
function clock(iso) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function openTile(a) {
if (a.target === "_blank") window.open(a.href, "_blank", "noopener,noreferrer");
else window.location.assign(a.href);
}
function isTyping(target) {
return target instanceof HTMLElement && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
}
searchEl.addEventListener("input", render);
searchEl.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
searchEl.value = "";
render();
searchEl.blur();
} else if (event.key === "Enter") {
const first = groupsEl.querySelector("a.tile");
if (first) {
event.preventDefault();
openTile(first);
}
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "/" && !event.ctrlKey && !event.metaKey && !event.altKey && !isTyping(event.target)) {
event.preventDefault();
searchEl.focus();
searchEl.select();
}
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden) load();
});
setInterval(() => {
if (!document.hidden) load();
}, POLL_MS);
load();
})();