homepage: sectioned panels, WIP open/closed, current model facts, Tautulli card
Both pages now group their panels under section headings rendered by shared
renderSection()/renderPanel() components in app.js (SECTION_ORDER fixes each
page's order: user = AI, Web Apps, Media Library Apps; admin = Media Library
Apps, Observability, AI, Infrastructure). The WIP badge splits into WIP: OPEN
and WIP: CLOSED via the wip value ("open"/"closed"); the checker passes the
value through as wip-open/wip-closed. The frank-large card follows the
unsloth llama.cpp cutover, frank-medium follows the FP8 checkpoint, and the
/docs model guide matches. Tautulli (Plex statistics, 192.168.0.235:8181)
joins the admin page's Media Library Apps section with an open link.
This commit is contained in:
@@ -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 <services.json> <out.json>
|
||||
"""
|
||||
@@ -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"))
|
||||
|
||||
@@ -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/<svc>/`).
|
||||
- `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
|
||||
|
||||
+59
-19
@@ -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('<a class="open" href="' + esc(svc.url) + '">open</a>');
|
||||
if (svc.docs) links.push('<a class="docs" href="' + esc(svc.docs) + '">docs</a>');
|
||||
panel.innerHTML =
|
||||
'<div class="panel-head">' +
|
||||
' <span class="badge ' + state + '">' + BADGE_LABEL[state] + '</span>' +
|
||||
' <span class="name">' + esc(svc.name) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="desc">' + esc(svc.description || '') + '</div>' +
|
||||
'<div class="links">' + links.join('') + '</div>';
|
||||
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('<a class="open" href="' + esc(svc.url) + '">open</a>');
|
||||
if (svc.docs) links.push('<a class="docs" href="' + esc(svc.docs) + '">docs</a>');
|
||||
panel.innerHTML =
|
||||
'<div class="panel-head">' +
|
||||
' <span class="badge ' + state + '">' + state.toUpperCase() + '</span>' +
|
||||
' <span class="name">' + esc(svc.name) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="desc">' + esc(svc.description || '') + '</div>' +
|
||||
'<div class="links">' + links.join('') + '</div>';
|
||||
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;
|
||||
|
||||
@@ -47,9 +47,9 @@
|
||||
|
||||
<h2>frank-large — the big brain</h2>
|
||||
<ul>
|
||||
<li><strong>Model:</strong> Qwen3.8-Flash CIRU-STRIX-Orca, served by CIRU v4.4.1 llama.cpp with the model's own MTP draft head.</li>
|
||||
<li><strong>Model:</strong> Qwen3.8-Flash-Next UD-IQ4_XS, served by unsloth llama.cpp with the model's own MTP draft head (depth 2).</li>
|
||||
<li><strong>Hardware:</strong> Dankclaw's Strix Halo iGPU (gfx1151), shared with the whole host.</li>
|
||||
<li><strong>Best for:</strong> long, thoughtful text conversations at the model's full trained window.</li>
|
||||
<li><strong>Best for:</strong> long, thoughtful text conversations at a 200k-token window.</li>
|
||||
<li><strong>Capacity:</strong> exactly one conversation resident at a time. A second waits — politely, but it waits.</li>
|
||||
<li><strong>Not for:</strong> images. It cannot see them.</li>
|
||||
</ul>
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
<h2>frank-medium — the 27B generalist</h2>
|
||||
<ul>
|
||||
<li><strong>Model:</strong> Qwen3.8-27B-Uncensored at MXFP4, on vllm-radiance with a DFlash2 draft head.</li>
|
||||
<li><strong>Model:</strong> Qwen3.8-27B-Uncensored at FP8, on vllm-radiance with a DFlash2 draft head.</li>
|
||||
<li><strong>Hardware:</strong> Dankserver's two Radeon R9700s as ONE tensor-parallel engine (TP=2).</li>
|
||||
<li><strong>Best for:</strong> general chat and reasoning when the big brain is busy; the biggest pure-language model we run, and the fastest we have measured.</li>
|
||||
<li><strong>Capacity:</strong> 262k-token context shared by up to eight conversations at once. It can see pictures too; <code>frank-small</code> is the faster eye and holds four conversations.</li>
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# Seam: scripts/homepage-status.py <services.json> <out.json>
|
||||
# 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" <<JSON
|
||||
[
|
||||
{"name":"up","category":"user","probe":"127.0.0.1:$UP_PORT","method":"tcp","wip":false},
|
||||
{"name":"down","category":"user","probe":"127.0.0.1:$DOWN_PORT","method":"tcp","wip":false},
|
||||
{"name":"wip","category":"user","probe":"127.0.0.1:1","method":"tcp","wip":true}
|
||||
{"name":"wipopen","category":"user","probe":"127.0.0.1:1","method":"tcp","wip":"open"},
|
||||
{"name":"wipclosed","category":"user","probe":"127.0.0.1:1","method":"tcp","wip":"closed"}
|
||||
]
|
||||
JSON
|
||||
python3 "$CHECKER" "$svc" "$out"
|
||||
@@ -41,7 +43,8 @@ d = json.load(open(sys.argv[1]))
|
||||
s = {x["name"]: x["state"] for x in d["services"]}
|
||||
assert s["up"] == "up", s
|
||||
assert s["down"] == "down", s
|
||||
assert s["wip"] == "wip", s
|
||||
assert s["wipopen"] == "wip-open", s
|
||||
assert s["wipclosed"] == "wip-closed", s
|
||||
assert "generated_at" in d
|
||||
PY
|
||||
}
|
||||
@@ -91,7 +94,7 @@ PY
|
||||
|
||||
@test "the checker writes status.json world-readable so Caddy can serve it" {
|
||||
local svc="$TMP/services.json" out="$TMP/status.json"
|
||||
echo '[{"name":"x","category":"user","probe":"127.0.0.1:1","method":"tcp","wip":true}]' > "$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" ]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user