diff --git a/app.py b/app.py index faa589e..72a79db 100644 --- a/app.py +++ b/app.py @@ -20,7 +20,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # Docker tab caches stale container IDs, so the footer is the reliable way # to confirm a `docker compose up -d --build` actually took effect. # Both are env-overridable if the deploy pipeline ever injects them. -APP_VERSION = os.environ.get("APP_VERSION", "1.1.0") +APP_VERSION = os.environ.get("APP_VERSION", "1.2.0") APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-26") # Must point at a path that survives `docker compose up --build` — the @@ -682,6 +682,48 @@ def prowlarr_get(endpoint): return resp.json() +# Grab/query counts change slowly and the extra round-trip sits inside the +# dashboard's 5s wall-clock deadline, so this is cached harder than the +# up/down status it decorates. Failures are not cached — a stats outage +# should not blank the share column for ten minutes after it recovers. +PROWLARR_STATS_TTL = 10 * 60 +PROWLARR_STATS_DAYS = 30 +_prowlarr_stats_cache = {"data": None, "expires": 0} + + +def get_prowlarr_stats(): + """Per-indexer query/grab counts for the last PROWLARR_STATS_DAYS days. + + Returns {indexer_id: {"queries": int, "grabs": int, "failed_queries": int}} + or {} if Prowlarr's stats endpoint is unavailable — callers must treat an + empty result as "unknown", not "zero activity". + """ + now = time.time() + if _prowlarr_stats_cache["data"] is not None and now < _prowlarr_stats_cache["expires"]: + return _prowlarr_stats_cache["data"] + + end = datetime.utcnow() + start = end - timedelta(days=PROWLARR_STATS_DAYS) + fmt = "%Y-%m-%dT%H:%M:%SZ" + try: + raw = prowlarr_get( + f"indexerstats?startDate={start.strftime(fmt)}&endDate={end.strftime(fmt)}" + ) + except Exception: + return {} + + stats = {} + for row in (raw.get("indexers") or []) if isinstance(raw, dict) else []: + stats[row.get("indexerId")] = { + "queries": row.get("numberOfQueries", 0) or 0, + "grabs": row.get("numberOfGrabs", 0) or 0, + "failed_queries": row.get("numberOfFailedQueries", 0) or 0, + } + _prowlarr_stats_cache["data"] = stats + _prowlarr_stats_cache["expires"] = now + PROWLARR_STATS_TTL + return stats + + def get_prowlarr_summary(): try: indexers = prowlarr_get("indexer") @@ -692,18 +734,43 @@ def get_prowlarr_summary(): except Exception: failed_ids = set() + stats = get_prowlarr_stats() + total_grabs = sum(v["grabs"] for v in stats.values()) + total_queries = sum(v["queries"] for v in stats.values()) + result = [] - for idx in sorted(indexers, key=lambda x: x.get("name", "").lower()): + for idx in indexers: enabled = idx.get("enable", True) blocked = idx.get("id") in failed_ids up = enabled and not blocked + st = stats.get(idx.get("id")) result.append({ "id": idx.get("id"), "name": idx.get("name", "Unknown"), "protocol": idx.get("protocol", ""), "enabled": enabled, "up": up, + "has_stats": st is not None, + "grabs": st["grabs"] if st else 0, + "queries": st["queries"] if st else 0, + "failed_queries": st["failed_queries"] if st else 0, + # Share of everything actually pulled down in the window. Falls + # back to query share when nothing has been grabbed yet, so a + # quiet month still ranks the indexers by who is doing the work. + "grab_share": round(100 * st["grabs"] / total_grabs, 1) + if st and total_grabs else 0.0, + "query_share": round(100 * st["queries"] / total_queries, 1) + if st and total_queries else 0.0, }) + # The share the card actually renders, so templates never have to + # know which metric won. + result[-1]["share"] = (result[-1]["grab_share"] if total_grabs + else result[-1]["query_share"]) + + # Busiest first — the card's job is to answer "who is pulling the + # weight", so name order would bury the answer. + rank_key = "grabs" if total_grabs else "queries" + result.sort(key=lambda i: (-i[rank_key], i["name"].lower())) total = len(result) up_count = sum(1 for i in result if i["up"]) @@ -712,6 +779,11 @@ def get_prowlarr_summary(): "indexers": result, "total": total, "up_count": up_count, + "has_stats": bool(stats), + "stats_days": PROWLARR_STATS_DAYS, + "total_grabs": total_grabs, + "total_queries": total_queries, + "share_metric": "grabs" if total_grabs else "queries", } except Exception as e: return {"status": "error", "error": str(e), "indexers": []} diff --git a/static/css/dashboard.css b/static/css/dashboard.css index 3e519cb..0d7f6f3 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -163,7 +163,10 @@ main { } .card-body { padding: 1.1rem; flex: 1; } .card-body > :first-child { margin-top: 0; } -#prowlarr-body { display: flex; flex-direction: column; justify-content: center; } +/* Requests can grow tall when expanded; the Indexers card beside it should + keep its natural height and sit at the top rather than stretching into a + mostly-empty box with its content floating in the middle. */ +.row.align-start { align-items: start; } /* ── "Right now" hero strip ─────────────────────────── */ .hero .card { border-color: var(--border-lt); } @@ -431,26 +434,52 @@ dd.is-accent { color: var(--accent); } } /* ── Indexers ───────────────────────────────────────── */ -.indexer-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); - gap: 0.65rem; -} +/* One indexer per line, built to the same rhythm as the Ombi request rows + beside it — same padding, gap, radius and two-line body, so the two cards + read as one pair rather than two unrelated layouts. */ +.indexer-grid { display: flex; flex-direction: column; gap: 0.5rem; } .indexer-item { display: flex; align-items: center; - gap: 0.65rem; - min-height: var(--tap); - padding: 0.75rem 0.9rem; + gap: 0.7rem; + padding: 0.6rem 0.7rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); - font-size: var(--fs-base); min-width: 0; } .indexer-item.down { border-color: var(--danger-border); } -.indexer-item .status-light { width: 9px; height: 9px; } -.indexer-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-2); } +.indexer-item .status-light { width: 9px; height: 9px; flex-shrink: 0; } +.indexer-info { min-width: 0; flex: 1; } +.indexer-name { + font-size: var(--fs-sm); font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.indexer-sub { font-size: var(--fs-xs); color: var(--text-3); } +.indexer-sub .proto { text-transform: capitalize; } +.indexer-sub:empty::before { content: "Indexer"; } +/* Share of grabs, scaled to the busiest indexer (see dashboard.html) */ +.share-bar { + margin-top: 0.35rem; + height: 4px; + border-radius: 100px; + background: var(--surface); + overflow: hidden; +} +.share-bar span { + display: block; + height: 100%; + border-radius: 100px; + background: var(--accent); +} +.indexer-item.down .share-bar span { background: var(--text-3); } +.share-pct { + font-size: var(--fs-sm); + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-2); + flex-shrink: 0; +} /* ── Requests (Ombi) ────────────────────────────────── */ .req-list { display: flex; flex-direction: column; gap: 0.5rem; } @@ -479,6 +508,8 @@ dd.is-accent { color: var(--accent); } .badge.denied { background: var(--danger-dim); color: var(--danger); } .badge.idle { background: var(--surface); color: var(--text-3); } .badge.downloading { background: var(--accent-dim); color: var(--accent); } +.badge.online { background: var(--ok-dim); color: var(--ok); } +.badge.offline { background: var(--danger-dim); color: var(--danger); } .badge.paused { background: var(--warn-dim); color: var(--warn); } .req-info { min-width: 0; flex: 1; } .req-title { diff --git a/static/js/dashboard.js b/static/js/dashboard.js index f27da46..07e24a0 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -187,6 +187,12 @@ async function pollProwlarr() { const light = document.getElementById('idx-light-' + idx.id); if (row) { row.classList.toggle('up', idx.up); row.classList.toggle('down', !idx.up); } if (light) { light.classList.toggle('up', idx.up); light.classList.toggle('down', !idx.up); } + const badge = document.getElementById('idx-badge-' + idx.id); + if (badge) { + badge.classList.toggle('online', idx.up); + badge.classList.toggle('offline', !idx.up); + badge.textContent = idx.up ? 'Online' : 'Offline'; + } }); updateServicePill('prowlarr', d.up_count === d.total && d.total > 0); const stat = document.getElementById('svc-stat-prowlarr'); diff --git a/templates/dashboard.html b/templates/dashboard.html index c6ff28d..b856635 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -332,7 +332,7 @@

Requests & indexers

-
+
@@ -377,16 +377,38 @@ {% if prowlarr.status == 'ok' %} {{ prowlarr.up_count }}/{{ prowlarr.total }} online + {%- if prowlarr.has_stats %} · share of {{ prowlarr.share_metric }}, {{ prowlarr.stats_days }}d{% endif %} {% endif %}
{% if prowlarr.status == 'ok' %} + {# Bars are scaled against the busiest indexer, not against 100%, + so a spread of single-digit shares is still readable. The label + beside each bar stays the true percentage. #} + {% set top_share = prowlarr.indexers[0].share if prowlarr.indexers else 0 %}
{% for idx in prowlarr.indexers %} + {% set share = idx.share %}
- {{ idx.name }} +
+
{{ idx.name }}
+
+ {{ idx.protocol }}{% if prowlarr.has_stats %} · {{ '{:,}'.format(idx.grabs) }} grabs · {{ '{:,}'.format(idx.queries) }} queries{% endif %} +
+ {% if prowlarr.has_stats %} + + {% endif %} +
+ {% if prowlarr.has_stats %} + + {% endif %} + {{ 'Online' if idx.up else 'Offline' }}
{% endfor %}