CI / build (push) Successful in 30s
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>
226 lines
7.0 KiB
JavaScript
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();
|
|
})();
|