diff --git a/scripts/homepage-status.py b/scripts/homepage-status.py index 4b913f5..d58ac50 100755 --- a/scripts/homepage-status.py +++ b/scripts/homepage-status.py @@ -2,7 +2,8 @@ """homepage-status.py — the homepage status checker. Reads a service config (a JSON array of service entries), probes each backend, -and writes a status.json atomically. A wip service is not probed. +and writes a status.json atomically. A wip service is not probed; its state +is "wip-open" or "wip-closed" following the wip value in the config. Usage: homepage-status.py """ @@ -48,8 +49,9 @@ def main(argv): results = [] for svc in services: - if svc.get("wip"): - state = "wip" + wip = svc.get("wip") + if wip: + state = "wip-%s" % wip else: host, _, port = svc["probe"].rpartition(":") state = probe(host, int(port), svc.get("method", "tcp")) diff --git a/serverAppConfigs-docs/Infrastructure/Homepage.md b/serverAppConfigs-docs/Infrastructure/Homepage.md index 9d3000f..fe61df4 100644 --- a/serverAppConfigs-docs/Infrastructure/Homepage.md +++ b/serverAppConfigs-docs/Infrastructure/Homepage.md @@ -17,6 +17,16 @@ remain a separate thread. The three LLM cards (`frank-large`, `frank-medium`, `frank-small`) moved from admin-only to the **user** page with live model descriptions and a `docs` link to `/docs#models`. +**Sections + WIP split (2026-09-21):** both pages now group their panels +under section headings rendered by shared `renderSection()`/`renderPanel()` +components in `app.js`. The user page orders **AI, Web Apps, Media Library +Apps**; the admin page orders **Media Library Apps, Observability, AI, +Infrastructure**. The WIP badge split into **WIP: OPEN** (work in progress) +and **WIP: CLOSED** (work finished by decision — e.g. a card that is +deliberately unprobeable) via the `wip` value (`"open"` / `"closed"`). +Tautulli (Plex statistics, `192.168.0.235:8181`) joined the admin page's +Media Library Apps section. + ## Design decision — 2026-08-22 The live up/down check runs server-side. A checker on Dankclaw probes each backend and writes a same-origin `status.json`. The page fetches only that file and renders the badges. The browser never calls a backend directly. The checker runs as a systemd timer every 3 minutes. @@ -57,15 +67,20 @@ Each service shows one of three states. stateDiagram-v2 [*] --> Up [*] --> Down - [*] --> WIP + [*] --> WipOpen: wip = "open" + [*] --> WipClosed: wip = "closed" Up --> Down: probe fails Down --> Up: probe succeeds - WIP --> Up: work completes + WipOpen --> Up: work completes + WipClosed --> Up: card becomes probeable ``` - **Up** — the probe reached the backend. - **Down** — the probe failed (connection refused or timeout). -- **WIP** — a static override. The service is intentionally not finished. The checker does not probe it. +- **WIP: Open** — work is in progress. The checker does not probe it. +- **WIP: Closed** — the work is closed by decision (for example a card that + is deliberately unprobeable, with the reason in its description). The + checker does not probe it. ## Checker @@ -86,13 +101,16 @@ flowchart TD ## Service classification -**User services** (root page): SearXNG, OpenWebUI, Gitea, Calibre-Web, RomM, Copyparty, Plex, Jellyfin, and the three LLM fleet cards (frank-large, frank-medium, frank-small — status + docs link, no Open link). +**User services** (root page), in section order: +- **AI** — OpenWebUI, and the three LLM fleet cards (frank-large, frank-medium, frank-small — status + docs link, no Open link). +- **Web Apps** — SearXNG, Gitea, Copyparty, Marreta. +- **Media Library Apps** — Calibre-Web, RomM, Plex, Jellyfin, Seerr. -**Admin services** (`/admin/`): Hermes, Grafana, Prometheus, Loki, Caddy, Pi-hole, Gluetun, and the low-level backends (mariadb, redis, Postgres, node-exporter, alloy). The backends show status only. They get a link only where a UI is meaningful. - -**Excluded from the listing:** the *arr stack + qBittorrent (Prowlarr, Sonarr, -Radarr, Lidarr, Readarr, qB) — live on FrankBook-Pro but operator-only: no -vhosts, direct IP:port access only. See [[FrankBook-Pro]]. +**Admin services** (`/admin/`), in section order: +- **Media Library Apps** — the *arr stack (Prowlarr, Sonarr, Radarr, Lidarr, Readarr), qBittorrent, and Tautulli — live on FrankBook-Pro and Dankserver, operator-only: no vhosts (Tautulli included), direct IP:port access only. See [[FrankBook-Pro]]. +- **Observability** — Grafana, Prometheus, Loki, node-exporter, alloy, alertmanager. +- **AI** — Hermes, ComfyUI. +- **Infrastructure** — Caddy, dnsmasq, Gluetun, and the low-level backends (mariadb, valkey, Postgres). The backends show status only. They get a link only where a UI is meaningful. ## Layout @@ -111,7 +129,12 @@ shared/homepage/ ## Rendering -The page renders a **grid of TUI-style panels** — one panel per service. Each panel shows the state badge, the name, the description, and the `Open` and `Docs` links. A summary line at the top counts the states. The footer shows the last master deployment datetime (Q43). +The page renders **sections of TUI-style panels** — one panel per service, +grouped under a section heading. Each panel shows the state badge, the name, +the description, and the `Open` and `Docs` links. Section and panel are +shared components (`renderSection()` / `renderPanel()` in `app.js`); each +page's section order is fixed by `SECTION_ORDER`. A summary line at the top +counts the states. The footer shows the last master deployment datetime (Q43). The page reads `status.json` **once on load**. It does not auto-refresh (Q41). @@ -175,6 +198,7 @@ agent knowledge base (`~/.pi/agent/docs/caddy/caddyfile.md`). { "name": "SearXNG", "category": "user", + "section": "Web Apps", "description": "Privacy-respecting metasearch engine.", "url": "http://search.franksdank.cloud", "docs": "/docs/searxng/", @@ -186,11 +210,16 @@ agent knowledge base (`~/.pi/agent/docs/caddy/caddyfile.md`). ``` - `category` — `user` or `admin`. Selects the page. +- `section` — the heading the panel renders under. Must be one of the page's + `SECTION_ORDER` entries (user: AI, Web Apps, Media Library Apps; admin: + Media Library Apps, Observability, AI, Infrastructure), or the panel is + silently dropped from the page. - `url` — the "Open" link (the Caddy site). - `docs` — the per-service docs page (`/docs//`). - `probe` — the backend `host:port` the checker probes. - `method` — `tcp` or `http`. The probe type for this service (Q10). -- `wip` — when true, the badge shows WIP and the checker skips the probe. +- `wip` — `false`, or `"open"` / `"closed"`. When set, the badge shows + **WIP: OPEN** or **WIP: CLOSED** and the checker skips the probe. **A card must be probeable from the monitor's host, or it must be `wip`.** The checker runs on Dankclaw, so its reach is: that host's own loopback, its own diff --git a/shared/homepage/app.js b/shared/homepage/app.js index 80805b5..90dca98 100644 --- a/shared/homepage/app.js +++ b/shared/homepage/app.js @@ -1,10 +1,26 @@ // 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). +// (no auto-refresh, Q41), renders the services for this page's category as +// sectioned grids of TUI panels, offers the styling toggle (Q27), and shows +// the last master deployment datetime (Q43). It never alters the user's +// cursor (Q28). const CATEGORY = window.location.pathname.indexOf('/admin') !== -1 ? 'admin' : 'user'; +// Sections and panels are shared components: both pages render through the +// same renderSection()/renderPanel() below. SECTION_ORDER fixes each page's +// section order; a section with no services renders nothing. +const SECTION_ORDER = { + user: ['AI', 'Web Apps', 'Media Library Apps'], + admin: ['Media Library Apps', 'Observability', 'AI', 'Infrastructure'], +}; + +const BADGE_LABEL = { + up: 'UP', + down: 'DOWN', + 'wip-open': 'WIP: OPEN', + 'wip-closed': 'WIP: CLOSED', +}; + function load(url) { return fetch(url, { cache: 'no-store' }).then(function (r) { return r.json(); }); } @@ -15,32 +31,56 @@ function esc(s) { }); } +// Shared panel component: one TUI panel for a service in a given state. +function renderPanel(svc, state) { + 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 = + '
' + + ' ' + BADGE_LABEL[state] + '' + + ' ' + esc(svc.name) + '' + + '
' + + '
' + esc(svc.description || '') + '
' + + ''; + return panel; +} + +// Shared section component: a heading over a grid of panels. +function renderSection(title, panels) { + const section = document.createElement('section'); + section.className = 'section'; + const heading = document.createElement('h2'); + heading.className = 'section-title'; + heading.textContent = title; + const grid = document.createElement('div'); + grid.className = 'grid'; + for (const panel of panels) grid.appendChild(panel); + section.appendChild(heading); + section.appendChild(grid); + return section; +} + function renderPanels(services, status) { const stateByName = {}; for (const s of (status.services || [])) stateByName[s.name] = s.state; - const grid = document.getElementById('grid'); - grid.innerHTML = ''; + const root = document.getElementById('grid'); + root.innerHTML = ''; let up = 0, down = 0, wip = 0; + const bySection = {}; for (const svc of services) { if (svc.category !== CATEGORY) continue; - const state = svc.wip ? 'wip' : (stateByName[svc.name] || 'down'); + const state = svc.wip ? 'wip-' + svc.wip : (stateByName[svc.name] || 'down'); if (state === 'up') up++; else if (state === 'down') down++; else wip++; + (bySection[svc.section] = bySection[svc.section] || []).push(renderPanel(svc, state)); + } - 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 || '') + '
' + - ''; - grid.appendChild(panel); + for (const title of SECTION_ORDER[CATEGORY]) { + if (bySection[title]) root.appendChild(renderSection(title, bySection[title])); } document.getElementById('summary').textContent = 'UP ' + up + ' DOWN ' + down + ' WIP ' + wip; diff --git a/shared/homepage/docs.html b/shared/homepage/docs.html index b4f6359..812b0fa 100644 --- a/shared/homepage/docs.html +++ b/shared/homepage/docs.html @@ -47,9 +47,9 @@

frank-large — the big brain

    -
  • Model: Qwen3.8-Flash CIRU-STRIX-Orca, served by CIRU v4.4.1 llama.cpp with the model's own MTP draft head.
  • +
  • Model: Qwen3.8-Flash-Next UD-IQ4_XS, served by unsloth llama.cpp with the model's own MTP draft head (depth 2).
  • Hardware: Dankclaw's Strix Halo iGPU (gfx1151), shared with the whole host.
  • -
  • Best for: long, thoughtful text conversations at the model's full trained window.
  • +
  • Best for: long, thoughtful text conversations at a 200k-token window.
  • Capacity: exactly one conversation resident at a time. A second waits — politely, but it waits.
  • Not for: images. It cannot see them.
@@ -64,7 +64,7 @@

frank-medium — the 27B generalist

    -
  • Model: Qwen3.8-27B-Uncensored at MXFP4, on vllm-radiance with a DFlash2 draft head.
  • +
  • Model: Qwen3.8-27B-Uncensored at FP8, on vllm-radiance with a DFlash2 draft head.
  • Hardware: Dankserver's two Radeon R9700s as ONE tensor-parallel engine (TP=2).
  • Best for: general chat and reasoning when the big brain is busy; the biggest pure-language model we run, and the fastest we have measured.
  • Capacity: 262k-token context shared by up to eight conversations at once. It can see pictures too; frank-small is the faster eye and holds four conversations.
  • diff --git a/shared/homepage/services.json b/shared/homepage/services.json index a136ec4..e08941e 100644 --- a/shared/homepage/services.json +++ b/shared/homepage/services.json @@ -2,6 +2,7 @@ { "name": "SearXNG", "category": "user", + "section": "Web Apps", "description": "AI-free, VPN-routed search engine, fully hosted on Frank's server.", "url": "http://search.franksdank.cloud", "docs": "", @@ -13,6 +14,7 @@ { "name": "OpenWebUI", "category": "user", + "section": "AI", "description": "AI Chat/Search interface", "url": "http://ai.franksdank.cloud", "docs": "", @@ -24,6 +26,7 @@ { "name": "Gitea", "category": "user", + "section": "Web Apps", "description": "Git code hosting and review platform.", "url": "http://gitea.franksdank.cloud", "docs": "", @@ -35,6 +38,7 @@ { "name": "Calibre-Web", "category": "user", + "section": "Media Library Apps", "description": "E-book library.", "url": "http://books.franksdank.cloud", "docs": "", @@ -46,6 +50,7 @@ { "name": "RomM", "category": "user", + "section": "Media Library Apps", "description": "ROM and emulator library.", "url": "http://romm.franksdank.cloud", "docs": "", @@ -57,6 +62,7 @@ { "name": "Copyparty", "category": "user", + "section": "Web Apps", "description": "Direct downloads from Frank's server.", "url": "http://files.franksdank.cloud", "docs": "", @@ -68,6 +74,7 @@ { "name": "Plex", "category": "user", + "section": "Media Library Apps", "description": "Closed source media server with apps for everything.", "url": "http://plex.franksdank.cloud/web/index.html", "docs": "", @@ -79,6 +86,7 @@ { "name": "Jellyfin", "category": "user", + "section": "Media Library Apps", "description": "Open source media server.", "url": "http://jelly.franksdank.cloud", "docs": "", @@ -90,6 +98,7 @@ { "name": "Seerr", "category": "user", + "section": "Media Library Apps", "description": "Download Requests", "url": "http://seerr.franksdank.cloud", "docs": "", @@ -101,6 +110,7 @@ { "name": "Marreta", "category": "user", + "section": "Web Apps", "description": "Clean, ad-free article reader. Paste any URL, get just the article.", "url": "http://marreta.franksdank.cloud", "docs": "", @@ -112,6 +122,7 @@ { "name": "Sonarr", "category": "admin", + "section": "Media Library Apps", "description": "TV series library and automation.", "url": "http://192.168.0.112:8989", "docs": "", @@ -123,6 +134,7 @@ { "name": "Hermes", "category": "admin", + "section": "AI", "description": "The agent gateway.", "url": "", "docs": "", @@ -134,6 +146,7 @@ { "name": "Grafana", "category": "admin", + "section": "Observability", "description": "Dashboards.", "url": "http://grafana.franksdank.cloud", "docs": "", @@ -145,6 +158,7 @@ { "name": "Prometheus", "category": "admin", + "section": "Observability", "description": "Metrics collection.", "url": "http://prometheus.franksdank.cloud", "docs": "", @@ -156,6 +170,7 @@ { "name": "Loki", "category": "admin", + "section": "Observability", "description": "Log storage.", "url": "http://loki.franksdank.cloud", "docs": "", @@ -167,6 +182,7 @@ { "name": "node-exporter", "category": "admin", + "section": "Observability", "description": "Host metrics.", "url": "", "docs": "", @@ -178,6 +194,7 @@ { "name": "alloy", "category": "admin", + "section": "Observability", "description": "Log shipping.", "url": "", "docs": "", @@ -189,6 +206,7 @@ { "name": "alertmanager", "category": "admin", + "section": "Observability", "description": "Alert delivery to Signal.", "url": "", "docs": "", @@ -200,6 +218,7 @@ { "name": "Caddy", "category": "admin", + "section": "Infrastructure", "description": "The reverse proxy.", "url": "", "docs": "", @@ -211,6 +230,7 @@ { "name": "dnsmasq", "category": "admin", + "section": "Infrastructure", "description": "LAN name resolution.", "url": "", "docs": "", @@ -222,18 +242,20 @@ { "name": "Gluetun", "category": "admin", + "section": "Infrastructure", "description": "The outbound VPN tunnel.", "url": "", "docs": "", "probe": "127.0.0.1:51820", "method": "tcp", - "wip": true, + "wip": "closed", "showLink": false }, { "name": "frank-large", "category": "user", - "description": "The big brain. Qwen3.8-Flash CIRU-STRIX-Orca on CIRU v4.4.1 llama.cpp, running on Dankclaw's Strix Halo iGPU. Text-only; one conversation resident at a time.", + "section": "AI", + "description": "The big brain. Qwen3.8-Flash-Next UD-IQ4_XS on unsloth llama.cpp, running on Dankclaw's Strix Halo iGPU. Text-only; one conversation resident at a time.", "url": "", "docs": "/docs#models", "probe": "127.0.0.1:7770", @@ -244,7 +266,8 @@ { "name": "frank-medium", "category": "user", - "description": "The 27B generalist. Qwen3.8-27B-Uncensored at MXFP4 on vllm-radiance, one tensor-parallel process across Dankserver's two R9700s with a DFlash2 drafter; 262k-token context shared by up to eight conversations.", + "section": "AI", + "description": "The 27B generalist. Qwen3.8-27B-Uncensored at FP8 on vllm-radiance, one tensor-parallel process across Dankserver's two R9700s with a DFlash2 drafter; 262k-token context shared by up to eight conversations.", "url": "", "docs": "/docs#models", "probe": "192.168.0.235:7770", @@ -255,6 +278,7 @@ { "name": "frank-small", "category": "user", + "section": "AI", "description": "The vision path. HauhauCS Gemma4-12B QAT (Balanced, Q4_K_M) with MTP + ngram spec-decode on Dankclaw's RTX 3090. Sees images; 1M-token pool across four conversations.", "url": "", "docs": "/docs#models", @@ -266,28 +290,31 @@ { "name": "mariadb", "category": "admin", + "section": "Infrastructure", "description": "RomM's database (Dankserver). Not probeable from the monitor: 3306 is container-internal, and publishing a database port on the LAN is not a trade this fleet makes. RomM's own card covers the stack.", "url": "", "docs": "", "probe": "192.168.0.235:3306", "method": "tcp", - "wip": true, + "wip": "closed", "showLink": false }, { "name": "valkey", "category": "admin", + "section": "Infrastructure", "description": "SearXNG's cache/limiter (Dankclaw). Not probeable: 6379 is container-internal. SearXNG's own card covers the stack.", "url": "", "docs": "", "probe": "127.0.0.1:6379", "method": "tcp", - "wip": true, + "wip": "closed", "showLink": false }, { "name": "Prowlarr", "category": "admin", + "section": "Media Library Apps", "description": "Indexer manager.", "url": "http://prowlarr.franksdank.cloud", "docs": "", @@ -299,6 +326,7 @@ { "name": "Radarr", "category": "admin", + "section": "Media Library Apps", "description": "Movie library and automation.", "url": "http://radarr.franksdank.cloud", "docs": "", @@ -310,6 +338,7 @@ { "name": "Readarr", "category": "admin", + "section": "Media Library Apps", "description": "Book library and automation.", "url": "http://readarr.franksdank.cloud", "docs": "", @@ -321,6 +350,7 @@ { "name": "Lidarr", "category": "admin", + "section": "Media Library Apps", "description": "Music library and automation.", "url": "http://lidarr.franksdank.cloud", "docs": "", @@ -332,6 +362,7 @@ { "name": "qBittorrent", "category": "admin", + "section": "Media Library Apps", "description": "Torrent client.", "url": "http://qbittorrent.franksdank.cloud", "docs": "", @@ -340,20 +371,34 @@ "wip": false, "showLink": true }, + { + "name": "Tautulli", + "category": "admin", + "section": "Media Library Apps", + "description": "Plex statistics and notifications.", + "url": "http://192.168.0.235:8181", + "docs": "", + "probe": "192.168.0.235:8181", + "method": "http", + "wip": false, + "showLink": true + }, { "name": "Postgres", "category": "admin", + "section": "Infrastructure", "description": "Authentik's and Gitea's databases (Dankclaw). Not probeable: 5432 is container-internal. Their apps' cards cover the stacks.", "url": "", "docs": "", "probe": "127.0.0.1:5432", "method": "tcp", - "wip": true, + "wip": "closed", "showLink": false }, { "name": "ComfyUI", "category": "admin", + "section": "AI", "description": "Node-based image generation.", "url": "", "docs": "", diff --git a/shared/homepage/style.css b/shared/homepage/style.css index 068a2cd..6d56d59 100644 --- a/shared/homepage/style.css +++ b/shared/homepage/style.css @@ -47,10 +47,20 @@ body::before { padding-bottom: 8px; } .title { color: var(--head); } -.toggle { color: var(--link); text-decoration: none; border: 1px solid var(--border); padding: 0 8px; } - .summary { color: var(--dim); margin: 12px 0; letter-spacing: 0.05em; } +.section { margin-bottom: 20px; } +.section-title { + color: var(--head); + font-size: 1rem; + font-weight: normal; + letter-spacing: 0.08em; + text-transform: uppercase; + border-bottom: 1px solid var(--border); + margin: 0 0 12px; + padding-bottom: 4px; +} + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); @@ -63,7 +73,8 @@ body::before { .badge { border: 1px solid currentColor; padding: 0 6px; border-radius: 2px; font-size: 12px; } .badge.up { color: var(--fg); } .badge.down { color: var(--down); } -.badge.wip { color: var(--wip); } +.badge.wip-open { color: var(--wip); } +.badge.wip-closed { color: var(--dim); } .desc { color: var(--fg); margin: 8px 0; } .links a { color: var(--link); text-decoration: underline; margin-right: 12px; } @@ -91,5 +102,6 @@ html[data-bare="1"] .docs a, html[data-bare="1"] .docs code { color: #000; } html[data-bare="1"] body { background: #fff; color: #000; font-family: serif; text-shadow: none; font-size: 16px; } html[data-bare="1"] body::before { display: none; } html[data-bare="1"] .panel, html[data-bare="1"] .top, html[data-bare="1"] .foot { border-color: #000; background: none; } -html[data-bare="1"] .badge, html[data-bare="1"] .name, html[data-bare="1"] .desc, html[data-bare="1"] .summary { color: #000; } +html[data-bare="1"] .badge, html[data-bare="1"] .name, html[data-bare="1"] .desc, html[data-bare="1"] .summary, html[data-bare="1"] .section-title { color: #000; } +html[data-bare="1"] .section-title { border-color: #000; } html[data-bare="1"] .links a, html[data-bare="1"] .toggle, html[data-bare="1"] .foot a { color: #000; } diff --git a/tests/homepage_checker.bats b/tests/homepage_checker.bats index d26c84e..98a9a4e 100644 --- a/tests/homepage_checker.bats +++ b/tests/homepage_checker.bats @@ -2,7 +2,8 @@ # Seam: scripts/homepage-status.py # Given a service config, the checker writes a valid status.json with the # correct state per service: an open port is up, a closed port is down, -# a wip service is wip (not probed). The output carries generated_at. +# a wip service is not probed and takes the state of its wip value +# (wip-open / wip-closed). The output carries generated_at. load helpers @@ -24,13 +25,14 @@ teardown() { rm -rf "$TMP" } -@test "checker: open port is up, closed port is down, wip is wip" { +@test "checker: open port is up, closed port is down, wip is wip-open / wip-closed" { local svc="$TMP/services.json" out="$TMP/status.json" cat > "$svc" < "$svc" + echo '[{"name":"x","category":"user","probe":"127.0.0.1:1","method":"tcp","wip":"closed"}]' > "$svc" python3 "$CHECKER" "$svc" "$out" [ "$(stat -c %a "$out")" = "644" ] } diff --git a/tests/homepage_config.bats b/tests/homepage_config.bats index a5312b1..93033a8 100644 --- a/tests/homepage_config.bats +++ b/tests/homepage_config.bats @@ -1,8 +1,9 @@ #!/usr/bin/env bats # Seam: shared/homepage/services.json — the service config the checker reads # and the pages render. It must be a JSON list; every entry carries name, -# category, and a host:port probe. category is user or admin. method, when -# present, is tcp or http. +# category, section, and a host:port probe. category is user or admin; +# section must be one the page's SECTION_ORDER renders. method, when +# present, is tcp or http. wip, when set, is "open" or "closed". load helpers @@ -17,16 +18,34 @@ except FileNotFoundError: assert isinstance(data, list), "top level must be a list" assert data, "the list must not be empty" for svc in data: - for field in ("name", "category", "probe"): + for field in ("name", "category", "section", "probe"): assert field in svc, f"{svc.get('name', '?')} is missing {field}" assert svc["category"] in ("user", "admin"), svc["category"] if "method" in svc: assert svc["method"] in ("tcp", "http"), svc["method"] + wip = svc.get("wip") + assert wip is False or wip in ("open", "closed"), f"{svc['name']} wip must be false, 'open' or 'closed'" host, _, port = svc["probe"].rpartition(":") assert host and port.isdigit(), f"bad probe {svc['probe']}" PY } +@test "services.json: every section is one the page renders" { + # A service whose section is not in app.js's SECTION_ORDER for its + # category is silently dropped from the page. Pin the contract. + python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY' +import json, sys +data = json.load(open(sys.argv[1])) +allowed = { + "user": {"AI", "Web Apps", "Media Library Apps"}, + "admin": {"Media Library Apps", "Observability", "AI", "Infrastructure"}, +} +for svc in data: + assert svc.get("section") in allowed[svc["category"]], ( + f"{svc['name']} has unrenderable section {svc.get('section')!r} for {svc['category']}") +PY +} + @test "services.json: every user service has an Open link, status-only backends do not" { python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY' import json, sys diff --git a/tests/homepage_pages.bats b/tests/homepage_pages.bats index de64e7d..799af1f 100644 --- a/tests/homepage_pages.bats +++ b/tests/homepage_pages.bats @@ -4,6 +4,8 @@ # statically: the pages reference the data and the theme, the page does not # auto-refresh (Q41), does not alter the user's cursor (Q28), offers the # styling toggle (Q27), and shows the last master deployment datetime (Q43). +# Both pages render through the shared section/panel components, and WIP +# badges distinguish open from closed work. load helpers @@ -24,6 +26,21 @@ setup() { grep -q "services.json" "$BASE/app.js" } +@test "sections and panels are shared components rendered by both pages" { + # Both pages load the same app.js; the grouping must live in shared + # renderSection()/renderPanel() components, not in per-page markup. + grep -q "renderSection" "$BASE/app.js" + grep -q "renderPanel" "$BASE/app.js" + grep -q "SECTION_ORDER" "$BASE/app.js" +} + +@test "WIP badges distinguish open from closed work" { + grep -q "WIP: OPEN" "$BASE/app.js" + grep -q "WIP: CLOSED" "$BASE/app.js" + grep -q "wip-open" "$BASE/style.css" + grep -q "wip-closed" "$BASE/style.css" +} + @test "the page does not auto-refresh (Q41)" { ! grep -q "setInterval" "$BASE/app.js" ! grep -qi "poll" "$BASE/app.js"