feat(infra): landing-page health badges and the reach alias

nginx serves fixed /healthz/<service> probes (one named upstream each,
CORS-readable, no-store) so the landing and Gitea home panels show live
status; it joins the observability and storage networks to reach loki,
tempo and rustfs, and resolves through Docker DNS so recreated
containers are picked up. reach.fhirworx.io is a second hostname for the
LEAD public-data explorer (coredns, traefik, SSO client, subdomains).
This commit is contained in:
kert
2026-11-11 12:57:04 -05:00
parent 60b911a057
commit 607dd40e62
9 changed files with 127 additions and 35 deletions

View File

@@ -159,17 +159,38 @@ h2 {
color: var(--primary);
}
/* Status dot */
.status {
position: absolute;
top: 1rem;
right: 1rem;
width: 8px;
height: 8px;
background: var(--chart-4);
border-radius: 50%;
/* Health, inside the tile. Same shape as the corwins.media status badge:
a label, a status word, and a left edge that carries the state. */
.health {
display: flex;
flex-direction: column;
margin: 0 0 0.85rem;
padding: 8px 10px;
border: 1px solid var(--border);
border-left-width: 4px;
background: var(--background);
}
.health-k {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.health-v {
font-size: 18px;
font-weight: 600;
line-height: 1.2;
color: var(--foreground);
}
.health-ok { border-left-color: var(--chart-4) !important; }
.health-warn { border-left-color: var(--chart-5) !important; }
.health-bad { border-left-color: var(--destructive) !important; }
.health-unknown { border-left-color: var(--muted-foreground) !important; }
/* Icon plate — one navy line glyph in a hairline square, like the
identification block on a technical drawing. Glyphs are the inline
Tabler sprite in index.html; stroke weight and size live here. */

View File

@@ -600,10 +600,15 @@ services:
dashboard:
image: nginx:alpine
container_name: dashboard
# gateway reaches the apps. observability reaches loki and tempo.
# storage reaches rustfs. The landing-page health probes use those names.
networks:
- gateway
- observability
- storage
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/nginx/healthz-headers.conf:/etc/nginx/healthz-headers.conf:ro
- ./infra/nginx/index.html:/usr/share/nginx/html/index.html:ro
- ./assets/css/dashboard.css:/usr/share/nginx/html/dashboard.css:ro
- ./assets/css/inject.css:/usr/share/nginx/html/fhirworx.css:ro

View File

@@ -277,6 +277,7 @@ SUBDOMAINS = [
"llm",
"bcda", # BCDA manager (P52) — Streamlit behind git-sso
"lead", # LEAD public-data explorer — Streamlit behind git-sso
"reach", # same public-data explorer, at reach.fhirworx.io
]

View File

@@ -19,3 +19,4 @@
192.168.1.192 llm.fhirworx.io
192.168.1.192 bcda.fhirworx.io
192.168.1.192 lead.fhirworx.io
192.168.1.192 reach.fhirworx.io

View File

@@ -0,0 +1,10 @@
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header Connection "";
proxy_connect_timeout 2s;
proxy_read_timeout 3s;
# Some apps already send this header. Two copies make the browser
# reject the response, so the badge would say down while the app is up.
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "no-store" always;

View File

@@ -51,178 +51,188 @@
<h2>Infrastructure</h2>
</div>
<div class="grid">
<div class="grid" data-health-origin="">
<a data-subdomain="llm" target="_blank" class="tile notebook">
<span class="badge gpu">GPU</span>
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-messages"/></svg></span>
<div class="tile-eyebrow">Chat</div>
<div class="tile-title">Comment Chat</div>
<p class="tile-desc">RAG chat over indexed CMS rulemaking comments, with cited sources</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="bcda" data-path="/_stcore/health" target="_blank" class="tile notebook">
<span class="status"></span>
<a data-subdomain="bcda" target="_blank" class="tile notebook">
<span class="icon" aria-hidden="true"><svg><use href="#i-file-invoice"/></svg></span>
<div class="tile-eyebrow">Claims</div>
<div class="tile-title">BCDA Manager</div>
<p class="tile-desc">Beneficiary Claims Data API v3 — exports, store, load, crosswalk</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="lead" data-path="/_stcore/health" target="_blank" class="tile notebook">
<span class="status"></span>
<a data-subdomain="lead" target="_blank" class="tile notebook">
<span class="icon" aria-hidden="true"><svg><use href="#i-chart-area-line"/></svg></span>
<div class="tile-eyebrow">LEAD</div>
<div class="tile-title">Public mirror</div>
<p class="tile-desc">Read-only explorer over the public-data DuckDB mirror</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="reach" target="_blank" class="tile notebook">
<span class="icon" aria-hidden="true"><svg><use href="#i-chart-area-line"/></svg></span>
<div class="tile-eyebrow">REACH</div>
<div class="tile-title">Public mirror</div>
<p class="tile-desc">Read-only explorer over the public-data DuckDB mirror</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="traefik" target="_blank" class="tile proxy">
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-route"/></svg></span>
<div class="tile-eyebrow">Networking</div>
<div class="tile-title">Traefik</div>
<p class="tile-desc">Cloud-native reverse proxy and load balancer with automatic TLS</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="s3console" target="_blank" class="tile storage">
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-server-2"/></svg></span>
<div class="tile-eyebrow">Storage</div>
<div class="tile-title">RustFS</div>
<p class="tile-desc">S3-compatible object storage for all your data needs</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="git" target="_blank" class="tile git">
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-git-branch"/></svg></span>
<div class="tile-eyebrow">Version Control</div>
<div class="tile-title">Gitea</div>
<p class="tile-desc">Self-hosted Git service with packages and CI</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="notebooks" target="_blank" class="tile notebook">
<span class="badge gpu">GPU</span>
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-notebook"/></svg></span>
<div class="tile-eyebrow">Analysis</div>
<div class="tile-title">Notebooks</div>
<p class="tile-desc">Marimo reactive notebooks with CUDA acceleration</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="zotero" target="_blank" class="tile research">
<span class="badge gpu">GPU</span>
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-books"/></svg></span>
<div class="tile-eyebrow">Research</div>
<div class="tile-title">Zotero</div>
<p class="tile-desc">Research reference manager in the cloud</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="docs" target="_blank" class="tile research">
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-file-text"/></svg></span>
<div class="tile-eyebrow">Documentation</div>
<div class="tile-title">Docs</div>
<p class="tile-desc">API reference and CMS bibliography browser</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="nessie" data-path="/api/v2/config" target="_blank" class="tile catalog">
<a data-subdomain="nessie" target="_blank" class="tile catalog">
<span class="badge data">Data</span>
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-versions"/></svg></span>
<div class="tile-eyebrow">Catalog</div>
<div class="tile-title">Nessie</div>
<p class="tile-desc">Git-like versioned data catalog for Apache Iceberg</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="trino" target="_blank" class="tile query">
<span class="badge data">Data</span>
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-sql"/></svg></span>
<div class="tile-eyebrow">Query</div>
<div class="tile-title">Trino</div>
<p class="tile-desc">Distributed SQL query engine for the lakehouse</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="polaris" target="_blank" class="tile catalog">
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-north-star"/></svg></span>
<div class="tile-eyebrow">Catalog</div>
<div class="tile-title">Polaris</div>
<p class="tile-desc">Apache Iceberg catalog with governance and Unity-style access control</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="grafana" target="_blank" class="tile observability">
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-chart-area-line"/></svg></span>
<div class="tile-eyebrow">Observability</div>
<div class="tile-title">Grafana</div>
<p class="tile-desc">Observability dashboards for metrics, logs, and traces</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="tempo" target="_blank" class="tile metrics">
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-timeline"/></svg></span>
<div class="tile-eyebrow">Tracing</div>
<div class="tile-title">Tempo</div>
<p class="tile-desc">Distributed tracing for microservices</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="prometheus" target="_blank" class="tile logs">
<span class="status"></span>
<span class="icon" aria-hidden="true"><svg><use href="#i-activity-heartbeat"/></svg></span>
<div class="tile-eyebrow">Metrics</div>
<div class="tile-title">Prometheus</div>
<p class="tile-desc">Metrics collection and alerting</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="loki" data-path="/ready" target="_blank" class="tile database">
<span class="status"></span>
<a data-subdomain="loki" target="_blank" class="tile database">
<span class="icon" aria-hidden="true"><svg><use href="#i-list-details"/></svg></span>
<div class="tile-eyebrow">Logs</div>
<div class="tile-title">Loki</div>
<p class="tile-desc">Log aggregation system by Grafana</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
<a data-subdomain="api" data-path="/health" target="_blank" class="tile proxy">
<span class="status"></span>
<a data-subdomain="api" target="_blank" class="tile proxy">
<span class="icon" aria-hidden="true"><svg><use href="#i-api"/></svg></span>
<div class="tile-eyebrow">API</div>
<div class="tile-title">Platform API</div>
<p class="tile-desc">FastAPI platform backend</p>
<span class="health health-unknown"><span class="health-k">health</span><span class="health-v">checking</span></span>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
@@ -234,15 +244,33 @@
</div>
<script>
function paintHealth(el, origin, sub, fqdn) {
var box = el.querySelector('.health');
if (!box) return;
var value = box.querySelector('.health-v');
function paint(kind, text) {
box.className = 'health health-' + kind;
value.textContent = text;
}
fetch(origin + '/healthz/' + sub, {cache: 'no-store'}).then(function (r) {
if (r.status === 503) paint('warn', 'not ready');
else if (r.ok) paint('ok', 'up');
else paint('bad', 'down');
}, function () {
paint('bad', 'down');
});
}
(function() {
var root = document.querySelector('[data-health-origin]');
var origin = root ? (root.getAttribute('data-health-origin') || '') : '';
var base = location.hostname.replace(/^dashboard\./, '');
document.querySelectorAll('[data-subdomain]').forEach(function(el) {
var sub = el.dataset.subdomain;
var path = el.dataset.path || '';
var fqdn = sub + '.' + base;
el.href = 'https://' + fqdn + path;
el.href = 'https://' + fqdn + '/';
var port = el.querySelector('.tile-port');
if (port) port.textContent = fqdn;
paintHealth(el, origin, sub, fqdn);
});
})();
</script>

View File

@@ -13,6 +13,9 @@ http {
root /usr/share/nginx/html;
index index.html;
# Docker's DNS, so a recreated container is picked up without a reload.
resolver 127.0.0.11 valid=10s ipv6=off;
# CORS for theme assets (loaded cross-origin by themed services)
location ~* \.(css|svg|png|ico)$ {
add_header Access-Control-Allow-Origin "*";
@@ -21,6 +24,28 @@ http {
try_files $uri =404;
}
# Service health for the landing panels. The Gitea home fetches these
# cross-origin, so the status code has to be readable from any host.
# Each name is one fixed upstream. Nothing here is an open proxy.
location = /healthz/llm { set $up llm:8000; rewrite ^ /health break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/bcda { set $up bcda:8501; rewrite ^ /_stcore/health break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/lead { set $up lead:8501; rewrite ^ /_stcore/health break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/reach { set $up lead:8501; rewrite ^ /_stcore/health break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/traefik { set $up traefik:8080; rewrite ^ /api/version break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/s3console { set $up rustfs:9001; rewrite ^ /health break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/git { set $up git:3000; rewrite ^ /api/healthz break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/notebooks { set $up notebooks:2718; rewrite ^ /health break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/zotero { set $up zotero:8080; rewrite ^ / break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/docs { set $up docs:80; rewrite ^ / break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/nessie { set $up nessie:19120; rewrite ^ /api/v2/config break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/trino { set $up trino:8080; rewrite ^ /v1/info break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/polaris { set $up polaris:8182; rewrite ^ /q/health/ready break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/grafana { set $up grafana:3000; rewrite ^ /api/health break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/tempo { set $up tempo:3200; rewrite ^ /ready break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/prometheus { set $up prometheus:9090; rewrite ^ /-/healthy break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/loki { set $up loki:3100; rewrite ^ /ready break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location = /healthz/api { set $up api:8000; rewrite ^ /health break; proxy_pass http://$up; include /etc/nginx/healthz-headers.conf; }
location / {
try_files $uri $uri/ =404;
}

View File

@@ -21,6 +21,7 @@
{{- $multi := dict
"rustfs-api" (dict "container" "rustfs" "port" "9000" "subdomain" "s3" "theme" false "mw" "git-sso,infra-headers")
"rustfs-console" (dict "container" "rustfs" "port" "9001" "subdomain" "s3console" "theme" true "mw" "git-sso,infra-headers")
"reach" (dict "container" "lead" "port" "8501" "subdomain" "reach" "theme" false "mw" "git-sso,secure-headers")
-}}
http:
middlewares:

View File

@@ -24,7 +24,7 @@ subdomains = [
"dashboard", "docs", "git", "ci", "notebooks", "zotero",
"webdav", "api", "nessie", "trino", "polaris",
"grafana", "prometheus", "tempo", "loki",
"s3", "s3console", "llm", "bcda", "lead",
"s3", "s3console", "llm", "bcda", "lead", "reach",
]
[services]