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
|
# Docker tab caches stale container IDs, so the footer is the reliable way
|
||||||
# to confirm a `docker compose up -d --build` actually took effect.
|
# to confirm a `docker compose up -d --build` actually took effect.
|
||||||
# Both are env-overridable if the deploy pipeline ever injects them.
|
# 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")
|
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-26")
|
||||||
|
|
||||||
# Must point at a path that survives `docker compose up --build` — the
|
# Must point at a path that survives `docker compose up --build` — the
|
||||||
|
|
@ -682,6 +682,48 @@ def prowlarr_get(endpoint):
|
||||||
return resp.json()
|
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():
|
def get_prowlarr_summary():
|
||||||
try:
|
try:
|
||||||
indexers = prowlarr_get("indexer")
|
indexers = prowlarr_get("indexer")
|
||||||
|
|
@ -692,18 +734,43 @@ def get_prowlarr_summary():
|
||||||
except Exception:
|
except Exception:
|
||||||
failed_ids = set()
|
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 = []
|
result = []
|
||||||
for idx in sorted(indexers, key=lambda x: x.get("name", "").lower()):
|
for idx in indexers:
|
||||||
enabled = idx.get("enable", True)
|
enabled = idx.get("enable", True)
|
||||||
blocked = idx.get("id") in failed_ids
|
blocked = idx.get("id") in failed_ids
|
||||||
up = enabled and not blocked
|
up = enabled and not blocked
|
||||||
|
st = stats.get(idx.get("id"))
|
||||||
result.append({
|
result.append({
|
||||||
"id": idx.get("id"),
|
"id": idx.get("id"),
|
||||||
"name": idx.get("name", "Unknown"),
|
"name": idx.get("name", "Unknown"),
|
||||||
"protocol": idx.get("protocol", ""),
|
"protocol": idx.get("protocol", ""),
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"up": up,
|
"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)
|
total = len(result)
|
||||||
up_count = sum(1 for i in result if i["up"])
|
up_count = sum(1 for i in result if i["up"])
|
||||||
|
|
@ -712,6 +779,11 @@ def get_prowlarr_summary():
|
||||||
"indexers": result,
|
"indexers": result,
|
||||||
"total": total,
|
"total": total,
|
||||||
"up_count": up_count,
|
"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:
|
except Exception as e:
|
||||||
return {"status": "error", "error": str(e), "indexers": []}
|
return {"status": "error", "error": str(e), "indexers": []}
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,10 @@ main {
|
||||||
}
|
}
|
||||||
.card-body { padding: 1.1rem; flex: 1; }
|
.card-body { padding: 1.1rem; flex: 1; }
|
||||||
.card-body > :first-child { margin-top: 0; }
|
.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 ─────────────────────────── */
|
/* ── "Right now" hero strip ─────────────────────────── */
|
||||||
.hero .card { border-color: var(--border-lt); }
|
.hero .card { border-color: var(--border-lt); }
|
||||||
|
|
@ -431,26 +434,52 @@ dd.is-accent { color: var(--accent); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Indexers ───────────────────────────────────────── */
|
/* ── Indexers ───────────────────────────────────────── */
|
||||||
.indexer-grid {
|
/* One indexer per line, built to the same rhythm as the Ombi request rows
|
||||||
display: grid;
|
beside it — same padding, gap, radius and two-line body, so the two cards
|
||||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
read as one pair rather than two unrelated layouts. */
|
||||||
gap: 0.65rem;
|
.indexer-grid { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
}
|
|
||||||
.indexer-item {
|
.indexer-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.65rem;
|
gap: 0.7rem;
|
||||||
min-height: var(--tap);
|
padding: 0.6rem 0.7rem;
|
||||||
padding: 0.75rem 0.9rem;
|
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--fs-base);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.indexer-item.down { border-color: var(--danger-border); }
|
.indexer-item.down { border-color: var(--danger-border); }
|
||||||
.indexer-item .status-light { width: 9px; height: 9px; }
|
.indexer-item .status-light { width: 9px; height: 9px; flex-shrink: 0; }
|
||||||
.indexer-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-2); }
|
.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) ────────────────────────────────── */
|
/* ── Requests (Ombi) ────────────────────────────────── */
|
||||||
.req-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
.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.denied { background: var(--danger-dim); color: var(--danger); }
|
||||||
.badge.idle { background: var(--surface); color: var(--text-3); }
|
.badge.idle { background: var(--surface); color: var(--text-3); }
|
||||||
.badge.downloading { background: var(--accent-dim); color: var(--accent); }
|
.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); }
|
.badge.paused { background: var(--warn-dim); color: var(--warn); }
|
||||||
.req-info { min-width: 0; flex: 1; }
|
.req-info { min-width: 0; flex: 1; }
|
||||||
.req-title {
|
.req-title {
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,12 @@ async function pollProwlarr() {
|
||||||
const light = document.getElementById('idx-light-' + idx.id);
|
const light = document.getElementById('idx-light-' + idx.id);
|
||||||
if (row) { row.classList.toggle('up', idx.up); row.classList.toggle('down', !idx.up); }
|
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); }
|
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);
|
updateServicePill('prowlarr', d.up_count === d.total && d.total > 0);
|
||||||
const stat = document.getElementById('svc-stat-prowlarr');
|
const stat = document.getElementById('svc-stat-prowlarr');
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@
|
||||||
<!-- ══ Requests + Indexers ══ -->
|
<!-- ══ Requests + Indexers ══ -->
|
||||||
<section class="section" aria-labelledby="h-ops">
|
<section class="section" aria-labelledby="h-ops">
|
||||||
<h2 id="h-ops">Requests & indexers</h2>
|
<h2 id="h-ops">Requests & indexers</h2>
|
||||||
<div class="row cols-2">
|
<div class="row cols-2 align-start">
|
||||||
|
|
||||||
<!-- Ombi -->
|
<!-- Ombi -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -377,16 +377,38 @@
|
||||||
{% if prowlarr.status == 'ok' %}
|
{% if prowlarr.status == 'ok' %}
|
||||||
<span class="card-meta">
|
<span class="card-meta">
|
||||||
<span id="prowlarr-up-count">{{ prowlarr.up_count }}</span>/<span id="prowlarr-total">{{ prowlarr.total }}</span> online
|
<span id="prowlarr-up-count">{{ prowlarr.up_count }}</span>/<span id="prowlarr-total">{{ prowlarr.total }}</span> online
|
||||||
|
{%- if prowlarr.has_stats %} · share of {{ prowlarr.share_metric }}, {{ prowlarr.stats_days }}d{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" id="prowlarr-body">
|
<div class="card-body" id="prowlarr-body">
|
||||||
{% if prowlarr.status == 'ok' %}
|
{% 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 %}
|
||||||
<div class="indexer-grid" id="indexer-grid">
|
<div class="indexer-grid" id="indexer-grid">
|
||||||
{% for idx in prowlarr.indexers %}
|
{% for idx in prowlarr.indexers %}
|
||||||
|
{% set share = idx.share %}
|
||||||
<div class="indexer-item {{ 'up' if idx.up else 'down' }}" id="idx-{{ idx.id }}">
|
<div class="indexer-item {{ 'up' if idx.up else 'down' }}" id="idx-{{ idx.id }}">
|
||||||
<span class="status-light {{ 'up' if idx.up else 'down' }}" id="idx-light-{{ idx.id }}"></span>
|
<span class="status-light {{ 'up' if idx.up else 'down' }}" id="idx-light-{{ idx.id }}"></span>
|
||||||
<span class="indexer-name" title="{{ idx.name }}">{{ idx.name }}</span>
|
<div class="indexer-info">
|
||||||
|
<div class="indexer-name" title="{{ idx.name }}">{{ idx.name }}</div>
|
||||||
|
<div class="indexer-sub">
|
||||||
|
<span class="proto">{{ idx.protocol }}</span>{% if prowlarr.has_stats %} · {{ '{:,}'.format(idx.grabs) }} grabs · {{ '{:,}'.format(idx.queries) }} queries{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if prowlarr.has_stats %}
|
||||||
|
<div class="share-bar" role="img"
|
||||||
|
aria-label="{{ share }}% of {{ prowlarr.share_metric }} in the last {{ prowlarr.stats_days }} days">
|
||||||
|
<span style="width: {{ (share / top_share * 100) | round(1) if top_share else 0 }}%"></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if prowlarr.has_stats %}
|
||||||
|
<span class="share-pct">{{ share }}%</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="badge {{ 'online' if idx.up else 'offline' }}"
|
||||||
|
id="idx-badge-{{ idx.id }}">{{ 'Online' if idx.up else 'Offline' }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue