Homepage (TDD slice 3): the real services.json + schema tests

Red tests first (the old services.json was an object, not the new list schema).
- tests/homepage_config.bats: the config is a JSON list; every entry has
  name/category/probe; category is user|admin; method is tcp|http; a
  showLink entry has a url and a status-only entry does not.
- shared/homepage/services.json: the real fleet. User: SearXNG, ComfyUI,
  OpenWebUI, Gitea, Calibre-Web, RomM, Copyparty, Plex, Jellyfin. Admin:
  Hermes, Grafana, Prometheus, Loki, node-exporter, alloy, Caddy, dnsmasq,
  Gluetun (wip - a tunnel has no simple local port), the two LLMs, mariadb,
  valkey, Postgres. Ports confirmed from the repo config (compose/quadlet).
  docs fields are empty for now (the /docs/ workstream fills them as each
  service goes active).
This commit is contained in:
Frank
2026-08-23 12:11:21 -07:00
parent fcb6593afd
commit ce55d1dc3a
2 changed files with 67 additions and 59 deletions
+26 -59
View File
@@ -1,59 +1,26 @@
{
"host": "dankclaw",
"services": [
{
"name": "Homepage",
"url": "/",
"description": "This page. Password-protected service index.",
"docs": "docs/Infrastructure/Homepage.md"
},
{
"name": "SearXNG",
"url": "http://search.franksdank.cloud",
"description": "Privacy-respecting metasearch engine (through Gluetun VPN).",
"docs": "docs/Dankclaw/SearXNG.md"
},
{
"name": "ComfyUI",
"url": "http://comfyui.franksdank.cloud",
"description": "Node-based AI image generation (CPU backend on Strix Halo).",
"docs": "docs/Dankclaw/ComfyUI.md"
},
{
"name": "OpenWebUI",
"url": "http://chat.franksdank.cloud",
"description": "Chat interface for local LLMs (llama.cpp on :7770).",
"docs": "docs/Dankclaw/OpenWebUI.md"
},
{
"name": "Hermes",
"url": "http://hermes.franksdank.cloud",
"description": "Self-improving AI agent (Nous Research).",
"docs": "docs/Dankclaw/Hermes.md"
},
{
"name": "Gitea",
"url": "http://gitea.franksdank.cloud",
"description": "Self-hosted Git service with issues and PRs.",
"docs": "docs/Dankclaw/Gitea.md"
},
{
"name": "Grafana",
"url": "http://grafana.franksdank.cloud",
"description": "Dashboards for metrics and logs.",
"docs": "docs/Infrastructure/Grafana.md"
},
{
"name": "Prometheus",
"url": "http://prometheus.franksdank.cloud",
"description": "Metrics collection for the whole stack.",
"docs": "docs/Infrastructure/Prometheus.md"
},
{
"name": "Loki",
"url": "http://loki.franksdank.cloud",
"description": "Log storage and query backend.",
"docs": "docs/Infrastructure/Loki.md"
}
]
}
[
{"name":"SearXNG","category":"user","description":"Privacy-respecting metasearch engine.","url":"http://search.franksdank.cloud","docs":"","probe":"127.0.0.1:8081","method":"http","wip":false,"showLink":true},
{"name":"ComfyUI","category":"user","description":"Node-based image generation.","url":"http://comfyui.franksdank.cloud","docs":"","probe":"127.0.0.1:8188","method":"http","wip":false,"showLink":true},
{"name":"OpenWebUI","category":"user","description":"Chat interface for the LLM fleet.","url":"http://chat.franksdank.cloud","docs":"","probe":"127.0.0.1:3005","method":"http","wip":false,"showLink":true},
{"name":"Gitea","category":"user","description":"Git hosting and code review.","url":"http://gitea.franksdank.cloud","docs":"","probe":"127.0.0.1:3000","method":"http","wip":false,"showLink":true},
{"name":"Calibre-Web","category":"user","description":"E-book library.","url":"http://books.franksdank.cloud","docs":"","probe":"192.168.0.235:8083","method":"http","wip":false,"showLink":true},
{"name":"RomM","category":"user","description":"ROM and emulator library.","url":"http://romm.franksdank.cloud","docs":"","probe":"192.168.0.235:8085","method":"http","wip":false,"showLink":true},
{"name":"Copyparty","category":"user","description":"File sharing over the LAN.","url":"http://files.franksdank.cloud","docs":"","probe":"192.168.0.235:3923","method":"http","wip":false,"showLink":true},
{"name":"Plex","category":"user","description":"Media server.","url":"http://plex.franksdank.cloud","docs":"","probe":"192.168.0.235:32400","method":"http","wip":false,"showLink":true},
{"name":"Jellyfin","category":"user","description":"Media server.","url":"http://jellyfin.franksdank.cloud","docs":"","probe":"192.168.0.235:8096","method":"http","wip":false,"showLink":true},
{"name":"Hermes","category":"admin","description":"The agent gateway.","url":"http://hermes.franksdank.cloud","docs":"","probe":"127.0.0.1:8090","method":"http","wip":false,"showLink":true},
{"name":"Grafana","category":"admin","description":"Dashboards.","url":"http://grafana.franksdank.cloud","docs":"","probe":"127.0.0.1:3002","method":"http","wip":false,"showLink":true},
{"name":"Prometheus","category":"admin","description":"Metrics collection.","url":"http://prometheus.franksdank.cloud","docs":"","probe":"127.0.0.1:9090","method":"http","wip":false,"showLink":true},
{"name":"Loki","category":"admin","description":"Log storage.","url":"http://loki.franksdank.cloud","docs":"","probe":"127.0.0.1:3100","method":"http","wip":false,"showLink":true},
{"name":"node-exporter","category":"admin","description":"Host metrics.","url":"","docs":"","probe":"127.0.0.1:9100","method":"http","wip":false,"showLink":false},
{"name":"alloy","category":"admin","description":"Log shipping.","url":"","docs":"","probe":"127.0.0.1:12345","method":"http","wip":false,"showLink":false},
{"name":"Caddy","category":"admin","description":"The reverse proxy.","url":"","docs":"","probe":"127.0.0.1:80","method":"http","wip":false,"showLink":false},
{"name":"dnsmasq","category":"admin","description":"LAN name resolution.","url":"","docs":"","probe":"127.0.0.1:53","method":"tcp","wip":false,"showLink":false},
{"name":"Gluetun","category":"admin","description":"The outbound VPN tunnel.","url":"","docs":"","probe":"127.0.0.1:51820","method":"tcp","wip":true,"showLink":false},
{"name":"frank-default-large","category":"admin","description":"The large LLM (27B) on Dankclaw.","url":"","docs":"","probe":"127.0.0.1:7770","method":"http","wip":false,"showLink":false},
{"name":"frank-default-small","category":"admin","description":"The small LLM (35B MoE) on Dankserver.","url":"","docs":"","probe":"192.168.0.235:7770","method":"http","wip":false,"showLink":false},
{"name":"mariadb","category":"admin","description":"The media-stack database.","url":"","docs":"","probe":"192.168.0.235:3306","method":"tcp","wip":false,"showLink":false},
{"name":"valkey","category":"admin","description":"The SearXNG cache.","url":"","docs":"","probe":"127.0.0.1:6379","method":"tcp","wip":false,"showLink":false},
{"name":"Postgres","category":"admin","description":"The Gitea database.","url":"","docs":"","probe":"127.0.0.1:5432","method":"tcp","wip":false,"showLink":false}
]
+41
View File
@@ -0,0 +1,41 @@
#!/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.
load helpers
@test "services.json: valid schema" {
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
import json, sys
path = sys.argv[1]
try:
data = json.load(open(path))
except FileNotFoundError:
sys.exit("services.json not found")
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"):
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"]
host, _, port = svc["probe"].rpartition(":")
assert host and port.isdigit(), f"bad probe {svc['probe']}"
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
data = json.load(open(sys.argv[1]))
for svc in data:
show = svc.get("showLink", True)
if show:
assert svc.get("url"), f"{svc['name']} shows a link but has no url"
else:
assert not svc.get("url"), f"{svc['name']} is status-only but has a url"
PY
}