diff --git a/shared/homepage/admin.html b/shared/homepage/admin.html
new file mode 100644
index 0000000..7ff7f00
--- /dev/null
+++ b/shared/homepage/admin.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+frank — admin
+
+
+
+
+ frank@home:~$ admin
+ bare
+
+
+
+
+
+
+
+
diff --git a/shared/homepage/app.js b/shared/homepage/app.js
index c2167f4..80805b5 100644
--- a/shared/homepage/app.js
+++ b/shared/homepage/app.js
@@ -1,79 +1,76 @@
-// Dankclaw homepage status checker.
-// Loads services.json, renders cards, probes each service URL for up/down.
-(function () {
- "use strict";
+// The homepage front end. It reads status.json and services.json once on load
+// (no auto-refresh, Q41), renders one TUI panel per service for this page's
+// category, offers the styling toggle (Q27), and shows the last master
+// deployment datetime (Q43). It never alters the user's cursor (Q28).
- function probe(url, timeoutMs) {
- // Same-origin fetch only; cross-origin will throw -> counted as checking
- // via proxy. For LAN hostnames Caddy proxies everything, so URLs here are
- // Caddy hostnames and fetch works same-origin.
- var controller = new AbortController();
- var timer = setTimeout(function () { controller.abort(); }, timeoutMs);
- return fetch(url, { signal: controller.signal, cache: "no-store" })
- .then(function (r) { return r.ok; })
- .catch(function () { return false; })
- .finally(function () { clearTimeout(timer); });
- }
+const CATEGORY = window.location.pathname.indexOf('/admin') !== -1 ? 'admin' : 'user';
- function card(svc) {
- var el = document.createElement("article");
- el.className = "card";
+function load(url) {
+ return fetch(url, { cache: 'no-store' }).then(function (r) { return r.json(); });
+}
- var top = document.createElement("div");
- top.className = "top";
- var badge = document.createElement("span");
- badge.className = "badge checking";
- badge.title = "checking";
- var h2 = document.createElement("h2");
- h2.textContent = svc.name;
- top.appendChild(badge);
- top.appendChild(h2);
-
- var desc = document.createElement("p");
- desc.textContent = svc.description;
-
- var links = document.createElement("div");
- links.className = "links";
- var aOpen = document.createElement("a");
- aOpen.href = svc.url;
- aOpen.target = "_blank";
- aOpen.rel = "noopener";
- aOpen.textContent = "Open";
- var aDocs = document.createElement("a");
- aDocs.href = svc.docs;
- aDocs.textContent = "Docs";
- links.appendChild(aOpen);
- links.appendChild(aDocs);
-
- el.appendChild(top);
- el.appendChild(desc);
- el.appendChild(links);
- return { el: el, badge: badge };
- }
-
- fetch("services.json")
- .then(function (r) { return r.json(); })
- .then(function (data) {
- var grid = document.getElementById("services");
- data.services.forEach(function (svc) {
- var c = card(svc);
- grid.appendChild(c.el);
- // For the homepage itself, mark up immediately.
- if (svc.url === "/") {
- c.badge.className = "badge up";
- c.badge.title = "up";
- return;
- }
- probe(svc.url, 6000).then(function (ok) {
- c.badge.className = "badge " + (ok ? "up" : "down");
- c.badge.title = ok ? "up" : "down";
- });
- });
- })
- .catch(function (err) {
- var grid = document.getElementById("services");
- var p = document.createElement("p");
- p.textContent = "Failed to load services.json: " + err.message;
- grid.appendChild(p);
+function esc(s) {
+ return String(s).replace(/[&<>"']/g, function (c) {
+ return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
-})();
+}
+
+function renderPanels(services, status) {
+ const stateByName = {};
+ for (const s of (status.services || [])) stateByName[s.name] = s.state;
+
+ const grid = document.getElementById('grid');
+ grid.innerHTML = '';
+ let up = 0, down = 0, wip = 0;
+
+ for (const svc of services) {
+ if (svc.category !== CATEGORY) continue;
+ const state = svc.wip ? 'wip' : (stateByName[svc.name] || 'down');
+ if (state === 'up') up++; else if (state === 'down') down++; else wip++;
+
+ const panel = document.createElement('div');
+ panel.className = 'panel ' + state;
+ const links = [];
+ if (svc.showLink && svc.url) links.push('open');
+ if (svc.docs) links.push('docs');
+ panel.innerHTML =
+ '' +
+ ' ' + state.toUpperCase() + '' +
+ ' ' + esc(svc.name) + '' +
+ '
' +
+ '' + esc(svc.description || '') + '
' +
+ '' + links.join('') + '
';
+ grid.appendChild(panel);
+ }
+
+ document.getElementById('summary').textContent = 'UP ' + up + ' DOWN ' + down + ' WIP ' + wip;
+}
+
+// The styling toggle (Q27): ?bare=1 strips the page to bare, unstyled HTML.
+function applyBare() {
+ const bare = new URLSearchParams(window.location.search).get('bare') === '1';
+ document.documentElement.setAttribute('data-bare', bare ? '1' : '0');
+ const t = document.getElementById('bare-toggle');
+ if (t) {
+ t.textContent = bare ? 'styled' : 'bare';
+ t.href = bare ? '?bare=0' : '?bare=1';
+ }
+}
+
+// The last master deployment datetime (Q43), baked in at deploy time.
+function fillDeployed() {
+ const el = document.getElementById('deployed-at');
+ if (el && window.DEPLOYED_AT) el.textContent = window.DEPLOYED_AT;
+}
+
+function main() {
+ applyBare();
+ fillDeployed();
+ Promise.all([load('services.json'), load('status.json')])
+ .then(function (pair) { renderPanels(pair[0], pair[1]); })
+ .catch(function (e) {
+ document.getElementById('summary').textContent = 'status unavailable: ' + e;
+ });
+}
+
+main();
diff --git a/shared/homepage/index.html b/shared/homepage/index.html
index 6755733..1cf55a6 100644
--- a/shared/homepage/index.html
+++ b/shared/homepage/index.html
@@ -3,20 +3,21 @@
-Dankclaw Services
+frank — services
-
-
-
-
-
+
+ frank@home:~$ services
+ bare
+
+
+
+
+