Newer qBittorrent renamed its session cookie from "SID" to "QBT_SID" /
"QBT_SID_<port>" (the diagnostic revealed cookies=['QBT_SID_5080']). The
client only accepted "SID", so a valid login (cookie present in the jar) was
reported as "Unexpected response". Re-entering correct credentials never
helped because login was succeeding all along.
Treat any cookie named "SID" OR starting with "QBT_SID" as the session
cookie, checked in both the parsed jar and the raw Set-Cookie header.
qBittorrent only sets this cookie on a valid login, so it stays authoritative.
Diagnostic message updated to mention both names.
New regression test covers the QBT_SID_<port> case. 388/388 backend tests
pass; ruff clean.
qBittorrent (or its reverse proxy) was returning 204 No Content with an SID
cookie and no body on a successful login, but the client only treated the
response as success if body == "Ok." or the cookie was in the parsed
requests cookie jar (resp.cookies.get("SID")). In the reported case the
Set-Cookie header was present (set-cookie=yes) yet the jar was empty —
requests doesn't always populate the jar from such headers (proxy-set/oddly-
attributed cookies) — so a valid login was reported as "Unexpected response".
The user re-entering correct credentials never helped.
Detect a successful login from EITHER the parsed jar OR the raw Set-Cookie
header (cookie name == SID). qBittorrent only sets SID on a valid login, so
this remains authoritative. The diagnostic now lists the cookie names it saw
for future-proofing.
New regression test reproduces the exact 204 + Set-Cookie SID + empty jar
case. 387/387 backend tests pass; ruff clean.
When the login endpoint returns an unexpected response (e.g. a 204 No Content
with no body), the diagnostic now reports whether a Set-Cookie header was
present. That single fact tells us whether qBittorrent attempted to establish
a session at all — distinguishing "qBittorrent answered weirdly" from
"something in the proxy path answered before qBittorrent" (e.g. a 204 from a
misrouted reverse proxy), which is the key clue when diagnosing login failures
behind a proxy.
42/42 qBittorrent + credential-tester tests pass; ruff clean.
The credential tester always reported "Authentication failed — qBittorrent
rejected the credentials" for the qBittorrent service, even when credentials
were correct. test_connection classified any RuntimeError whose message
contained "login failed" as an auth failure — and the gateway-timeout error
(502/503/504 from the reverse proxy) and the wrong-URL diagnostic both started
with "qBittorrent login failed:", so a proxy timeout was reported as a
credentials rejection. That sent users down the wrong path (re-entering correct
passwords to fix a 504).
- QbittorrentClient._login: gateway and URL/routing errors no longer contain
"login failed"; only a genuine "Fails." body carries the
"invalid username or password" signal.
- integrations/qbittorrent.test_connection: key the auth message off
"invalid username or password" specifically; all other login errors flow
through translate_connection_error so the real reason (proxy timeout, wrong
URL, empty body) is surfaced.
After this, a failing test reports the actual cause (e.g. "qBittorrent is
unreachable: reverse proxy returned HTTP 504 ...") instead of accusing the
credentials. New regression test asserts a gateway error is NOT reported as
"Authentication failed". 386/386 backend tests pass; ruff clean.
The gauge rendered as multiple black rings. Two causes:
1. recharts RadialBarChart draws each data entry as a CONCENTRIC RING, not an
arc segment, so the 3 "track band" entries + value produced 4 nested rings.
Render a single value arc over a neutral background track instead, colored
by status, with the readout absolutely centered (replacing the -mt-12 hack).
2. The fills used hsl(var(--primary)) / hsl(var(--chart-1)) etc., but this
project's Tailwind v4 theme (index.css) defines colors as --color-* holding
full hex values (--color-primary: #4f8cff). So the references were doubly
invalid (wrong name + hsl() wrapping a hex) -> invalid SVG fill defaults to
black. Use var(--color-*) directly, with the semantically correct chart
colors: ok=--color-chart-2 (green), warn=--color-chart-3 (amber),
crit=--color-chart-4 (red).
Also fix the same hsl(var(--x)) -> var(--color-x) bug in LineSeriesChart's
tooltip contentStyle (popover/border/popover-foreground). The line stroke
palette already used the correct var(--color-chart-N) form.
166/166 frontend tests pass; typecheck + ESLint clean.
QbittorrentWidgetSource built a brand-new QbittorrentClient on every fetch,
logging in each time. With three qBittorrent widgets polling every 5-30s and
qBittorrent verifying passwords with slow PBKDF2 hashing, the concurrent login
load saturates its web thread pool and the reverse proxy returns 504 gateway
timeouts on /api/v2/auth/login. The client was already designed for reuse
(login once, SID cookie reuse, 403 re-login) — the source just wasn't using it.
Cache one QbittorrentClient per service (lru_cache keyed by service id, URL,
credentials, timeout) so the SID cookie persists across fetches and login
happens once. Mirrors dependencies._jellyfin_client_for. A credentials/URL
change produces a new cache key, so stale clients aren't reused after
reconfiguration.
Also surface 502/503/504 from the login as a clear "reverse proxy returned
HTTP <code> ... qBittorrent may be down/starting/overloaded" RuntimeError
instead of a bare HTTPError, so future gateway issues read as infrastructure,
not auth.
Tests: autouse fixture clears the client cache between tests; new gateway-error
login test. 385/385 backend tests pass; ruff clean.
Settings.tsx: ServiceConfigEditor derived editable state (name, config,
secrets) from the instance prop via useState, but the parent rendered it
without a key. Switching services in the rail reused the same component, so
name/config stayed pinned to the previously selected service while
instance.id/service_type (read live from props) pointed at the new one —
saving then wrote the stale values onto the wrong row (e.g. saving qBittorrent
renamed it "Jellyfin" with Jellyfin's URL). Add key={selectedService.id} so
the editor remounts and resets on switch.
ServicePage: add a Settings shortcut in the header that deep-links to
/settings?tab=services&service=<id>. Settings now reads tab + service query
params (useSearchParams) to open the Services tab with that service
pre-selected, via a new initialServiceId prop on ServicesAdminCard.
Tests: new Settings.services.test.tsx regression test (fails without the key,
passes with it); wrap existing Settings tests in MemoryRouter since Settings
now uses useSearchParams. 166/166 frontend tests pass; typecheck + ESLint clean.
Jellyfin: get_user_id() returned the configured user_id verbatim, so a
username like "admin" hit /Users/admin/Views and got HTTP 400 ("The value
'admin' is not valid."). The index worker already had username->Id
resolution, but the live API paths (dashboard counts, media query) did not.
Route all user-scoped paths through the new JellyfinClient.resolve_user_id()
(exact Id match -> Name match -> first user), cached per service/credentials
in get_user_id() so repeated requests don't re-list users. The worker is
simplified to call the same method.
qBittorrent: _login() raised "qBittorrent login failed: " (empty) on a 200
with an empty body, which happens when base_url doesn't reach the qBittorrent
login handler (wrong URL/path or a reverse proxy misroute) — not a credentials
issue. Now accepts the SID cookie as a success signal (reverse proxies that
mangle the body), returns a clear "invalid username or password" for "Fails.",
and surfaces a diagnostic error (HTTP status + body + base_url/proxy hint) for
any other/empty body.
Tests: new tests/test_jellyfin_client.py (5) + 3 qBittorrent login tests.
Full backend suite (384) passes; ruff clean.
WidgetConfigDialog crashed on edit with React error #185 (Maximum update
depth exceeded) when the references/instances query returned undefined and
the inline '= []' fallback created a new array ref every render, looping the
auto-edit useEffect. Stabilize via useMemo(data ?? []). Also moved the
referencedWidgetIds Set inside the availableWidgets useMemo (clears the
pre-existing exhaustive-deps warning).
Complex config fields (promql, query, text, command, notes, or opt-in via
format: 'textarea') now render as a taller resizable Textarea (rows=6,
min-h-120px, font-mono, resize) in both WidgetConfigFields and
ServiceConfigFields, instead of a single-line Input.
Build + lint clean (referencedWidgetIds warning gone), 165 vitest pass.
Every HTTP client passed an integer timeout to requests, applying the same
value to BOTH connect and read phases. A slow Jellyfin /Items page or qBit
/sync/maindata blew through the 10s read budget → ReadTimeoutError. Split
into a (connect=5s, read=60s default) tuple via shared http_timeout() helper.
The media index build worker uses a 180s read floor. Existing services with
low timeout_seconds benefit from bumping to 60+.
6 decisions: queryKey appends serviceId??''; API client reuses get(path,params);
service_id: str|None=None on backup endpoints; store filter via subquery for
runs/alerts (schema asymmetry — only backup_jobs has service_id column);
fetchBackupDashboard excluded (widget path, PI-117 risk). Single slice ~257
lines. 3 source findings: Alertmanager/Prom endpoints confirmed already take
service_id; backup runs/alerts attributed via FK chain (subquery needed).
Correctness fix for multi-instance: 6 hooks gain optional serviceId in queryKey;
7 API fns append ?service_id; 4 backup endpoints gain service_id filter
(Alertmanager/Prometheus status already take it — zero backend change there);
3 tabs pass instance.id; dashboard widgets unaffected; all params optional
(backward-compat). usePrometheusTargets + useMonitoringMachines stay global.
Per-type test routines for 7 remote types (qbittorrent, prometheus via Grafana
gateway, alertmanager, jellyfin, authentik, ssh_tasks, nextcloud) + backups
(no test). Endpoint POST /api/services/test, no persistence, friendly error
translation (resolves qBit login #3 at UI layer). Frontend Test button + gate
Create/Save on pass with Save-anyway override. Stale-proposal correction:
jellyseerr is not in the registry (merged into jellyfin).
Dependency on grafana-metric-gateway: the prometheus service now sources via
Grafana /api/ds/query (no direct Prom endpoint). Test routine changes from
GET /api/v1/query?query=up to POST {grafana_url}/api/ds/query with api_key +
datasource_uid, expr 'up'.
Move to openspec/changes/archive/2026-07-09-grafana-metric-gateway/
(R100 renames preserved). 9 artifacts. Canonical openspec/specs/
prometheus-charting/ (30 reqs, first non-additive sync) remains. Carry-overs
in archive-report: SC-106 stale component name (cosmetic); partial revert of
prometheus-direct-charting per new network constraint.
Route all prometheus widget queries through Grafana /api/ds/query instead of
direct Prom HTTP. PrometheusConfig: drop base_url, add grafana_url +
datasource_uid; secret grafana_api_key (required). PrometheusWidgetSource →
MetricSource with _gateway_query POST method. normalize_grafana_frames
recovered from 65bae95 + shared _dedup_label helper. Gateway-path status
check. Startup old-config validation. CHANGELOG migration note. All adapter
tests rewritten for POST /api/ds/query + Grafana frames mock. Backend: 331
pytest pass, ruff clean. Frontend: build green (unchanged in S1).
Route metric queries through Grafana /api/ds/query instead of direct Prom
(Prom is firewalled / unreachable from Manage; Grafana is the only path).
Partial revert of prometheus-direct-charting: keep the gauge/mean/
LineSeriesChart rendering, restore the frames->series normalizer (recovered
from git 65bae95), change prometheus service config to hold Grafana gateway
fields (url + api_key + datasource_uid). Rename widgets to neutral Metric*.
First non-additive canonical sync (prometheus-charting MODIFIED). Updates the
pending service-credential-tester proposal dependency.
On-demand per-service-type credential tester for the add/edit dialog. Model on
test_machine_ssh. POST /api/services/test dispatching to per-type routines
(qBit login+maindata, Prom instant query, Jellyfin /Users, etc.) returning
{ok, detail, evidence}. Frontend Test button + gate Create/Save on pass with
'Save anyway' override. Resolves#3 (qBit login failures visible in UI not
logs) and complements #1 (validation surfacing, already shipped).
qBittorrent was missing from SERVICE_TYPE_NAV_ENTRIES, so configured qBit
instances never appeared in the left nav (unlike jellyfin/prometheus/etc.).
Add entry with Magnet icon. navEntries.test.ts filters by configured types
(no fixed-count assertion) so it stays green.
CreateServiceDialog.save() awaited mutateAsync without a try/catch, so a
backend 422 (e.g. base_url missing http:// schema) threw uncaught and the
dialog sat silent with no feedback. Wrap in try/catch, hold the error in
local state, render a destructive Alert above the footer. Reset/onClose
only on success; on error the user can fix and retry.
Correctness fix: observability + backup hooks query globally, so multi-instance
service pages show data for the wrong instance. Tabs already accept instance
prop with TODO comments; backend mostly supports service_id already. Scope:
add serviceId to 6 hooks + fetch fns + 3 tabs; add service_id to backup
endpoints. Backward-compatible (optional params). ~250-350 lines, single slice.
Code merged to main on 2026-06-26 (01527ae) + fix passes. All 10 ACs PASS.
~8600 ins / ~3900 del across 103 files. Only SDD artifacts were untracked.
No code changes, no canonical sync. NOTE: verify-report describes a Grafana
LinksTab later removed by prometheus-direct-charting (2026-07-08); historically
accurate point-in-time record — see archive-report.md footnote.
Code merged to main on 2026-06-26 via rebase (01527ae) + bug-fix passes
(5a43894/04871bd/f7f590f). All 8 ACs PASS. Only SDD artifacts were untracked;
this archive closes the paperwork gap. No code changes, no canonical sync.
See archive-report.md for carry-over notes (iOS real-device test residual).
Wire ServiceDataHarness.cascade_delete into SettingsStore.delete_service
(best-effort try/except, logs on failure). Fix migration runner to also
catch 'no such table' on fresh DBs (ALTER TABLE before init_schema).
Integration test proves end-to-end cascade across both concerns (qBit
samples + media items) with multi-instance preservation.
Backend: 322 pytest pass, ruff clean.
Register MediaIndex as a harness concern with ALTER TABLE migration to add
service_id column (idempotent). Scope replace_items by service_id (FIXES latent
global-clear bug where building for one Jellyfin wiped another's rows). Scope
query by service_id (empty-string = all rows, backward-compat). Thread
service_id through build_media_index + worker + query_media router. New
regression test proves scoped replace preserves other services' rows.
Backend: 321 pytest pass, ruff clean. Frontend: build green.