Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70dd7fbe6d | ||
|
|
2626332828 | ||
|
|
eb4d9910cb | ||
|
|
5cc2b3c25a | ||
|
|
18a29c40d2 | ||
|
|
73a7ec3fc1 | ||
|
|
130e9d739f | ||
|
|
854890a155 | ||
|
|
d9f3d1be1e | ||
|
|
afa3a4521c | ||
|
|
9cc8ca1c88 | ||
|
|
9c05c40aa0 |
@@ -166,9 +166,11 @@ bare compose container never returned; 2026-09-11: five observability
|
||||
services had no units). The observability services now have units under
|
||||
`dankclaw/observability/quadlet/` and their compose definitions were removed —
|
||||
one owner per container name. The stack is **configured and delivering as of
|
||||
2026-09-17**: a Grafana datasource and the `llm-fleet` dashboard are
|
||||
provisioned from `shared/grafana/provisioning/`, `shared/prometheus/rules/
|
||||
llm-fleet.yml` loads 5 alert rules + 1 recording rule, and firing rules are
|
||||
2026-09-17**: Grafana datasources (Prometheus and Loki) and the `llm-fleet`
|
||||
dashboard are provisioned from `shared/grafana/provisioning/`, and
|
||||
`shared/prometheus/rules/llm-fleet.yml` loads two rule groups — `llm-fleet`
|
||||
(llama.cpp/halogen, `llamacpp:*`) and `vllm-fleet` (radiance, `vllm:*`) — alert
|
||||
+ recording rules, and firing rules are
|
||||
delivered: Prometheus → Alertmanager (`dankclaw/observability/quadlet/
|
||||
alertmanager.container`, loopback :9093) → host-side Signal relay
|
||||
(`scripts/signal-relay.py`, user unit, 192.168.0.143:18093 — the host LAN
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Set the authentik login-session TTL on Dankclaw.
|
||||
#
|
||||
# The login-session lifetime is governed by the `default-authentication-login`
|
||||
# stage's `session_duration`, NOT a global setting and NOT SESSION_COOKIE_AGE
|
||||
# (that is the pre-login session, sessions.unauthenticated_age). Authentik does
|
||||
# not expose SESSION_SAVE_EVERY_REQUEST at all, so the session is an absolute
|
||||
# window from login, not a sliding one.
|
||||
#
|
||||
# The field is a STRING in authentik's own idiom ("days=7", "seconds=30",
|
||||
# "hours=12") — not a Python timedelta. Writing str(timedelta(days=7)) stores
|
||||
# "7 days, 0:00:00", which authentik's timedelta_from_string parser REJECTS
|
||||
# (ValueError), and login then crashes computing the session expiry. Always pass
|
||||
# the "key=value" form; this script validates it before writing.
|
||||
#
|
||||
# Existing sessions keep their old expiry; the new TTL applies from the next
|
||||
# login. This is DB state — it is NOT repo-managed and a blueprint re-import or
|
||||
# rebuild-from-scratch reverts it. Run this again after such a rebuild.
|
||||
#
|
||||
# Usage: authentik-set-login-session-duration.sh ["days=7"] (default days=7)
|
||||
set -euo pipefail
|
||||
|
||||
DUR="${1:-days=7}"
|
||||
STAGE="default-authentication-login"
|
||||
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
||||
|
||||
# Refuse anything that is not the safe key=value duration idiom.
|
||||
if ! printf '%s' "$DUR" | grep -Eq '^[A-Za-z]+=[0-9]+([ ]+[A-Za-z]+=[0-9]+)*$'; then
|
||||
echo "invalid duration '$DUR' — use authentik form, e.g. 'days=7' or 'seconds=30'" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
|
||||
from django.apps import apps
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
dur = "$DUR"
|
||||
timedelta_from_string(dur) # validate the form before touching the row
|
||||
ULS = apps.get_model("authentik_stages_user_login", "UserLoginStage")
|
||||
s = ULS.objects.get(name="$STAGE")
|
||||
s.session_duration = dur
|
||||
s.save(update_fields=["session_duration"])
|
||||
back = ULS.objects.get(name="$STAGE").session_duration
|
||||
assert back == dur, "persisted value mismatch: %r" % back
|
||||
print("session_duration set to", repr(back), "->", timedelta_from_string(back))
|
||||
PYEOF
|
||||
|
||||
echo "Login-session TTL for '$STAGE' is now $DUR (applies on next login)."
|
||||
@@ -7,7 +7,7 @@
|
||||
# whichever TTS engine wins the engine decision (dankclaw/voiceservice/app.py
|
||||
# is a single stdlib-only file in a locally built image). Consumers — omp's
|
||||
# TTS device, OpenWebUI, open-notebook — address a VOICE BY NAME
|
||||
# (voice=narrator1, or the harness' voice_id=narrator1) and never see a path:
|
||||
# (voice=wilwheaton, or the harness' voice_id=wilwheaton) and never see a path:
|
||||
# a name is real exactly when /voices/<name>/reference.wav exists, and
|
||||
# anything else is a 400 that lists the names that are.
|
||||
#
|
||||
@@ -65,7 +65,7 @@ TTS_UPSTREAM_MODEL=voxcpm2
|
||||
# name still 400s with the list that exists — this covers the absent case only —
|
||||
# and the journal marks such a line `(default)` so an unrequested voice never
|
||||
# reads as a requested one. Any pack directory in /home/dank/voices is legal.
|
||||
DEFAULT_VOICE=narrator1
|
||||
DEFAULT_VOICE=wilwheaton
|
||||
|
||||
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
|
||||
# configured": the endpoint answers a 500 that says so instead of running
|
||||
|
||||
@@ -21,7 +21,7 @@ never learn a path.
|
||||
**Deployed 2026-10-18** — `./scripts/deploy.sh dankclaw --service frank-tts`
|
||||
both units `active`, no drop-ins, and the homepage card's `wip` gate came off in
|
||||
the same change. Probes on the deployed endpoint: `/health` answers
|
||||
`{"status":"ok","backend":"omni-http","voices":["narrator1","narrator2","narrator3"]}`
|
||||
`{"status":"ok","backend":"omni-http","voices":["dr.kern","garrickhagon","jeffhays","vaati","wilwheaton"]}`
|
||||
and two different voices rendered the same sentence into **different** bytes
|
||||
(24 kHz mono PCM16, 4.24 s, healthy RMS) — so name resolution is real, not a
|
||||
pass-through of one default. The operator harness routes its `speech` role here;
|
||||
@@ -42,8 +42,8 @@ render needs the app installed, not this endpoint.
|
||||
|
||||
## Naming a voice
|
||||
|
||||
A voice **is** a directory name. `voice=narrator1` resolves to
|
||||
`/voices/narrator1/reference.wav`, and that file — not a manifest, not a config
|
||||
A voice **is** a directory name. `voice=wilwheaton` resolves to
|
||||
`/voices/wilwheaton/reference.wav`, and that file — not a manifest, not a config
|
||||
entry — is what makes the name real. omp's TTS device may send `voice_id`; the
|
||||
shim accepts either. A call that sends **neither** — the harness's normal shape,
|
||||
because no harness setting carries a voice for this transport — speaks
|
||||
@@ -59,10 +59,11 @@ loudly instead of letting it half-work.
|
||||
|
||||
Names are user-visible: an unknown voice comes back as
|
||||
`400 unknown voice 'x'; available voices: …`, and that list is the honest
|
||||
census. The podcast speakers are `narrator1`, `narrator2`, `narrator3` —
|
||||
[[Open Notebook]] is configured with exactly those three, so renaming them
|
||||
breaks episode rendering, not just speech. A personal voice gets the person's
|
||||
name, lowercase.
|
||||
census. As of 2026-10-20 the live packs are `dr.kern`, `garrickhagon`,
|
||||
`jeffhays`, `vaati`, `wilwheaton`. [[Open Notebook]]'s `dank-duo` profile speaks
|
||||
`wilwheaton` (Frank) and `vaati` (Comrade), and `DEFAULT_VOICE` is `wilwheaton`.
|
||||
Renaming a pack that a speaker profile names breaks episode rendering, not just
|
||||
speech. A personal voice gets the person's name, lowercase.
|
||||
|
||||
## Voice packs
|
||||
|
||||
@@ -221,5 +222,5 @@ harness bundle and measured the day of the flip:
|
||||
| Endpoint answers, synthesis fails | the engine: `podman healthcheck run frank-tts-engine`, then `systemctl --user status frank-tts-engine.service` |
|
||||
| First request of the day is ~0.6 s slower than the rest — or every name returns 400 | the warm pass did not run; it carries `-`, so it cannot fail the unit. `journalctl --user -u frank-tts-engine` for the warmer's lines, then `podman exec frank-tts-engine /app/warm-voices.sh` — the registry is memory-only (law 1 of [[Frank TTS Engine]]) |
|
||||
| The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` |
|
||||
| A consumer rejects `narrator1` by listing 29 names (`af_heart … bm_fable`) and no request ever reaches this endpoint | that consumer is synthesizing **in the browser** with Kokoro.js, whose voice pack is fixed — OpenWebUI 0.11.4 ships it as the `browser-kokoro` TTS engine; see [[OpenWebUI]] |
|
||||
| A consumer rejects `wilwheaton` by listing 29 names (`af_heart … bm_fable`) and no request ever reaches this endpoint | that consumer is synthesizing **in the browser** with Kokoro.js, whose voice pack is fixed — OpenWebUI 0.11.4 ships it as the `browser-kokoro` TTS engine; see [[OpenWebUI]] |
|
||||
---
|
||||
|
||||
@@ -45,6 +45,8 @@ open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook))
|
||||
|
||||
The app's own login stays on behind the chain. It is not redundant: publishing the API on loopback does **not** hide it from this host's other containers, which can dial `open-notebook:5055` by name on `dankclaw_default`. The chain decides *who from outside* gets in; `OPEN_NOTEBOOK_PASSWORD` decides what anything that already reached the port may do.
|
||||
|
||||
**Single tenant — there are no user accounts, so this is an admin-side tool.** Upstream ships one global password and nothing else: `PasswordAuthMiddleware` compares `Bearer <OPEN_NOTEBOOK_PASSWORD>`, and the SurrealDB schema has no user/account/owner table (verified in the running image, 2026-10-20). One shared password therefore means **one shared workspace** — the guard chain can gate the door but cannot partition the data, and Authentik identity is never consumed. Multi-user is *approved but unbuilt* upstream (issue **#712**, "design phase, targeted post-v2"); OIDC/SSO was declined for now (**#607** closed). Real per-person isolation means **one deployment per person**, not a flag to flip. For that reason the homepage lists open-notebook under **admin**, not as a user-facing app.
|
||||
|
||||
## What the image actually runs
|
||||
|
||||
One container, three processes under supervisord (`supervisord.conf` upstream): `uvicorn api.main:app --port 5055` (`:12`), the job worker (`:25`), and the Next.js frontend (`:39`). Two consequences the config depends on:
|
||||
@@ -68,7 +70,7 @@ Credentials live in the database, encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`,
|
||||
| Model row `voxcpm2` | type `text_to_speech` on the frank-tts credential. The name is decorative: frank-tts ignores the request's `model` field and synthesizes whatever `voice` names ([[Frank TTS]]) |
|
||||
| Model row `frank-archon` | type `language`; `PUT /api/models/defaults` points chat, transformation and large-context at it |
|
||||
| Model row `qwen3-embedding-0.6b` | type `embedding` on the NPU-lane credential; `default_embedding_model` points at it, so every note save embeds through the XDNA2 lane ([[frank-dragoon]]) |
|
||||
| Speaker profile `dank-duo` | two speakers, Frank → `voice_id: narrator1`, Comrade → `narrator2`, `voice_model` = the TTS row |
|
||||
| Speaker profile `dank-duo` | two speakers, Frank → `voice_id: wilwheaton`, Comrade → `vaati`, `voice_model` = the TTS row |
|
||||
| Episode profile `dank-daily` | `num_segments: 4`, `speaker_config` = `dank-duo`, outline and transcript LLM = the archon row, briefing that ends "Never invent numbers." |
|
||||
|
||||
Order that works over REST: credentials → model rows (`POST /api/models` with `credential: <credential id>`) → `PUT /api/models/defaults` → speaker profile (`voice_model: <model id>`) → episode profile (`speaker_config` / `outline_llm` / `transcript_llm`). `POST /api/models/{id}/test` proves each row: the language row answered with a real completion from frank-archon; the TTS row reached frank-tts and came back `unknown voice 'alloy'; available voices: narrator1, narrator2, narrator3` — the app's own test path hard-codes an OpenAI voice, so that message *is* the proof the leg works.
|
||||
@@ -106,13 +108,13 @@ A **speaker profile** carries one to four speakers (`docs/3-USER-GUIDE/creating-
|
||||
|
||||
| Speaker | Voice ID to type |
|
||||
|---------|------------------|
|
||||
| Speaker 1 | `narrator1` |
|
||||
| Speaker 2 | `narrator2` |
|
||||
| Speaker 3 | `narrator3` |
|
||||
| Speaker 1 | `wilwheaton` |
|
||||
| Speaker 2 | `vaati` |
|
||||
| Speaker 3 | `dr.kern` |
|
||||
|
||||
Those are the pack names frank-tts resolves from `/home/dank/voices` (`scripts/frank-tts.env` header: a voice is real exactly when `/voices/<name>/reference.wav` exists). Two traps, both from upstream's shipped defaults:
|
||||
|
||||
- The three seeded speaker profiles carry **OpenAI voice names** (`nova`, `alloy`, …) and **no voice model at all** — they show a "Setup required" badge, and generation fails with a voice error until they are edited (`creating-podcasts.md:13`, `:33`; `local-tts.md:89`). Add a `narrator1`/`narrator2` pair (or a third/fourth) rather than editing the seeded ones in place.
|
||||
- The three seeded speaker profiles carry **OpenAI voice names** (`nova`, `alloy`, …) and **no voice model at all** — they show a "Setup required" badge, and generation fails with a voice error until they are edited (`creating-podcasts.md:13`, `:33`; `local-tts.md:89`). Add a `wilwheaton`/`vaati` pair (or a third/fourth) rather than editing the seeded ones in place.
|
||||
- Two speakers with the same voice are hard to follow (`creating-podcasts.md:89`). Distinct packs per speaker is the point of the 1–4 shape.
|
||||
|
||||
## Two buttons that fail against frank-tts, and why that is cosmetic
|
||||
|
||||
@@ -290,9 +290,12 @@ on large sizes ⇒ check ComfyUI resources; 401 ⇒ the token is bad.
|
||||
|
||||
## Text-to-speech (frank-tts)
|
||||
|
||||
TTS is the fleet's one OpenAI-compatible speech endpoint —
|
||||
`http://host.containers.internal:7772/v1` (`audio.tts.openai.api_base_url`
|
||||
in the config DB, 2026-10-18), model `frank-tts`, voice `narrator1`. That
|
||||
TTS is the fleet's one OpenAI-compatible speech endpoint. The config DB
|
||||
`audio.tts.*` is set to engine `openai`, `audio.tts.openai.api_base_url`
|
||||
`http://192.168.0.143:7772/v1`, `audio.tts.voice` `wilwheaton` (re-keyed
|
||||
2026-10-20 from the retired `narrator1`; 7772 is a published port so
|
||||
`host.containers.internal` also reaches it, but the LAN IP is the canonical
|
||||
on-host form). That
|
||||
port is the rootless quadlet `frank-tts` (this repo:
|
||||
`dankclaw/voiceservice/quadlet/frank-tts.container`
|
||||
`scripts/frank-tts.env`), and its shim resolves `voice=<name>` against
|
||||
@@ -306,8 +309,8 @@ ships an in-page Kokoro implementation (chunk carrying a frozen voice table
|
||||
`onnx-community/Kokoro-82M-v1.0-ONNX`, plus a personal *Kokoro.js dtype*
|
||||
setting). Its engine select takes `""` (Default) or `browser-kokoro`; when
|
||||
`browser-kokoro` is chosen the page synthesizes locally and rejects any
|
||||
voice outside its fixed pack — so `narrator1` fails with
|
||||
`Voice: "narrator1" not found. Should be one of: af_heart, …` **without a
|
||||
voice outside its fixed pack — so `wilwheaton` fails with
|
||||
`Voice: "wilwheaton" not found. Should be one of: af_heart, …` **without a
|
||||
single request reaching the server**. Personal audio settings are
|
||||
browser: no `user.settings` row and no container env carries an audio
|
||||
override, yet the toast still appears ⇒ the choice lives in that browser's
|
||||
|
||||
@@ -51,15 +51,41 @@ Promtail reached end-of-life on 2026-03-02 and Grafana Labs directs new installs
|
||||
|
||||
Alloy's UI listens on the container's own `127.0.0.1:12345` by default, and Alloy 1.x refuses to configure the `ui` service from a config block ("service ui does not support being configured", `shared/alloy/config.alloy:8-10`). Under compose that meant **no reachable health endpoint at all**: the homepage status card for alloy read `down` continuously and a deploy had nothing to wait on. The unit therefore passes `--server.http.listen-addr=0.0.0.0:12345` (`dankclaw/observability/quadlet/alloy.container:46`) and publishes `127.0.0.1:12345` (`alloy.container:47`, confirmed by `ss -ltnp` 2026-10-18) — same posture as the other three: reachable from the host and from Caddy's network, and from nothing off-host. Alloy has no authentication, which is exactly why it is not published wider. The homepage card probes that same loopback port (`shared/homepage/services.json:233`).
|
||||
|
||||
## Delivery state (2026-10-18): healthy, shipping nothing
|
||||
## Delivery state (2026-10-20): healthy, shipping nothing
|
||||
|
||||
The unit is `active`, the container reports `healthy`, the log says `Alloy is ready`, and the pipeline carries no data at all. Measured on 2026-10-18:
|
||||
The unit is `active`, the container reports `healthy`, the log says `Alloy is
|
||||
ready`, and the pipeline carries no data at all.
|
||||
|
||||
- `curl -s http://127.0.0.1:12345/metrics` → `loki_source_journal_target_lines_total{component_id="loki.source.journal.journal"} 0` and `loki_source_file_files_active_total{component_id="loki.source.file.containers"} 0`. The journal source logs no error; the counter simply never moves. The journal files it would have to read through the `/var/log` bind are `root:systemd-journal 0640` with an ACL (`ls -l /var/log/journal/64a287d7…/` 2026-10-18).
|
||||
- Both file globs were refused at start (`journalctl --user -u alloy`, 2026-10-18T08:25:27Z), verbatim: `failed to create source, skipping … component_id=loki.source.file.containers error="failed to tail file, stat failed: stat /var/lib/containers/storage/overlay-containers/*/userdata/ctr.log: permission denied"` and the same message for `/var/log/containers/*.log: no such file or directory`.
|
||||
- Downstream that means Loki holds zero streams — `GET /loki/api/v1/labels` returns `{"status":"success"}` with no `data` — see [[Loki]].
|
||||
**Root cause, proven: a rootless container cannot read the host journal.** The
|
||||
journal files are `root:systemd-journal 0640` (with `group:adm`, `other::---`).
|
||||
`/var/log` is bind-mounted so the files are *visible*, but a rootless container
|
||||
spawns with no supplementary groups, so it is denied. Proven directly:
|
||||
`podman run --rm -v /var/log/journal:/var/log/journal:ro busybox cat
|
||||
/var/log/journal/*/system.journal` → `DENIED`. `loki.source.journal` opens via
|
||||
`sd_journal_open`, so it silently sees an empty view — no error logged, counter
|
||||
frozen at `loki_source_journal_target_lines_total 0` (`system.journal` and
|
||||
`user-1000.journal` are both present and non-trivial, 2026-10-20).
|
||||
|
||||
No fix is claimed here: the unit, the config and the push endpoint are all correct as written, and the gap is source-side access (and a Kubernetes-style glob that does not exist on this host). Anyone closing it should re-check the two counters above, not the health status — health is exactly what hides this failure mode.
|
||||
**Second, independent gap:** the rootless Quadlet containers log to the *user*
|
||||
journal (`user-1000.journal`), and `loki.source.journal` reads the *system*
|
||||
journal only — a fixed system read would still miss them. Container stdout is
|
||||
not in a `ctr.log` here either: the file globs point at root storage
|
||||
(`/var/lib/containers/storage/.../ctr.log`, `/var/log/containers/*.log`) that
|
||||
rootless does not use, refused at start with `stat …: permission denied` / `no
|
||||
such file or directory`, `loki_source_file_files_active_total 0`.
|
||||
|
||||
Downstream: Loki holds zero streams (`GET /loki/api/v1/labels` → no `data`) —
|
||||
see [[Loki]].
|
||||
|
||||
**Closing it is a permission/architecture decision, not a config edit.** The
|
||||
unit, config and push endpoint are correct as written; rootless Podman simply
|
||||
cannot grant the journal group. The viable shapes: run Alloy as a *system*
|
||||
service (root, in `adm`/`systemd-journal`) instead of a rootless Quadlet; grant
|
||||
the container's mapped uid journal read via a tmpfiles/ACL rule; or have
|
||||
journald forward to a path the container can read. Each trades rootless posture
|
||||
for reach — decide deliberately. Re-check `loki_source_journal_target_lines_total`
|
||||
and the Loki label query to confirm a fix, never `/healthy`: health is exactly
|
||||
what hides this failure.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -267,6 +267,12 @@ The silent re-auth path was proven end-to-end: after deleting
|
||||
`qa-e2e-admin`'s rows, the e2e admin spec ran against the stale storage
|
||||
state and passed with no login prompt (2 tests, 2.9 s).
|
||||
|
||||
## Login-session lifetime (how often you re-authenticate)
|
||||
|
||||
How long a login stays valid is the **`default-authentication-login` stage's `session_duration`** (`UserLoginStage`) — not a global setting, and not `SESSION_COOKIE_AGE` (that is the *pre-login* session). Raised to **`days=7`** on 2026-10-20 (was stage value `0` = the 24 h framework default). It is an **absolute** window, never sliding: authentik implements `SESSION_SAVE_EVERY_REQUEST` nowhere, so activity does not extend it and no supported knob makes it one.
|
||||
|
||||
**`session_duration` is a STRING** in authentik's idiom (`"days=7"`, `"seconds=30"`, `"hours=12"`). A Python `timedelta` stores `"7 days, 0:00:00"`, which `authentik.lib.utils.time.timedelta_from_string` rejects with `ValueError` and login then crashes computing the expiry — so set it with **`scripts/authentik-set-login-session-duration.sh`** (validates the form first). Any non-zero value applies to *every* login (the "remember me" box only adds `remember_me_offset` on top) and a positive `set_expiry` also overrides `SESSION_EXPIRE_AT_BROWSER_CLOSE`, so closing the browser no longer logs you out; **existing sessions keep their old expiry** — delete the user's `authentik_core_session`/`…proxysession` rows and re-login to force it now. This is **DB state, not repo-managed**: a blueprint re-import or rebuild reverts the stage to `0`, so re-run the script after one. The other two login stages (`default-source-*`) drive external-source/enrollment logins and stay at the framework default.
|
||||
|
||||
## Guard-chain flip recipe (adding a guarded vhost)
|
||||
|
||||
Move a public vhost from `reverse_proxy <app>:<port>` onto the chain:
|
||||
|
||||
@@ -3,7 +3,7 @@ type: reference
|
||||
title: "Observability on Dankclaw"
|
||||
description: "The observability stack: what runs, how it is deployed, how it is exposed, and what is still unconfigured."
|
||||
tags: [observability, prometheus, grafana, loki, alloy, node-exporter, quadlet, dankclaw]
|
||||
timestamp: 2026-09-11
|
||||
timestamp: 2026-10-20
|
||||
---
|
||||
|
||||
# Observability on Dankclaw
|
||||
@@ -83,27 +83,36 @@ health endpoint (`/prometheus`: `/-/healthy`, `/alertmanager`: `/-/healthy`
|
||||
[[Observability Provisioning]] for what is provisioned as code and how to
|
||||
verify it through the APIs.
|
||||
|
||||
## Current state (2026-10-18)
|
||||
## Current state (2026-10-20)
|
||||
|
||||
* **Metrics: working.** `curl -s http://127.0.0.1:9090/api/v1/targets` reports
|
||||
eight up targets; the four downs are structural (the `comfyui` and `services`
|
||||
HTML probes, and Gitea's disabled metrics endpoint), not outages. Rule set:
|
||||
4 alert rules + 2 recording rules (`/api/v1/rules`, matching
|
||||
`shared/prometheus/rules/llm-fleet.yml`).
|
||||
eight up targets — the three LLM endpoints (frank-archon on `vllm:*`,
|
||||
frank-dragoon and frank-zealot on `llamacpp:*`), grafana, loki, prometheus
|
||||
and both node exporters. The four downs are structural (the `comfyui` and
|
||||
`services` HTML probes, and Gitea's disabled metrics endpoint), not outages.
|
||||
Two rule groups in `shared/prometheus/rules/llm-fleet.yml`: `llm-fleet`
|
||||
(2 recording + 4 alert, `llamacpp:*`) and `vllm-fleet` (1 recording + 4
|
||||
alert, `vllm:*` — archon queueing, prefix collapse, preemption, p95
|
||||
time-to-first-token), each keyed only to the namespace its runtime exports.
|
||||
* **Delivery: working.** Alertmanager's single `signal` receiver webhooks the
|
||||
relay, and `journalctl --user -u signal-relay` records successful
|
||||
`sent to +12062476336:` lines for every rule in the set.
|
||||
* **Dashboards: working.** One provisioned Prometheus datasource (uid
|
||||
`prometheus`) and the `llm-fleet` dashboard, both installed from
|
||||
`shared/grafana/provisioning/`.
|
||||
* **Logs: configured, empty.** Alloy ships zero lines (journal counter 0, both
|
||||
file globs refused) and Loki therefore holds zero streams — `/opt/loki/data`
|
||||
is a few hundred KB of WAL/compactor bookkeeping with **no chunk files**
|
||||
(8 files total by `find`, 2026-10-18) and `GET /loki/api/v1/labels` returns
|
||||
no label names.
|
||||
See [[Alloy]] → Delivery state and [[Loki]] → Contents.
|
||||
* **Not configured:** no Loki datasource in Grafana, no blackbox exporter (the
|
||||
structural downs have no real uptime signal), no Alertmanager vhost.
|
||||
* **Dashboards: working.** Provisioned datasources — Prometheus (uid
|
||||
`prometheus`) and Loki (uid `loki`) — and the `llm-fleet` dashboard, all
|
||||
installed from `shared/grafana/provisioning/`. The Loki datasource is how
|
||||
you read logs: Loki has no web UI of its own, so Grafana Explore is the
|
||||
human surface (the guarded `loki.franksdank.cloud` vhost serves only the API).
|
||||
* **Logs: configured, empty.** Alloy ships zero lines and Loki holds zero
|
||||
streams (`GET /loki/api/v1/labels` returns no label names; no chunk files on
|
||||
disk). The cause is source-side access, now proven, not a config bug: a
|
||||
rootless container cannot read the journal files (`system.journal` is
|
||||
`root:systemd-journal 0640`/`adm` and a rootless read of it returns DENIED),
|
||||
and the container-log globs point at a root-storage path that does not exist
|
||||
under rootless. Closing it is a permission/architecture decision, not an
|
||||
edit — see [[Alloy]] → Delivery state and [[Loki]] → Contents.
|
||||
* **Not configured:** no blackbox exporter (the structural downs have no real
|
||||
uptime signal) and no Alertmanager vhost. The log pipeline is provisioned
|
||||
but carries no data (see *Logs* above).
|
||||
|
||||
## Citations
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Grafana datasource for Loki, declared in git rather than clicked into
|
||||
# grafana.db — same reason as the Prometheus datasource: a UI-configured
|
||||
# datasource is invisible to review, lost with the data volume, and unknown
|
||||
# to the repository. Loki OSS ships no web UI of its own, so Explore through
|
||||
# this datasource is the only way a human reads the log store.
|
||||
#
|
||||
# URL is the container name on dankclaw_default (the same address Caddy
|
||||
# proxies to), not the published loopback port.
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Loki
|
||||
uid: loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
editable: false
|
||||
@@ -30,9 +30,9 @@
|
||||
},
|
||||
{
|
||||
"name": "open-notebook",
|
||||
"category": "user",
|
||||
"category": "admin",
|
||||
"section": "AI",
|
||||
"description": "Podcast generator — notes in, spoken episode out: script written by frank-zealot, voices rendered by frank-tts. Guarded twice: Authentik at the edge, then its own login.",
|
||||
"description": "Podcast generator — notes in, spoken episode out: script written by frank-archon, voices rendered by frank-tts. Guarded twice: Authentik at the edge, then its own login. Single shared password, no per-user accounts — admin-only.",
|
||||
"url": "http://notebook.franksdank.cloud",
|
||||
"docs": "",
|
||||
"probe": "127.0.0.1:8502",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# Alerting and recording rules for the LLM fleet.
|
||||
#
|
||||
# One metric namespace: llamacpp:* — frank-zealot and frank-dragoon, both
|
||||
# llama.cpp with /metrics (the family verified from /metrics on 2026-09-16
|
||||
# and 2026-09-20). frank-archon LEFT the family 2026-10-05: FreeToken (the
|
||||
# PR587 ROCm engine) has no /metrics endpoint, so its scrape job was
|
||||
# deleted rather than left never-green.
|
||||
# Two metric namespaces, because two runtime families are scraped:
|
||||
#
|
||||
# * llamacpp:* — frank-zealot (llama.cpp) and frank-dragoon (halogen, which
|
||||
# re-exports the llama.cpp metric names). Verified from /metrics on
|
||||
# 2026-09-16 / 2026-09-20.
|
||||
# * vllm:* — frank-archon, the radiance engine on Dankserver since
|
||||
# 2026-10-05. It exports vLLM-compatible names, a DIFFERENT namespace, so
|
||||
# it is a separate scrape job in prometheus.yml and the rules below are a
|
||||
# separate group. (The old claim that its scrape job was deleted is false:
|
||||
# it re-joined the day it left the FreeToken engine and is `up` today.)
|
||||
# (The netavark published-port quirk that forces the Dankclaw endpoints
|
||||
# onto the gateway is same-host only.)
|
||||
#
|
||||
@@ -22,7 +27,8 @@
|
||||
# cached by cached + prompt.
|
||||
# * Speculative decoding: drafts and accepted tokens are both counters,
|
||||
# so LlmSpecDecodeOff watches the pair.
|
||||
# * No latency histograms exist, so there is no p95 alert.
|
||||
# * No latency histograms in llama.cpp, so no p95 alert there; radiance's
|
||||
# vllm:* namespace DOES export one, so the vllm-fleet group alerts on it.
|
||||
#
|
||||
# IMPORTANT: delivery. Since 2026-09-17 a firing alert reaches Frank on
|
||||
# Signal: Prometheus pushes to Alertmanager (dankclaw/observability/quadlet/
|
||||
@@ -112,6 +118,84 @@ groups:
|
||||
description: >-
|
||||
Prometheus cannot reach the LLM endpoint. frank-zealot and
|
||||
frank-dragoon are root units on Dankclaw: `systemctl is-active
|
||||
frank-zealot` / `frank-dragoon`. Both scraped endpoints export
|
||||
the llamacpp:* family. (frank-archon left the scrape set
|
||||
2026-10-05 — FreeToken exports no /metrics.)
|
||||
frank-zealot` / `frank-dragoon` (both export llamacpp:* —
|
||||
dragoon via halogen's llama.cpp-compatible names). frank-archon
|
||||
is a radiance endpoint on Dankserver (`systemctl show
|
||||
frank-archon` there) exporting the vllm:* family; the
|
||||
`frank-.*` selector covers all three regardless of namespace.
|
||||
|
||||
# frank-archon (radiance) exports the vLLM-compatible namespace, NOT
|
||||
# llamacpp:*. Every rule here is keyed to a series the exporter actually
|
||||
# emits (verified from /metrics 2026-10-20: num_requests_waiting,
|
||||
# gpu_prefix_cache_hit_rate, num_preemptions_total, prompt_tokens_total,
|
||||
# time_to_first_token_seconds). The llamacpp rules above evaluate nothing for
|
||||
# this job and these evaluate nothing for llama.cpp/halogen — two clean
|
||||
# namespaces, each keyed only to what its runtime exports.
|
||||
- name: vllm-fleet
|
||||
interval: 30s
|
||||
rules:
|
||||
# p95 time-to-first-token, shared with the dashboard. When the endpoint
|
||||
# is idle every bucket rate is zero, histogram_quantile yields NaN, and
|
||||
# neither this nor the alert below can fire on an idle engine.
|
||||
- record: job:vllm_ttft_p95:rate5m
|
||||
expr: |
|
||||
histogram_quantile(0.95, sum by (job, le)
|
||||
(rate(vllm:time_to_first_token_seconds_bucket[5m])))
|
||||
|
||||
# Radiance exports a real queue-depth gauge (llama.cpp only shows the
|
||||
# wall as requests_deferred). --max-num-seqs is small, so a sustained
|
||||
# wait is either real load or this endpoint carrying the fleet alone.
|
||||
- alert: VllmRequestsQueueing
|
||||
expr: vllm:num_requests_waiting > 0
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "{{ $labels.job }}: requests waiting for 10 minutes"
|
||||
description: >-
|
||||
The radiance scheduler has requests queued. Either the fleet is
|
||||
genuinely busy or this endpoint is carrying it alone.
|
||||
|
||||
# Radiance reports prefix reuse directly as a hit-rate gauge — no
|
||||
# counter division needed. The token-rate floor keeps an idle engine
|
||||
# (hit rate 0 with no traffic) from firing.
|
||||
- alert: VllmPrefixCacheCollapse
|
||||
expr: >-
|
||||
vllm:gpu_prefix_cache_hit_rate < 0.20
|
||||
and rate(vllm:prompt_tokens_total[5m]) > 0.05
|
||||
for: 15m
|
||||
labels:
|
||||
severity: info
|
||||
annotations:
|
||||
summary: "{{ $labels.job }}: prefix hit rate below 20 % for 15 minutes"
|
||||
description: >-
|
||||
Nearly every request prefilling from scratch. With several large
|
||||
contexts in flight this is the capacity wall, not a routing fault.
|
||||
|
||||
# Preemption is the KV-thrash signature radiance reports that llama.cpp
|
||||
# cannot: the engine evicts a running request to admit another. Any
|
||||
# sustained rate means the KV cache cannot hold the concurrency in flight.
|
||||
- alert: VllmPreempting
|
||||
expr: rate(vllm:num_preemptions_total[5m]) > 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "{{ $labels.job }}: engine preempting requests"
|
||||
description: >-
|
||||
The engine is evicting running requests to admit new ones — the KV
|
||||
cache is undersized for the concurrency in flight.
|
||||
|
||||
# p95 first-token latency, kept HIGH on purpose: a large-prompt prefill
|
||||
# legitimately spends many seconds before token 1, so this fires only on
|
||||
# a wedged scheduler or a queue backed up behind one request.
|
||||
- alert: VllmTtftSlow
|
||||
expr: job:vllm_ttft_p95:rate5m > 120
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "{{ $labels.job }}: p95 time-to-first-token over 120 s"
|
||||
description: >-
|
||||
First-token latency is pathological, not an honest big prefill.
|
||||
Likely a wedged scheduler or one request stalling the queue.
|
||||
|
||||
@@ -313,33 +313,33 @@ PAIRS
|
||||
done
|
||||
}
|
||||
|
||||
@test "prometheus scrapes every LLM endpoint that exports metrics" {
|
||||
# The LLM endpoints are the services whose behaviour this fleet argues about
|
||||
# most (KV pressure, prefix-cache reuse, queue depth). Both Dankclaw
|
||||
# endpoints export the llamacpp:* family. frank-archon LEFT the set
|
||||
# 2026-10-05: the FreeToken PR587 ROCm engine has NO /metrics endpoint —
|
||||
# scraping it was a structural never-green (404), so the job was deleted
|
||||
# rather than left vacuous (the Lucebox DS4.1 precedent, 2026-09-26).
|
||||
# The two Dankclaw endpoints are HOST processes reached through
|
||||
# host.containers.internal: this Prometheus is a bridge-networked
|
||||
# container, and on this netavark host a container can only reach the
|
||||
# host's LAN IP at a PUBLISHED port — a host-process bind like
|
||||
# llama-server's 0.0.0.0:7770 is refused at 192.168.0.143:7770 while it
|
||||
# answers at 169.254.1.2:7770 (why frank-zealot stayed red by construction
|
||||
# for days until the 2026-09-20 /targets audit).
|
||||
@test "prometheus scrapes every LLM endpoint, in its real metric namespace" {
|
||||
# Every LLM endpoint that exports /metrics must have a scrape job. As of
|
||||
# 2026-10-05 there are TWO runtime namespaces scraped:
|
||||
# * llamacpp:* — frank-zealot (llama.cpp) and frank-dragoon (halogen,
|
||||
# re-exporting the llama.cpp names), both root host processes on
|
||||
# Dankclaw reached via host.containers.internal — this Prometheus is a
|
||||
# bridge container and a host-process bind is refused at the LAN IP,
|
||||
# which is why frank-zealot read red by construction for days.
|
||||
# * vllm:* — frank-archon (radiance) on Dankserver, a different machine,
|
||||
# so plain LAN routing reaches 192.168.0.235:7770. It RE-JOINED the
|
||||
# scrape set with radiance; the old "job deleted, exports no /metrics"
|
||||
# note belonged to the retired FreeToken engine and is false today, so
|
||||
# this seam now asserts archon IS scraped (a bare `! grep` here was
|
||||
# set -e-exempt and never enforced the opposite).
|
||||
local cfg="$REPO_ROOT/shared/prometheus/prometheus.yml"
|
||||
grep -q '"host.containers.internal:7771"' "$cfg" || { echo "frank-zealot not scraped"; return 1; }
|
||||
grep -q '"host.containers.internal:7770"' "$cfg" || { echo "frank-dragoon not scraped"; return 1; }
|
||||
! grep -q '"192.168.0.235:7770"' "$cfg"
|
||||
grep -q '"host.containers.internal:7770,192.168.0.235:7770"' "$cfg" \
|
||||
&& { echo "frank-archon folded into a Dankclaw gateway target"; return 1; }
|
||||
for job in frank-zealot frank-dragoon; do
|
||||
grep -q '"192.168.0.235:7770"' "$cfg" || { echo "frank-archon not scraped"; return 1; }
|
||||
for job in frank-zealot frank-dragoon frank-archon; do
|
||||
grep -q "job_name: \"$job\"" "$cfg" || { echo "no scrape job for $job"; return 1; }
|
||||
done
|
||||
! grep -q 'job_name: "frank-archon"' "$cfg"
|
||||
# None of them is reached through a guarded vhost: Prometheus cannot solve
|
||||
# an Anubis challenge, so a franksdank.cloud target is red by construction.
|
||||
! grep -qE '^\s*-\s*"?https?://[^"]*franksdank' "$cfg"
|
||||
# No LLM endpoint is reached through a guarded vhost: Prometheus cannot
|
||||
# solve an Anubis challenge, so a franksdank.cloud target is red by
|
||||
# construction.
|
||||
grep -qE '^[[:space:]]*-[[:space:]]*"?https?://[^"]*franksdank' "$cfg" \
|
||||
&& { echo "a scrape target points at a guarded vhost"; return 1; }
|
||||
true
|
||||
}
|
||||
|
||||
@test "grafana provisioning is declared in git and installed by the deployer" {
|
||||
@@ -378,28 +378,44 @@ names = [r.get('alert') or r.get('record') for g in d['groups'] for r in g['rule
|
||||
for want in ('job:llm_prefix_cache_reuse:rate5m',
|
||||
'job:llm_spec_accept_length:rate5m',
|
||||
'LlmRequestsQueueing', 'LlmPrefixCacheCollapse',
|
||||
'LlmSpecDecodeOff', 'LlmEndpointDown'):
|
||||
'LlmSpecDecodeOff', 'LlmEndpointDown',
|
||||
'job:vllm_ttft_p95:rate5m', 'VllmRequestsQueueing',
|
||||
'VllmPrefixCacheCollapse', 'VllmPreempting', 'VllmTtftSlow'):
|
||||
assert want in names, want
|
||||
# Every rule must be keyed to a series that actually exists. A rule naming a
|
||||
# series nobody exports evaluates nothing forever and errors never — the
|
||||
# failure this seam exists for. Since the 2026-09-26 cutover the only scraped
|
||||
# LLM runtime is llama.cpp (frank-zealot, frank-dragoon); the vllm: family left
|
||||
# with frank-medium's radiance runtime and must not survive in any expr.
|
||||
# failure this seam exists for. Two runtimes are scraped in two namespaces:
|
||||
# llama.cpp/halogen (llamacpp:*) for frank-zealot and frank-dragoon, and
|
||||
# radiance (vllm:*) for frank-archon. Both families were read straight off the
|
||||
# endpoints' /metrics; anything else is a guess.
|
||||
exprs = [r.get('expr', '') for g in d['groups'] for r in g['rules']]
|
||||
# llama.cpp families measured from /metrics on 2026-09-16 and 2026-09-20
|
||||
# (frank-zealot, frank-dragoon). Anything else is a guess.
|
||||
live = {'llamacpp:prompt_tokens_total', 'llamacpp:prompt_tokens_cached_total',
|
||||
'llamacpp:requests_deferred', 'llamacpp:requests_processing',
|
||||
'llamacpp:spec_decode_num_drafts_total',
|
||||
'llamacpp:spec_decode_num_accepted_tokens_total',
|
||||
'llamacpp:spec_decode_num_draft_tokens_total',
|
||||
'llamacpp:tokens_predicted_total',
|
||||
'llamacpp:predicted_tokens_seconds', 'llamacpp:prompt_tokens_seconds',
|
||||
'llamacpp:n_decode_total', 'llamacpp:n_busy_slots_per_decode'}
|
||||
live = {
|
||||
# llama.cpp / halogen, measured from /metrics 2026-09-16 and 2026-09-20.
|
||||
'llamacpp:prompt_tokens_total', 'llamacpp:prompt_tokens_cached_total',
|
||||
'llamacpp:requests_deferred', 'llamacpp:requests_processing',
|
||||
'llamacpp:spec_decode_num_drafts_total',
|
||||
'llamacpp:spec_decode_num_accepted_tokens_total',
|
||||
'llamacpp:spec_decode_num_draft_tokens_total',
|
||||
'llamacpp:tokens_predicted_total',
|
||||
'llamacpp:predicted_tokens_seconds', 'llamacpp:prompt_tokens_seconds',
|
||||
'llamacpp:n_decode_total', 'llamacpp:n_busy_slots_per_decode',
|
||||
# radiance (frank-archon), measured from /metrics 2026-10-20.
|
||||
'vllm:num_requests_waiting', 'vllm:gpu_prefix_cache_hit_rate',
|
||||
'vllm:num_preemptions_total', 'vllm:prompt_tokens_total',
|
||||
'vllm:time_to_first_token_seconds_bucket',
|
||||
}
|
||||
used = {m for e in exprs
|
||||
for m in re.findall(r'(?:llamacpp|vllm):[a-z_0-9]+', e)}
|
||||
assert used <= live, used - live
|
||||
assert not any(':vllm' in e for e in exprs), 'vllm: series in rules but no vllm endpoint is scraped'
|
||||
# The two namespaces must not bleed across groups: a llamacpp: selector
|
||||
# evaluates nothing for the radiance job and vice versa, so each group may
|
||||
# reference only its own family.
|
||||
per = {g['name']: {m.split(':')[0] for r in g['rules']
|
||||
for m in re.findall(r'(?:llamacpp|vllm):[a-z_0-9]+', r.get('expr', ''))}
|
||||
for g in d['groups']}
|
||||
for name, fams in per.items():
|
||||
allowed = {'llamacpp'} if name == 'llm-fleet' else {'vllm'}
|
||||
assert fams <= allowed, (name, fams)
|
||||
print('rules ok:', len(names))
|
||||
"
|
||||
# A rule file that nothing loads evaluates nothing, and Prometheus reports
|
||||
|
||||
@@ -290,7 +290,9 @@ vhost = sys.argv[2]
|
||||
cards = [s for s in data if s["name"] == "open-notebook"]
|
||||
assert len(cards) == 1, f"expected exactly one open-notebook card, got {len(cards)}"
|
||||
card = cards[0]
|
||||
assert card["category"] == "user" and card["section"] == "AI", card
|
||||
# category moved to "admin": single shared password, no per-user accounts, so it
|
||||
# is positioned as an admin-side tool, not a user-facing app (see the vault page).
|
||||
assert card["category"] == "admin" and card["section"] == "AI", card
|
||||
assert card["probe"] == "127.0.0.1:8502", card["probe"]
|
||||
assert card["method"] == "http"
|
||||
# Deployed and guarded: the card is live and its link is the vhost, so the
|
||||
|
||||
Reference in New Issue
Block a user