feat(admin): bridge-avatar cache diagnostic page (LC-207) #273
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-207-bridge-avatar-admin-diagnostics"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
A read-only
/admin/bridges/avatarspage that answers "why is this bridged user showing initials" without dropping to SQL: a stats header (cached count + per-status ok/pending/failed, bytes on disk, oldest last-seen, stale-pending anomaly counter) plus the last 50 failed fetches withfailure_reasonand foreign host. Reached via an "Avatar cache" link on/admin/bridges.Hybrid mirror, not a 1:1 copy
The cited precedent (
/admin/outgoing-webhooks/{id}/deliveries) is a per-entity drill-in; the avatar cache is global, keyed byhash, owned by no bridge. So the page takes the table / inline-empty-state / read-only / static-on-load / AdminUser bones from deliveries, and the nav-reachable full-page (load_chrome) shape from the bridges/bots pages. Four diffs from the precedent, each forced by that data-model difference and named rather than silently diverged:Schema reality drives everything time-based
bridge_avatar_proxieshas nocreated_at, nofailed_at;fetched_atis NULL on pending/failed; onlylast_seen_atis non-null (insert time, bumped to now on every reference). So the failures table orders bylast_seen_at DESCand "oldest" isMIN(last_seen_at), labeled honestly as "oldest last-seen" (the schema carries no creation timestamp). That column is also GC-aligned (the unreferenced sweep deletes by it) and surfaces the page's actual subjects first: a recently-referenced failure is an avatar users are actively hitting that renders as initials. Ordering by a non-existentfailed_at, or byfetched_at(NULL on exactly the failed rows the page is about), would both have been subtle bugs.Decisions
title=. An unparseableforeign_urlrenders a literal<unparseable>sentinel (a truncated raw string would read like a host and hide the anomaly; the raw value still rides in the tooltip). Host-only is a summary affordance, not a secret: admin already has DB access, and the migration's "URL not in rendered HTML" side-channel concern targets room-viewer message HTML, not an admin-only page.last_seen_atis insert-time and only bumped forward, so the counter can't over-count from an ancient reference. The two sweep constants are a bare literal arg + a function-local const, so a parity meta-test would need plumbing both out; the derivation lives in a re-derive-if-constants-change doc comment.LC-78-AVATAR-REFRESHfollow-up.Implementation
db::bridge_avatar_proxies::cache_stats(single-query conditional-SUM aggregate;COALESCE(SUM(byte_size),0)naturally sums ok rows only) +recent_failures, both covered by the existing(fetch_status, last_seen_at)index. No new migration. i18n keys added to en + es (thei18n_catalogparity test enforces full coverage). The whole route surface is standalone-only, so the route test is#![cfg(feature = "standalone")]gated.Test plan
recent_failuresfilters + orders;cache_statscounts/sums/stale-threshold; empty-cache zeros +Noneoldest; a 30-min pending row is NOT stale (inside the threshold).Nonediscriminant leak); failed row shows host in cell + full URL in tooltip; unparseable URL shows the<unparseable>sentinel with raw value in tooltip (pins the sentinel against a future truncate-fallback refactor); stale-pending anomaly banner appears.just check+just build-cssclean; full suite green both modes (standalone + saas, 129 binaries each, zero failures).Out of scope (held)
No retry/clear/delete actions, no new migration, no
created_at/failed_atcolumn add, no generic cache-diagnostics framework, no new layout, no top-nav entry.🤖 Generated with Claude Code
Adds a read-only /admin/bridges/avatars page answering "why is this bridged user showing initials" without dropping to SQL: a stats header (cached count + per-status ok/pending/failed breakdown, bytes on disk, oldest last-seen, and a stale-pending anomaly counter) plus the last 50 failed fetches with failure_reason and foreign host. Reached via an "Avatar cache" link on /admin/bridges. Hybrid mirror, not a 1:1 copy. The cited precedent (/admin/outgoing-webhooks/{id}/deliveries) is a per-entity drill-in; the avatar cache is global, keyed by hash, owned by no bridge. So the page borrows the table / inline-empty-state / read-only / static-on-load / AdminUser shape from deliveries, but the nav-reachable full-page (load_chrome) shape from the bridges/bots pages. Four diffs from the precedent, each forced by that data-model difference and called out rather than silently diverged: (1) global page not per-entity drill-in; (2) a net-new stats header the deliveries page has no precedent for; (3) host-only-with-full-URL-tooltip redaction, a fresh choice since the deliveries page shows no URL at all; (4) failures-only filter rather than all-rows. Schema reality drives everything time-based. bridge_avatar_proxies has no created_at and no failed_at; fetched_at is NULL on pending/failed rows; only last_seen_at is non-null (insert time, bumped to now on every reference). So the failures table orders by last_seen_at DESC and the "oldest" stat is MIN(last_seen_at) - honestly labeled "oldest last-seen", not "oldest entry", because the schema carries no creation timestamp. last_seen_at is also the GC-aligned column (the unreferenced sweep deletes by it) and surfaces the page's actual subjects first: a recently-referenced failure is an avatar users are actively hitting that renders as initials. Ordering by a non-existent failed_at, or by fetched_at (NULL on the failed rows the page is entirely about, silently excluding them), would both have been subtle bugs; verifying the schema before mirroring caught it. Host redaction: foreign_url renders host-only in the scannable cell with the full URL in a title= tooltip (one hover away, in-DOM for copy). An unparseable foreign_url renders a literal <unparseable> sentinel rather than a truncated raw string, which would visually read like a host and hide the anomaly. Host-only is a summary affordance, not a secret: admin already has DB access, and the migration's "URL must not appear in rendered HTML" side-channel concern targets room-viewer message HTML seen by every viewer, not an admin-only diagnostic page. Stale-pending counter: pending rows older than 4200s by last_seen_at. Derived as the sweep's pending threshold (600s) plus one sweep interval (3600s, spawn_orphan_sweeper's hourly tick), so the counter means "a full sweep opportunity passed without flipping this to failed" (sweeper broken / restart-looping) rather than normal sweep lag. last_seen_at is set at insert and only ever bumped forward, so the counter cannot over-count from an ancient reference. The two sweep constants are a bare literal arg and a function-local const, so a parity meta-test would require plumbing both out; the derivation lives in a re-derive-if-constants-change doc comment instead. Read-only by design, matching both the deliveries precedent and the LC-78 v2 "failed is terminal, render-initials-forever" posture. The retry argument is weaker for avatars than webhooks (a webhook redelivery can still land useful; an avatar retry only helps if the foreign homeserver itself recovered, which v2 does not track), so the precedent's lack of a retry action propagates for a principled reason, not just convenience. Retry-failed is the already-named LC-78-AVATAR-REFRESH follow-up. New db helpers (db::bridge_avatar_proxies::cache_stats single-query conditional-SUM aggregate + recent_failures), both covered by the existing (fetch_status, last_seen_at) index; no new migration. i18n keys added to en + es (the i18n_catalog parity test enforces full coverage). The whole route surface is standalone-only (routes::admin is), so the route test is file-scope #![cfg(feature = "standalone")] gated. Tests: db-layer (recent_failures filters + orders; cache_stats counts/sums/stale-threshold; empty-cache zeros + None oldest; 30-min pending is not stale); route-layer (empty-state renders with no None discriminant leak; failed row shows host in cell + full URL in tooltip; unparseable URL shows the <unparseable> sentinel with raw value in tooltip - pins the sentinel choice against a future truncate-fallback refactor; stale-pending anomaly banner appears). Validation: just check + just build-css clean; full suite green both modes (standalone and saas, 129 binaries each, zero failures). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>