Skip to content

Commit 59fd7e9

Browse files
committed
KB per-container + Neo4j latency chart + reorder (v0.10.0)
1. KB Neo4j (kb.virtualflybrain.org) gains per-container probing via service 1s56 (vfb-neo4j-kb-readonly, scale 2). Same pattern as PDB and ServerONLY: each member hit at http://<ip>:7474. 2. New "Query latency over time (cluster avg, ms)" sparkline on every Neo4j card. Plots the AVG(latency_ms) per ts already captured in neo4j_history. 3. Core user-facing group now renders at the very top of the page, above Rancher cluster / Neo4j / Apps / Caches. The remaining groups stay in their original order below. Also: - Auth-failure detection on Neo4j cards now applies cluster-wide (every container errors with 401/Unauthorized -> auth? pill). - Moved the all-auth-err / first-err computation out of Jinja (where the select('search', ...) test doesn't exist) into the card builder in Python.
1 parent 46cc30d commit 59fd7e9

4 files changed

Lines changed: 101 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
All notable changes to vfb-status are recorded here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44

5+
## [0.10.0] — 2026-06-04
6+
7+
### Added
8+
9+
- KB Neo4j (`kb.virtualflybrain.org`) now per-container: probes each `vfb-neo4j-kb-readonly` member (service `1s56`, scale 2) at `http://<primaryIpAddress>:7474`. Same SHOW DATABASES + count probe; same cluster summary + per-container table on the card.
10+
- Latency-over-time sparkline on each Neo4j card, below the node-count chart. Plots `AVG(latency_ms)` across cluster members per timestamp.
11+
- Auth-failure detection on Neo4j cards now applies to the whole cluster — if every container errors with a 401/Unauthorized, the card shows an amber `auth?` pill instead of red `down`. (Previously a single-container check.)
12+
13+
### Changed
14+
15+
- **`Core user-facing` group now renders at the very top of the page**, above the specialised sections (Rancher cluster, Neo4j databases, Application services, Caching services). The remaining groups stay in their original order below. Driven by a new `priority_groups` / `rest_groups` split passed to the template.
16+
517
## [0.9.0] — 2026-06-04
618

719
Per-container probing for `neo4j_services` + new ServerONLY PDB entry.
@@ -221,6 +233,7 @@ Initial release. Self-contained Docker uptime tracker for public-facing Virtual
221233
- Four subdomains (`nas0`, `iip3d`, `nblast`, `abd1-5.catmaid`) ship with `verify_tls: false` because the production cert SAN doesn't cover them. The servers are up; the cert provisioning is a separate problem.
222234
- Kubernetes nodes are intentionally not handled here — separate checks planned for a later release.
223235

236+
[0.10.0]: https://github.com/VirtualFlyBrain/vfb-status/releases/tag/v0.10.0
224237
[0.9.0]: https://github.com/VirtualFlyBrain/vfb-status/releases/tag/v0.9.0
225238
[0.8.2]: https://github.com/VirtualFlyBrain/vfb-status/releases/tag/v0.8.2
226239
[0.8.1]: https://github.com/VirtualFlyBrain/vfb-status/releases/tag/v0.8.1

app/main.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2181,9 +2181,15 @@ def _neo4j_card(state: State, svc: Neo4jServiceSpec) -> dict[str, Any]:
21812181
ok_containers = [c for c in containers if c.ok]
21822182
counts = [c.node_count for c in ok_containers if c.node_count is not None]
21832183
statuses = sorted({c.db_status for c in containers if c.db_status})
2184+
auth_markers = ("401", "Unauthorized", "auth")
2185+
def _is_auth_err(err: str | None) -> bool:
2186+
return bool(err) and any(m in err for m in auth_markers)
2187+
errored = [c for c in containers if not c.ok]
21842188
summary = {
21852189
"container_count": len(containers),
21862190
"ok_count": len(ok_containers),
2191+
"is_all_auth_err": bool(errored) and len(errored) == len(containers) and all(_is_auth_err(c.error) for c in errored),
2192+
"first_err": next((c.error for c in errored if c.error), None),
21872193
"node_count_max": max(counts) if counts else None,
21882194
"node_count_min": min(counts) if counts else None,
21892195
"node_count_mixed": len(set(counts)) > 1,
@@ -2204,12 +2210,14 @@ def _neo4j_card(state: State, svc: Neo4jServiceSpec) -> dict[str, Any]:
22042210
svc.name, _cache_chart_window_seconds(), max_points=120
22052211
)
22062212
count_pts = [(r["ts"], r["node_count"]) for r in series if r["node_count"] is not None]
2213+
latency_pts = [(r["ts"], r["latency_ms"]) for r in series if r["latency_ms"] is not None]
22072214
return {
22082215
"spec": svc,
22092216
"containers": containers,
22102217
"summary": summary,
22112218
"series": series,
22122219
"count_pts": count_pts,
2220+
"latency_pts": latency_pts,
22132221
}
22142222

22152223

@@ -2352,6 +2360,15 @@ async def index(request: Request) -> HTMLResponse:
23522360
cache_cards = [_cache_card(state, s) for s in state.cache_services]
23532361
app_cards = [_app_card(state, s) for s in state.app_services]
23542362
neo4j_cards = [_neo4j_card(state, s) for s in state.neo4j_services]
2363+
# Split the groups dict so the template can render the priority group
2364+
# (Core user-facing) at the very top of the page, then the specialised
2365+
# sections (Rancher cluster, Neo4j, Apps, Caches), then the remaining
2366+
# groups in their original order.
2367+
priority_group = "Core user-facing"
2368+
priority_groups = {
2369+
priority_group: grouped[priority_group]
2370+
} if priority_group in grouped else {}
2371+
rest_groups = {k: v for k, v in grouped.items() if k != priority_group}
23552372
# Cluster overview helpers
23562373
cluster = state.cluster_result
23572374
cluster_hosts_by_short = (
@@ -2392,6 +2409,8 @@ async def index(request: Request) -> HTMLResponse:
23922409
"cache_cards": cache_cards,
23932410
"app_cards": app_cards,
23942411
"neo4j_cards": neo4j_cards,
2412+
"priority_groups": priority_groups,
2413+
"rest_groups": rest_groups,
23952414
"cluster": cluster,
23962415
"cluster_summary": cluster_summary,
23972416
"cluster_hosts_by_short": cluster_hosts_by_short,

app/templates/index.html

Lines changed: 64 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,58 @@ <h1>VFB service status</h1>
254254
{%- if p is none -%}—{%- else -%}{{ "%.2f"|format(p) }}%{%- endif -%}
255255
{%- endmacro %}
256256

257+
{%- macro render_groups(group_dict) -%}
258+
{% for group_name, rows in group_dict.items() %}
259+
{% set is_rancher_group = group_name.startswith("Rancher servers") %}
260+
<section>
261+
<h2>{{ group_name }}{% if is_rancher_group and cluster_summary %} · LB on {{ cluster_summary.lb_coverage }}/{{ cluster_summary.hosts_total }} · {{ cluster_summary.dns_ingress_count }} on DNS{% endif %}</h2>
262+
<div class="svc-list">
263+
{% for row in rows %}
264+
{% set r = row.result %}
265+
{% set rh = none %}
266+
{% if is_rancher_group %}
267+
{%- set _short = r.name.split('.')[0].lower() -%}
268+
{%- set rh = cluster_hosts_by_short.get(_short) -%}
269+
{% endif %}
270+
<div class="svc">
271+
<div class="svc-status"><span class="pill {{ r.status }}">{{ r.status }}</span></div>
272+
<div class="svc-name">
273+
<a href="{{ r.url }}" rel="noopener noreferrer" target="_blank">{{ r.name }}</a>
274+
{% if rh %}
275+
<span class="host-badges">
276+
<span class="badge {{ 'badge-up' if rh.state == 'active' else 'badge-down' }}" title="Rancher state: {{ rh.state }}{% if rh.agent_state %} / agent {{ rh.agent_state }}{% endif %}">rancher: {{ rh.state }}</span>
277+
{% if rh.has_lb %}<span class="badge badge-info" title="vfb-loadbalancer-main running on this host">LB</span>{% endif %}
278+
{% if rh.is_dns_ingress %}<span class="badge badge-dns" title="virtualflybrain.org DNS A-record points at this host's agentIpAddress ({{ rh.agent_ip }})">DNS</span>{% endif %}
279+
</span>
280+
{% endif %}
281+
{% if r.error %}<div class="svc-err">{{ r.error }}</div>{% endif %}
282+
</div>
283+
<div class="svc-meta">
284+
{{ r.http_status if r.http_status is not none else "—" }}
285+
{% if r.latency_ms is not none %} · {{ r.latency_ms }} ms{% endif %}
286+
</div>
287+
<div class="strip-cell">
288+
<div class="strip" aria-label="last {{ history_buckets }} buckets, oldest left">
289+
{% for b in row.buckets %}
290+
<div class="bucket {{ b }}" title="bucket {{ loop.index0 }}: {{ b }}"></div>
291+
{% endfor %}
292+
</div>
293+
</div>
294+
<div class="uptime">
295+
<span class="pct {% if row.uptime_24h is not none and row.uptime_24h < 99 %}low{% endif %}">24h {{ fmt_pct(row.uptime_24h, row.n_24h) }}</span>
296+
<span>7d {{ fmt_pct(row.uptime_7d, row.n_7d) }}</span>
297+
<span>30d {{ fmt_pct(row.uptime_30d, row.n_30d) }}</span>
298+
</div>
299+
</div>
300+
{% endfor %}
301+
</div>
302+
</section>
303+
{% endfor %}
304+
{%- endmacro %}
305+
306+
{# Core user-facing renders first, before the specialised sections #}
307+
{{ render_groups(priority_groups) }}
308+
257309
{% macro fmt_int(n) -%}
258310
{%- if n is none -%}—{%- else -%}{{ "{:,}".format(n) }}{%- endif -%}
259311
{%- endmacro %}
@@ -352,8 +404,6 @@ <h2>Neo4j databases — content checks</h2>
352404
{% set spec = c.spec %}
353405
{% set s = c.summary %}
354406
{% set containers = c.containers %}
355-
{% set first_err = containers|selectattr('error')|map(attribute='error')|first %}
356-
{% set is_all_auth_err = containers|length > 0 and (containers|selectattr('error')|map(attribute='error')|select('search', '(401|Unauthorized|auth)')|list|length) == (containers|length) %}
357407
<article class="cache-card">
358408
<header>
359409
<div>
@@ -369,12 +419,12 @@ <h2>Neo4j databases — content checks</h2>
369419
<span class="pill up">online</span>
370420
{% elif s.container_count == 0 %}
371421
<span class="pill unknown">no data</span>
372-
{% elif is_all_auth_err %}
422+
{% elif s.is_all_auth_err %}
373423
<span class="pill auth" title="auth failed — check `password:` or NEO4J_PASSWORD">auth?</span>
374424
{% elif s.ok_count > 0 %}
375425
<span class="pill down" title="{{ s.container_count - s.ok_count }} container(s) failing">partial</span>
376426
{% else %}
377-
<span class="pill down" title="{{ first_err or 'down' }}">down</span>
427+
<span class="pill down" title="{{ s.first_err or 'down' }}">down</span>
378428
{% endif %}
379429
</header>
380430

@@ -432,7 +482,14 @@ <h2>Neo4j databases — content checks</h2>
432482
<div class="chart-label">Node count over time (cluster max)</div>
433483
{{ sparkline(c.count_pts, width=320, height=42, color="#7d4cd0", fill="rgba(125,76,208,0.15)") }}
434484
</div>
435-
{% elif s.container_count == 0 %}
485+
{% endif %}
486+
{% if c.latency_pts %}
487+
<div class="cache-chart">
488+
<div class="chart-label">Query latency over time (cluster avg, ms)</div>
489+
{{ sparkline(c.latency_pts, width=320, height=42, color="#3498db", fill="rgba(52,152,219,0.15)") }}
490+
</div>
491+
{% endif %}
492+
{% if not c.count_pts and not c.latency_pts and s.container_count == 0 %}
436493
<div class="cache-err">no data yet — first probe pending</div>
437494
{% endif %}
438495
</article>
@@ -637,52 +694,8 @@ <h2>Caching services — load over time</h2>
637694
</section>
638695
{% endif %}
639696

640-
{% for group_name, rows in groups.items() %}
641-
{% set is_rancher_group = group_name.startswith("Rancher servers") %}
642-
<section>
643-
<h2>{{ group_name }}{% if is_rancher_group and cluster_summary %} · LB on {{ cluster_summary.lb_coverage }}/{{ cluster_summary.hosts_total }} · {{ cluster_summary.dns_ingress_count }} on DNS{% endif %}</h2>
644-
<div class="svc-list">
645-
{% for row in rows %}
646-
{% set r = row.result %}
647-
{% set rh = none %}
648-
{% if is_rancher_group %}
649-
{%- set _short = r.name.split('.')[0].lower() -%}
650-
{%- set rh = cluster_hosts_by_short.get(_short) -%}
651-
{% endif %}
652-
<div class="svc">
653-
<div class="svc-status"><span class="pill {{ r.status }}">{{ r.status }}</span></div>
654-
<div class="svc-name">
655-
<a href="{{ r.url }}" rel="noopener noreferrer" target="_blank">{{ r.name }}</a>
656-
{% if rh %}
657-
<span class="host-badges">
658-
<span class="badge {{ 'badge-up' if rh.state == 'active' else 'badge-down' }}" title="Rancher state: {{ rh.state }}{% if rh.agent_state %} / agent {{ rh.agent_state }}{% endif %}">rancher: {{ rh.state }}</span>
659-
{% if rh.has_lb %}<span class="badge badge-info" title="vfb-loadbalancer-main running on this host">LB</span>{% endif %}
660-
{% if rh.is_dns_ingress %}<span class="badge badge-dns" title="virtualflybrain.org DNS A-record points at this host's agentIpAddress ({{ rh.agent_ip }})">DNS</span>{% endif %}
661-
</span>
662-
{% endif %}
663-
{% if r.error %}<div class="svc-err">{{ r.error }}</div>{% endif %}
664-
</div>
665-
<div class="svc-meta">
666-
{{ r.http_status if r.http_status is not none else "—" }}
667-
{% if r.latency_ms is not none %} · {{ r.latency_ms }} ms{% endif %}
668-
</div>
669-
<div class="strip-cell">
670-
<div class="strip" aria-label="last {{ history_buckets }} buckets, oldest left">
671-
{% for b in row.buckets %}
672-
<div class="bucket {{ b }}" title="bucket {{ loop.index0 }}: {{ b }}"></div>
673-
{% endfor %}
674-
</div>
675-
</div>
676-
<div class="uptime">
677-
<span class="pct {% if row.uptime_24h is not none and row.uptime_24h < 99 %}low{% endif %}">24h {{ fmt_pct(row.uptime_24h, row.n_24h) }}</span>
678-
<span>7d {{ fmt_pct(row.uptime_7d, row.n_7d) }}</span>
679-
<span>30d {{ fmt_pct(row.uptime_30d, row.n_30d) }}</span>
680-
</div>
681-
</div>
682-
{% endfor %}
683-
</div>
684-
</section>
685-
{% endfor %}
697+
{# Remaining groups (everything except Core user-facing which is rendered at the top) #}
698+
{{ render_groups(rest_groups) }}
686699

687700
<footer>
688701
<a href="/api/status">/api/status</a> ·

config/services.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,11 @@ neo4j_services:
230230
min_nodes: 1
231231
timeout: 20
232232
fronts: Knowledge Base (curation graph)
233-
# No rancher: block — KB is a single-instance service via LB.
233+
# vfb-neo4j-kb-readonly is the public KB read-only cluster (scale 2).
234+
rancher:
235+
service_url: https://herd.virtualflybrain.org/v2-beta/projects/1a5/services/1s56
236+
container_port: 7474
237+
container_scheme: http
234238

235239
# Application services exposing their own /status JSON (shape: vfbquery, ...).
236240
# Probed on the same schedule as everything else; metrics stored in

0 commit comments

Comments
 (0)