CI / build (push) Successful in 40s
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>
368 lines
12 KiB
JavaScript
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();
|
|
})();
|