Files
TMCandClaude Opus 5.5 60b4c79b0f
CI / build (push) Successful in 40s
Show internal URLs, list label problems, copy a label template
Tiles now show the public URL and, when set, the internal URL as a second
link. Label names ignore case and - versus _. Containers HODA can't fully
use are listed above the tiles with the reason: labels without enable,
a missing url, or an unknown label name with a suggestion (category ->
group). To find labels without enable, discovery reads all containers.

A "Copy labels" button copies every label as a commented template, and
falls back to the copy command where the clipboard API is missing (plain
http on the LAN).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:55:06 +01:00

368 lines
12 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. Label problems on
// containers are listed above the tiles so they can be fixed.
(() => {
"use strict";
const POLL_MS = 10_000;
const STATE_LABELS = { running: "Running", unhealthy: "Unhealthy", stopped: "Stopped" };
const SVG = "http://www.w3.org/2000/svg";
const GLYPHS = {
remote: "M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm0 0C6.2 3.3 5.3 5.5 5.3 8s.9 4.7 2.7 6.5m0-13c1.8 1.8 2.7 4 2.7 6.5s-.9 4.7-2.7 6.5M1.8 6h12.4M1.8 10h12.4",
internal: "M2 7.5 8 2.5l6 5M3.5 6.5v7h9v-7M6.5 13.5V10h3v3.5",
};
const groupsEl = document.getElementById("groups");
const bannersEl = document.getElementById("banners");
const issuesEl = document.getElementById("issues");
const searchEl = document.getElementById("search");
const copyLabelsEl = document.getElementById("copy-labels");
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;
let issuesOpen = 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();
renderIssues();
if (!data) return;
const focusedId = document.activeElement?.closest?.("[data-id]")?.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)}"] a.name`)?.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);
}
// Containers whose labels HODA couldn't use, and why: hidden apps, unknown
// label names, values in the wrong shape.
function renderIssues() {
const issues = data?.issues ?? [];
if (issues.length === 0) {
issuesEl.replaceChildren();
return;
}
const manyHosts = (data.hosts ?? []).length > 1;
const details = el("details", "issues");
details.open = issuesOpen;
details.addEventListener("toggle", () => {
issuesOpen = details.open;
});
const containers = new Set(issues.map((i) => `${i.host}/${i.container}`)).size;
const summary = el("summary", "", `${issues.length} label ${plural(issues.length, "problem")} on ${containers} ${plural(containers, "container")}`);
const list = el("ul", "issue-list");
for (const issue of issues) {
const item = el("li");
item.append(el("code", "issue-container", issue.container));
if (manyHosts) item.append(el("span", "issue-host", ` on ${issue.host}`));
item.append(el("span", "issue-message", issue.message));
list.append(item);
}
details.append(summary, list);
issuesEl.replaceChildren(details);
}
function matches(app, terms) {
if (terms.length === 0) return true;
const text = [app.name, app.description, app.group, app.host, app.url, app.internalUrl].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;
}
// A tile is one big link to the app's URL. The internal URL, when there is
// one, is a second link on top of it.
function tile(app) {
const state = STATE_LABELS[app.state] ? app.state : "stopped";
const card = el("article", `tile state-${state}`);
card.dataset.id = app.id;
card.title = `${app.name} on ${app.host}`;
const name = el("a", "name", app.name);
name.href = app.url;
setTarget(name);
const text = el("div", "text");
text.append(name);
if (app.description) text.append(el("span", "desc", app.description));
const urls = el("div", "urls");
urls.append(chip("span", "remote", app.url));
if (!app.internalUrl || app.internalUrl === app.url) urls.classList.add("remote-only");
if (app.internalUrl && app.internalUrl !== app.url) {
const internal = chip("a", "internal", app.internalUrl);
internal.href = app.internalUrl;
internal.title = `Open ${app.name} on the local network`;
setTarget(internal);
urls.append(internal);
}
text.append(urls);
const dot = el("span", "dot");
dot.title = STATE_LABELS[state];
card.append(icon(app), text, dot, el("span", "visually-hidden", `, ${STATE_LABELS[state]}`));
return card;
}
function chip(tag, kind, href) {
const node = el(tag, `chip chip-${kind}`);
node.append(glyph(kind), el("span", "chip-text", shortURL(href)));
return node;
}
// "https://git.mariosem.es/" reads as "git.mariosem.es"; a path is kept.
function shortURL(href) {
try {
const u = new URL(href);
const path = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
return u.host + path;
} catch {
return href;
}
}
function glyph(name) {
const svg = document.createElementNS(SVG, "svg");
svg.setAttribute("viewBox", "0 0 16 16");
svg.setAttribute("aria-hidden", "true");
svg.classList.add("glyph");
const path = document.createElementNS(SVG, "path");
path.setAttribute("d", GLYPHS[name]);
svg.append(path);
return svg;
}
function setTarget(a) {
if (linkTarget === "_blank") {
a.target = "_blank";
a.rel = "noopener noreferrer";
}
}
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;
}
// Every label with an example value, indented to paste under a service.
function labelTemplate() {
const p = labelPrefix;
const host = location.hostname;
const isIP = /^[\d.]+$/.test(host) || host === "localhost";
const domain = !isIP && host.includes(".") ? host.split(".").slice(-2).join(".") : "example.com";
const lan = isIP && host !== "localhost" && !host.startsWith("127.") ? host : "192.168.1.10";
const rows = [
[`${p}.enable: "true"`, "required: puts the service on the dashboard"],
[`${p}.url: https://myapp.${domain}`, "required: where the tile links"],
[`${p}.internal_url: http://${lan}:8080`, "LAN address, shown as a second link"],
[`${p}.name: My App`, "defaults to the service name"],
[`${p}.description: What it does`, "one line under the name"],
[`${p}.group: Media`, "section heading; apps without one go last"],
[`${p}.order: "10"`, "lower comes first within the group"],
[`${p}.icon: jellyfin`, "dashboard-icons name or an image URL"],
];
const width = Math.max(...rows.map(([label]) => label.length)) + 2;
return [
" labels:",
...rows.map(([label, note]) => ` ${label.padEnd(width)}# ${note}`),
].join("\n");
}
// The clipboard API only exists on https; HODA is also opened over plain
// http on the LAN, where the older copy command still works.
async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const area = el("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.append(area);
area.select();
const ok = document.execCommand("copy");
area.remove();
if (!ok) throw new Error("copy failed");
}
function flash(button, text) {
const label = button.querySelector(".button-text") || button;
label.dataset.idle ??= label.textContent;
label.textContent = text;
clearTimeout(Number(button.dataset.timer));
button.dataset.timer = String(setTimeout(() => {
label.textContent = label.dataset.idle;
}, 1600));
}
async function copyWithFeedback(button, text) {
try {
await copyText(text);
flash(button, "Copied");
} catch {
flash(button, "Copy failed");
}
}
function emptyState() {
const snippet = labelTemplate();
const copy = el("button", "button", "Copy");
copy.type = "button";
copy.addEventListener("click", () => copyWithFeedback(copy, snippet));
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 plural(n, word) {
return n === 1 ? word : `${word}s`;
}
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 openLink(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));
}
copyLabelsEl.addEventListener("click", () => copyWithFeedback(copyLabelsEl, labelTemplate()));
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.name");
if (first) {
event.preventDefault();
openLink(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();
})();