Show per-indexer grab share and rebuild the Indexers card as a list
All checks were successful
Deploy / deploy (push) Successful in 26s
All checks were successful
Deploy / deploy (push) Successful in 26s
The Indexers card sat beside Requests as a stretched, vertically centred grid of pills — mostly dead space once the Ombi list was expanded, and it said nothing about which indexer was actually doing the work. - Fetch Prowlarr's indexerstats over a rolling 30-day window and merge per-indexer queries/grabs into the summary. Cached 10 min on success and never on failure, so a stats outage can't blank the column after it recovers or take the up/down list down with it. - Rank indexers busiest-first by grab share (query share as fallback); queries mostly count enabled indexers, grabs count who delivered. - One indexer per line, matched to the Ombi request rows: same padding, gap, radius and two-line body, plus a share bar scaled to the busiest indexer and an Online/Offline badge the 30s poll keeps current. - Let that row size cards to their own content instead of stretching. Bump APP_VERSION to 1.2.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2cc62979a2
commit
909a0b568c
4 changed files with 147 additions and 16 deletions
76
app.py
76
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": []}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue