@wendellresearch-ai/benchwarmers 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/USAGE.md +16 -4
- package/VERSION +1 -1
- package/package.json +1 -1
- package/scripts/auto_retire.py +47 -4
- package/scripts/test_serve_edit.py +1 -1
- package/serve.py +166 -72
- package/suite/run.py +4 -0
- package/ui/bench.css +50 -13
- package/ui/bench.js +20 -27
package/README.md
CHANGED
|
@@ -187,7 +187,8 @@ dependencies either way: the app is the Python standard library.
|
|
|
187
187
|
- **Health.** Every morning each model gets a one-word prompt; once a week, a
|
|
188
188
|
short and a long one for speed. A model that stops answering, or that its
|
|
189
189
|
endpoint no longer serves, is retired automatically after one confirming
|
|
190
|
-
call, with the evidence kept, and can be restored in one click.
|
|
190
|
+
call, with the evidence kept, and can be restored in one click. A model
|
|
191
|
+
that is only out of quota until a stated reset is not counted against. The
|
|
191
192
|
built-in scheduler runs all of this once Settings switches it on; a fresh
|
|
192
193
|
install spends no calls until then.
|
|
193
194
|
- **Rate limits and usage.** Limits come from providers' rate-limit headers
|
package/USAGE.md
CHANGED
|
@@ -25,6 +25,7 @@ coding agent doing it with you); the gateway's whole contract is in
|
|
|
25
25
|
| Group | Page | What it is for |
|
|
26
26
|
| --- | --- | --- |
|
|
27
27
|
| Observe | **At a glance** | Every model on the bench: whether it works right now, its role, and its connection. Click a name for its popup: role, alias, facts, options, retire. |
|
|
28
|
+
| | **Action needed** | What will not fix itself, by the connection it comes from, with Retry and Retire on each model. |
|
|
28
29
|
| | **Benchmarks** | How models do on the suite and on real work: bugs found against false alarms, who is confidently wrong, and speed. |
|
|
29
30
|
| | **Quota management** | A tile per provider: what is left, when it resets, and which of its models are resting. Then the week's resets, and what the bench sent. |
|
|
30
31
|
| | **Logs** | Gateway calls (who sat and how it went, never the prompt), changes made from the dashboard, the scheduler, and onboarding runs. |
|
|
@@ -35,10 +36,21 @@ coding agent doing it with you); the gateway's whole contract is in
|
|
|
35
36
|
| Control | **Settings** | Theme, gateway keys, the scheduler, the model client, providers whose free models work only in their own app, how the install is reached (shown only), and Restart. |
|
|
36
37
|
| | **Info** | Version, Python, data folder and counts. |
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
**Action needed** lists what will not fix itself, such as a model about to be
|
|
40
|
+
retired, grouped by the connection it comes from, since one cause usually
|
|
41
|
+
reaches every model there. Each model is one line: its state, the provider's
|
|
42
|
+
own words for the error (whole on hover), and Retry (one call; an answer
|
|
43
|
+
clears the alert) and Retire. A group of two or more has Retry all and Retire
|
|
44
|
+
all; a connection held because every model on it fails says so above its
|
|
45
|
+
models, with any link the provider gave. Automatic retirements from the last
|
|
46
|
+
week are folded at the end, each with Restore. Its count sits beside it in the
|
|
47
|
+
sidebar, and At a glance opens on one line saying what is waiting and how
|
|
48
|
+
soon. An alert whose models have all been retired goes at once. A model that
|
|
49
|
+
is out of quota is not counting down: days the bench held its calls back until
|
|
50
|
+
a stated reset, an account's shared free quota was spent, or the provider said
|
|
51
|
+
the model's free quota is used up, pause its clock, and while it is still out
|
|
52
|
+
of quota no countdown shows. One refused again after the short wait it named
|
|
53
|
+
is failing, and keeps counting.
|
|
42
54
|
|
|
43
55
|
**Restart** (Settings, Server and access), or `bench restart`, starts the
|
|
44
56
|
dashboard again on the code now on disk, after an update. It waits for
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.5.
|
|
1
|
+
0.5.3
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
|
|
5
5
|
"mcpName": "io.github.WendellResearch-AI/benchwarmers",
|
|
6
6
|
"license": "MIT",
|
package/scripts/auto_retire.py
CHANGED
|
@@ -19,6 +19,14 @@ THE RULES (decided 2026-10-15)
|
|
|
19
19
|
reason -- a rate limit that never lifts is a quota or a dead model.
|
|
20
20
|
`mistral-medium-3-5` answered 429 to 11 calls out of 11.
|
|
21
21
|
- NEVER on a rate limit or timeout alone inside those 7 days.
|
|
22
|
+
- OUT OF QUOTA IS NOT FAILING (2026-11-02): a call the bench held back because
|
|
23
|
+
a limit is spent until a stated reset, or an account-wide quota, says
|
|
24
|
+
nothing about the model. Its days pause the clock, and while it is the
|
|
25
|
+
latest word there is no countdown. `gpt-5.6-luna` sat on "retires in 3
|
|
26
|
+
days" while CLI Proxy API's codex sign-in was out until 26 November and the
|
|
27
|
+
bench had not sent it a call since 28 October. A refusal that names a short
|
|
28
|
+
wait and is refused again after it still counts: that is a model that keeps
|
|
29
|
+
failing, not one that is tapped out.
|
|
22
30
|
|
|
23
31
|
"No answer" means from ANY source: the perf probe, a useful offload, or a
|
|
24
32
|
suite output that was a real answer. A model that answers in the suite resets
|
|
@@ -68,6 +76,18 @@ def account_quota(detail: str) -> bool:
|
|
|
68
76
|
return any(marker in detail for marker in ACCOUNT_QUOTAS)
|
|
69
77
|
|
|
70
78
|
|
|
79
|
+
HELD = "not sent; paced by the bench" # metrics/pacing.py's refusal
|
|
80
|
+
USED_UP = ("free usage quota",) # xKiro: "...quota for this model is used up"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def tapped_out(detail: str) -> bool:
|
|
84
|
+
"""Out of quota right now, so the model could not be tried: the bench held
|
|
85
|
+
the call back (a limit spent until its reset, the provider's or a cap you
|
|
86
|
+
set), the account's shared quota is spent (account_quota), or the
|
|
87
|
+
provider says the model's own free quota is used up."""
|
|
88
|
+
return HELD in detail or account_quota(detail) or any(m in detail for m in USED_UP)
|
|
89
|
+
|
|
90
|
+
|
|
71
91
|
def plan(today: dt.date, models: dict[str, str], live: set[str],
|
|
72
92
|
first_missing: dict[str, str], rows: list[dict],
|
|
73
93
|
answers: dict[str, dt.date], restored: dict[str, dt.date],
|
|
@@ -87,15 +107,18 @@ def plan(today: dt.date, models: dict[str, str], live: set[str],
|
|
|
87
107
|
down: dict[str, bool] = {}
|
|
88
108
|
candidates: list[tuple[str, str, str]] = [] # (alias, bucket, why)
|
|
89
109
|
for alias, served in models.items():
|
|
90
|
-
mine =
|
|
110
|
+
mine = sorted((r for r in rows if r["alias"] == alias), key=lambda r: r["date"]) # [-1] is the latest word
|
|
91
111
|
last_ok = max([r["date"] for r in mine if r["answered"]]
|
|
92
112
|
+ ([answers[alias]] if alias in answers else []),
|
|
93
113
|
default=None)
|
|
94
114
|
floor = max(d for d in (last_ok, restored.get(alias), dt.date.min) if d)
|
|
95
115
|
# A refused key (`signin`) is the endpoint's, fixed by signing in again.
|
|
96
116
|
fails = sorted((r for r in mine if not r["answered"] and r["failure"] and r["failure"] != "signin"
|
|
97
|
-
and r["date"] > floor and not
|
|
117
|
+
and r["date"] > floor and not tapped_out(r["detail"])),
|
|
98
118
|
key=lambda r: r["date"])
|
|
119
|
+
quiet = {r["date"] for r in mine if not r["answered"] and r["date"] > floor
|
|
120
|
+
and tapped_out(r["detail"])}
|
|
121
|
+
resting = bool(mine) and not mine[-1]["answered"] and tapped_out(mine[-1]["detail"])
|
|
99
122
|
missing_since = None
|
|
100
123
|
if served not in live and served in first_missing:
|
|
101
124
|
missing_since = max(dt.date.fromisoformat(first_missing[served]),
|
|
@@ -107,8 +130,10 @@ def plan(today: dt.date, models: dict[str, str], live: set[str],
|
|
|
107
130
|
why = (f"{latest['failure']}: {latest['detail']}".rstrip(": ")
|
|
108
131
|
if latest else "")
|
|
109
132
|
other_days = {r["date"] for r in fails if r["failure"] == "other"}
|
|
110
|
-
span = (today - fails[0]["date"]).days if fails else 0
|
|
111
133
|
fail_days = {r["date"] for r in fails}
|
|
134
|
+
# Days it was out of quota, and nothing else, do not count: the clock pauses.
|
|
135
|
+
span = ((today - fails[0]["date"]).days
|
|
136
|
+
- len({q for q in quiet - fail_days if q > fails[0]["date"]})) if fails else 0
|
|
112
137
|
|
|
113
138
|
# A CONNECTION PREFIX ADDED IN OPEN WEBUI renames every model on it
|
|
114
139
|
# (`codestral-latest` -> `llm7.codestral-latest`), and the old names
|
|
@@ -146,7 +171,7 @@ def plan(today: dt.date, models: dict[str, str], live: set[str],
|
|
|
146
171
|
left.append((OTHER_DAYS - len(other_days), why))
|
|
147
172
|
if fails:
|
|
148
173
|
left.append((BACKSTOP_DAYS - span, why))
|
|
149
|
-
if left:
|
|
174
|
+
if left and not resting: # resting: the Quota page says when it is back
|
|
150
175
|
days, reason = min(left)
|
|
151
176
|
out["pending"].append((alias, f"{max(days, 1)}|{reason}"))
|
|
152
177
|
|
|
@@ -351,6 +376,24 @@ def _selfcheck() -> None:
|
|
|
351
376
|
detail="429 Rate limit exceeded: free-models-per-day") for n in (8, 5, 0)]
|
|
352
377
|
p = plan(d, {"quota": "v/quota"}, {"v/quota"}, {}, shared, {}, {}, {"v/quota": "v"})
|
|
353
378
|
assert not p["retire"] and not p["pending"], p
|
|
379
|
+
# held back by the bench for days (its codex sign-in spent until a reset): no countdown
|
|
380
|
+
held = [dict(row("luna", n, "capacity"), detail=f"rate limit ({HELD}): 0 left") for n in (9, 5, 0)]
|
|
381
|
+
p = plan(d, {"luna": "c/luna"}, {"c/luna"}, {}, held, {}, {}, {"c/luna": "c"})
|
|
382
|
+
assert not p["retire"] and not p["pending"], p
|
|
383
|
+
assert tapped_out("Error code: service_unavailable - The free usage quota for this model is used up")
|
|
384
|
+
# real failures, then out of quota for a week: those days pause the clock
|
|
385
|
+
paused = ([row("p", n, "capacity") for n in (10, 9, 8, 0)]
|
|
386
|
+
+ [dict(row("p", n, "capacity"), detail=HELD) for n in range(1, 8)])
|
|
387
|
+
p = plan(d, {"p": "c/p"}, {"c/p"}, {}, paused, {}, {}, {"c/p": "c"})
|
|
388
|
+
assert not p["retire"] and dict(p["pending"])["p"].startswith("4|"), p
|
|
389
|
+
# ...and while it is still out of quota, no countdown is shown at all
|
|
390
|
+
p = plan(d, {"p": "c/p"}, {"c/p"}, {}, paused[:3] + paused[4:], {}, {}, {"c/p": "c"})
|
|
391
|
+
assert not p["retire"] and not p["pending"], p
|
|
392
|
+
# a short stated wait, refused again day after day, is a model that keeps failing
|
|
393
|
+
busy = [dict(row("u", n, "capacity"), detail="429 allows 1 request(s) every 1 min, retry in 60s")
|
|
394
|
+
for n in (8, 5, 0)]
|
|
395
|
+
p = plan(d, {"u": "v/u"}, {"v/u"}, {}, busy, {}, {}, {"v/u": "v"})
|
|
396
|
+
assert {a for a, _ in p["retire"]} == {"u"}, p
|
|
354
397
|
# A refused key is the endpoint's, never the model's (suite/run.py SIGNIN)
|
|
355
398
|
from run import failure_class
|
|
356
399
|
assert failure_class("Error: Error code: 401 - {'error': 'invalid key'}") == "signin"
|
|
@@ -454,7 +454,7 @@ def main() -> int:
|
|
|
454
454
|
encoding="utf-8")
|
|
455
455
|
serve.DATA, serve.registered = pathlib.Path(tmp), lambda: {"h1-served": "h1"}
|
|
456
456
|
try:
|
|
457
|
-
shown = serve.
|
|
457
|
+
shown = serve.actions_page(*serve.open_actions())
|
|
458
458
|
finally:
|
|
459
459
|
serve.DATA, serve.registered = kept_data, kept_reg
|
|
460
460
|
ok = "here.example" in shown and "gone.example" not in shown and "old retires" not in shown
|
package/serve.py
CHANGED
|
@@ -2003,89 +2003,172 @@ def first_run() -> str:
|
|
|
2003
2003
|
f"<button class='btn primary' id='first-go'>{label}</button></div></div></section>")
|
|
2004
2004
|
|
|
2005
2005
|
|
|
2006
|
-
def
|
|
2007
|
-
"""
|
|
2006
|
+
def said(detail: str) -> tuple[str, str]:
|
|
2007
|
+
"""An error as one short line, and the link the provider put in it: the
|
|
2008
|
+
status code and the provider's own words, without the JSON around them or
|
|
2009
|
+
a lead-in such as "Too many requests." that says less than the sentence
|
|
2010
|
+
after it. The whole detail stays in the row's title."""
|
|
2011
|
+
code = re.search(r"Error code: (\w+)", detail)
|
|
2012
|
+
quoted = re.search(r"""['"](?:message|detail)['"]: ['"](.+?)(?:['"]\s*[,}]|$)""", detail)
|
|
2013
|
+
text = (quoted.group(1) if quoted else re.sub(r"^.*?Error: ", "", detail) if "Error: " in detail
|
|
2014
|
+
else re.sub(r"^\w+: ", "", detail))
|
|
2015
|
+
text = " ".join(text.replace("<<", "").replace(">>", "").split())
|
|
2016
|
+
first, _, rest = text.partition(". ")
|
|
2017
|
+
if rest and len(first) < 25:
|
|
2018
|
+
text = rest
|
|
2019
|
+
url = re.search(r"https?://[^\s;,'\"()]+", quoted.group(1) if quoted else "")
|
|
2020
|
+
text = (f"{code.group(1)} · " if code else "") + text
|
|
2021
|
+
return (text if len(text) <= 110 else text[:109].rstrip() + "…"), (url.group(0) if url else "")
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
def open_actions() -> tuple[dict[str, dict], list[tuple[str, dict]]]:
|
|
2025
|
+
"""What needs a person, by the connection it comes from ({connection:
|
|
2026
|
+
{"held": [...], "rows": [...]}}, the soonest to retire first), and the
|
|
2027
|
+
last week's automatic retirements, newest first.
|
|
2008
2028
|
|
|
2009
2029
|
Rate limits and timeouts are not listed: they resolve themselves, or count
|
|
2010
2030
|
toward the 7-day backstop and appear here as `retiring` when that nears.
|
|
2011
|
-
Automatic retirements
|
|
2012
|
-
|
|
2031
|
+
Automatic retirements ARE listed, with Restore, because a decision taken
|
|
2032
|
+
without a person should be easy for one to undo."""
|
|
2013
2033
|
alerts = load_json(DATA / "metrics" / "alerts.json")
|
|
2014
2034
|
names = registered()
|
|
2015
|
-
today = datetime.date.today()
|
|
2016
|
-
KIND = {"retiring": "err", "retired": "err", "connection": "warn",
|
|
2017
|
-
"registration": "warn", "plan": "warn"}
|
|
2018
|
-
items = []
|
|
2019
2035
|
live = set(names.values())
|
|
2020
|
-
|
|
2036
|
+
status = model_status()
|
|
2037
|
+
groups: dict[str, dict] = {}
|
|
2038
|
+
|
|
2039
|
+
def group(conn: str) -> dict:
|
|
2040
|
+
return groups.setdefault(conn or "?", {"held": [], "rows": {}})
|
|
2041
|
+
|
|
2042
|
+
def row(alias: str, key: str, a: dict) -> dict:
|
|
2043
|
+
# The roster's words for its state, so the two pages agree; the
|
|
2044
|
+
# alert's own evidence for a countdown, the latest probe otherwise.
|
|
2045
|
+
label, css, latest = status.get(alias, (a.get("kind", "?"), "warn", ""))
|
|
2046
|
+
days = re.search(r"in (\d+)d", label)
|
|
2047
|
+
detail = a.get("action", "") if a.get("kind") == "retiring" else latest or a.get("message", "")
|
|
2048
|
+
text, url = said(detail)
|
|
2049
|
+
return {"alias": alias, "key": key, "label": label, "css": "warn" if days and int(days.group(1)) > 2 else css,
|
|
2050
|
+
"days": int(days.group(1)) if days else 99, "said": text, "url": url, "title": detail,
|
|
2051
|
+
"why": a.get("message", "")}
|
|
2052
|
+
|
|
2053
|
+
for key, a in alerts.items():
|
|
2021
2054
|
served = key.split(":", 2)[2] if key.count(":") >= 2 else ""
|
|
2022
2055
|
alias = a.get("alias") or names.get(served, "")
|
|
2023
2056
|
# The alerts file is rewritten by the daily check; until then an alert
|
|
2024
2057
|
# whose models have all left the bench (Retire all, or one by one) has
|
|
2025
2058
|
# nothing left to act on.
|
|
2026
|
-
if a.get("aliases") and not live & set(a["aliases"]):
|
|
2027
|
-
continue
|
|
2028
|
-
if a.get("kind") == "retiring" and alias and alias not in live:
|
|
2059
|
+
if (a.get("aliases") and not live & set(a["aliases"])) or (alias and alias not in live):
|
|
2029
2060
|
continue
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
acts.append(f"<button class='btn small' data-act='open' data-alias='{esc(alias)}'>Open</button>")
|
|
2050
|
-
acts.append(f"<button class='btn small danger' data-act='retire' data-alias='{esc(alias)}'"
|
|
2051
|
-
f" data-why='{esc(a.get('message', ''))}'>Retire now</button>")
|
|
2052
|
-
if a.get("dismiss"): # a notice, not a state: it goes once seen
|
|
2053
|
-
acts.append(f"<button class='btn small' data-act='dismiss' data-key='{esc(key)}'>Got it</button>")
|
|
2054
|
-
items.append((key, a.get("kind", "?"), KIND.get(a.get("kind"), "warn"),
|
|
2055
|
-
a.get("message", ""), why, "".join(acts)))
|
|
2056
|
-
cutoff = (today - datetime.timedelta(days=7)).isoformat()
|
|
2057
|
-
tags = load_json(DATA / "config" / "provider-tags.json") # a retired model's connection, by served name
|
|
2058
|
-
auto = [(f"retired:{alias}:{r['at']}", "", "",
|
|
2059
|
-
f"{alias} · {r.get('served') or '?'} via {tags.get(r.get('served', ''), '').strip('?') or r.get('endpoint') or endpoints.default()}"
|
|
2060
|
-
f" · {r['at'][:10]}",
|
|
2061
|
-
r.get("why", "").removeprefix("auto: "),
|
|
2062
|
-
f"<button class='btn small' data-act='restore' data-alias='{esc(alias)}'>Restore</button>")
|
|
2063
|
-
for alias, r in sorted(retired().items(), key=lambda kv: kv[1].get("at", ""), reverse=True)
|
|
2061
|
+
if a.get("kind") == "connection":
|
|
2062
|
+
g = group(served)
|
|
2063
|
+
text, url = said(a.get("action", ""))
|
|
2064
|
+
g["held"].append({"said": text, "url": url, "title": a.get("action", "")})
|
|
2065
|
+
for held in a.get("aliases", []):
|
|
2066
|
+
if held in live:
|
|
2067
|
+
g["rows"].setdefault(held, row(held, key, a))
|
|
2068
|
+
elif alias: # its own alert says more than a held connection's list
|
|
2069
|
+
group((tag_for(alias) or a.get("endpoint") or "?").split("/")[0])["rows"][alias] = row(alias, key, a)
|
|
2070
|
+
else: # a notice about no one model (NVIDIA's limits)
|
|
2071
|
+
group("Notices")["rows"][key] = {"alias": "", "key": key, "label": a.get("kind", "?"), "css": "warn",
|
|
2072
|
+
"days": 99, "said": a.get("message", ""), "url": "",
|
|
2073
|
+
"title": a.get("action", ""), "dismiss": a.get("dismiss")}
|
|
2074
|
+
for g in groups.values():
|
|
2075
|
+
g["rows"] = sorted(g["rows"].values(), key=lambda r: (r["days"], r["alias"]))
|
|
2076
|
+
order = sorted(groups.items(), key=lambda kv: (kv[0] == "Notices", min([r["days"] for r in kv[1]["rows"]] or [99]),
|
|
2077
|
+
-len(kv[1]["rows"]), kv[0]))
|
|
2078
|
+
cutoff = (datetime.date.today() - datetime.timedelta(days=7)).isoformat()
|
|
2079
|
+
auto = [(alias, r) for alias, r in sorted(retired().items(), key=lambda kv: kv[1].get("at", ""), reverse=True)
|
|
2064
2080
|
if r.get("by") == "auto" and r.get("at", "") >= cutoff]
|
|
2065
|
-
|
|
2081
|
+
return dict(order), auto
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def open_count(groups: dict) -> int:
|
|
2085
|
+
"""Models and connections waiting on a person: the sidebar's count."""
|
|
2086
|
+
return sum(len(g["rows"]) + len(g["held"]) for g in groups.values())
|
|
2087
|
+
|
|
2088
|
+
|
|
2089
|
+
def and_list(parts: list[str]) -> str:
|
|
2090
|
+
return " and ".join(parts) if len(parts) < 3 else ", ".join(parts[:-1]) + " and " + parts[-1]
|
|
2091
|
+
|
|
2092
|
+
|
|
2093
|
+
def actions_line(groups: dict) -> str:
|
|
2094
|
+
"""At a glance's one line about Action needed, when anything is open:
|
|
2095
|
+
what is waiting and how soon, and the way to its page."""
|
|
2096
|
+
rows = [r for g in groups.values() for r in g["rows"]]
|
|
2097
|
+
held = sum(len(g["held"]) for g in groups.values())
|
|
2098
|
+
if not rows and not held:
|
|
2066
2099
|
return ""
|
|
2100
|
+
models = sum(1 for r in rows if r["alias"])
|
|
2101
|
+
what = and_list([f"{n} {word}{'s' * (n != 1)}" for n, word in
|
|
2102
|
+
((models, "model"), (held, "connection"), (len(rows) - models, "notice")) if n])
|
|
2103
|
+
soon = min(r["days"] for r in rows) if rows else 99
|
|
2104
|
+
many = sum(r["days"] == soon for r in rows)
|
|
2105
|
+
when = (f"; {many} retire{'s' * (many == 1)} in {soon} day{'s' * (soon != 1)}" if soon < 99 else "")
|
|
2106
|
+
return (f"<p class='a-line'><strong>Action needed</strong> <span>{what}{when}.</span>"
|
|
2107
|
+
" <button type='button' class='linkish' data-go='needs'>Review →</button></p>")
|
|
2108
|
+
|
|
2109
|
+
|
|
2110
|
+
def actions_page(groups: dict, auto: list) -> str:
|
|
2111
|
+
"""Action needed, its own page since 2026-11-02: it sat above every page
|
|
2112
|
+
and grew to a screen and more of red. Grouped by connection, because
|
|
2113
|
+
one cause usually reaches every model on it (nine models on one free
|
|
2114
|
+
tier's rate limit); each model one line, its error in the provider's own
|
|
2115
|
+
words, with Retry a click away."""
|
|
2116
|
+
def link(url: str) -> str:
|
|
2117
|
+
if not url:
|
|
2118
|
+
return ""
|
|
2119
|
+
shown = re.sub(r"^https?://", "", url).split("?")[0]
|
|
2120
|
+
return f" <a href='{esc(url)}' target='_blank' rel='noopener'>{esc(shown)} ↗</a>"
|
|
2121
|
+
|
|
2122
|
+
def model_row(r: dict) -> str:
|
|
2123
|
+
if not r["alias"]: # a notice: what it says, and Got it if it goes once seen
|
|
2124
|
+
got = (f"<button class='linkish' data-act='dismiss' data-key='{esc(r['key'])}'>Got it</button>"
|
|
2125
|
+
if r.get("dismiss") else "")
|
|
2126
|
+
return (f"<div class='a-row' data-row><span class='what'>{esc(r['said'])}</span>"
|
|
2127
|
+
f"<span>{chip(r['label'], r['css'])}</span><span class='why'>{esc(r['title'])}</span>"
|
|
2128
|
+
f"<span class='acts'>{got}</span></div>")
|
|
2129
|
+
alias = esc(r["alias"])
|
|
2130
|
+
return (f"<div class='a-row' data-row><a href='#' class='model' data-alias='{alias}'>"
|
|
2131
|
+
f"<span class='alias'>{alias}</span></a><span>{chip(r['label'], r['css'])}</span>"
|
|
2132
|
+
f"<span class='why' title='{esc(r['title'])}'><span class='said'>{esc(r['said'])}</span>{link(r['url'])}</span>"
|
|
2133
|
+
f"<span class='acts'><button class='linkish' data-act='retry' data-alias='{alias}'"
|
|
2134
|
+
" title='One call now; an answer clears the alert'>Retry</button>"
|
|
2135
|
+
f"<button class='linkish danger' data-act='retire' data-alias='{alias}'"
|
|
2136
|
+
f" data-why='{esc(r['why'] or r['label'])}'>Retire</button></span></div>")
|
|
2137
|
+
|
|
2138
|
+
def section(conn: str, g: dict) -> str:
|
|
2139
|
+
aliases = [r["alias"] for r in g["rows"] if r["alias"]]
|
|
2140
|
+
both = (f"<button class='btn small' data-act='retry' data-aliases='{esc(' '.join(aliases))}'"
|
|
2141
|
+
f" title='One call each'>Retry all {len(aliases)}</button>"
|
|
2142
|
+
f"<button class='btn small danger' data-act='retire' data-aliases='{esc(' '.join(aliases))}'"
|
|
2143
|
+
f" data-why='every model on {esc(conn)} needed action'>Retire all {len(aliases)}</button>"
|
|
2144
|
+
if len(aliases) > 1 else "")
|
|
2145
|
+
held = "".join(
|
|
2146
|
+
f"<div class='a-held' title='{esc(h['title'])}'><span class='chip'>held</span><span>Every model"
|
|
2147
|
+
f" here is failing, likely the connection (key, credit, prefix), so nothing was retired."
|
|
2148
|
+
f" Latest: {esc(h['said'])}{link(h['url'])}</span></div>" for h in g["held"])
|
|
2149
|
+
n = len(g["rows"])
|
|
2150
|
+
return (f"<div class='a-group'><div class='a-head' data-row><span class='conn'>{esc(conn)}</span>"
|
|
2151
|
+
f"<span class='count'>{n} {'notice' if conn == 'Notices' else 'model'}{'s' * (n != 1)}</span>"
|
|
2152
|
+
f"<span class='why'></span><span class='acts'>{both}</span></div>{held}"
|
|
2153
|
+
f"<div class='a-rows'>{''.join(model_row(r) for r in g['rows'])}</div></div>")
|
|
2067
2154
|
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
#
|
|
2078
|
-
folded = (f"<details><summary>Retired automatically this week · {len(auto)}"
|
|
2079
|
-
" <span class='muted'>— each can be restored</span></summary>"
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
"
|
|
2084
|
-
"
|
|
2085
|
-
" title='Fold this away until something new needs you, or for 12 hours'>Hide</button></div>"
|
|
2086
|
-
+ ("<p>States that will not fix themselves.</p>" if items
|
|
2087
|
-
else "<p>Nothing open.</p>")
|
|
2088
|
-
+ f"{render_rows(items)}{folded}</section>")
|
|
2155
|
+
tags = load_json(DATA / "config" / "provider-tags.json") # a retired model's connection, by served name
|
|
2156
|
+
gone = "".join(
|
|
2157
|
+
f"<div class='a-row' data-row><a href='#' class='model' data-alias='{esc(alias)}'>"
|
|
2158
|
+
f"<span class='alias'>{esc(alias)}</span></a><span class='when'>{esc(r['at'][:10])}</span>"
|
|
2159
|
+
f"<span class='why' title='{esc(r.get('why', ''))}'><span class='said'>"
|
|
2160
|
+
f"{esc(tags.get(r.get('served', ''), '').strip('?') or r.get('endpoint') or endpoints.default())}"
|
|
2161
|
+
f" · {esc(said(r.get('why', '').removeprefix('auto: '))[0])}</span></span>"
|
|
2162
|
+
f"<span class='acts'><button class='linkish' data-act='restore' data-alias='{esc(alias)}'>Restore</button></span></div>"
|
|
2163
|
+
for alias, r in auto)
|
|
2164
|
+
# Folded: a week of automatic retirements is a record to check, not a list of things to do.
|
|
2165
|
+
folded = (f"<details class='a-retired'><summary>Retired automatically this week · {len(auto)}"
|
|
2166
|
+
f" <span class='muted'>— each can be restored</span></summary><div class='a-rows'>{gone}</div></details>"
|
|
2167
|
+
if auto else "")
|
|
2168
|
+
body = "".join(section(conn, g) for conn, g in groups.items()) or "<p class='a-none'>Nothing open.</p>"
|
|
2169
|
+
return ("<div class='panel'><h2>Action needed</h2><p class='sub'>States that will not fix themselves, by the"
|
|
2170
|
+
" connection they come from. A reason's full error is on hover, and a name opens the model."
|
|
2171
|
+
f" Retry spends one call; an answer clears the alert.</p>{body}{folded}</div>")
|
|
2089
2172
|
|
|
2090
2173
|
|
|
2091
2174
|
CAP_LABELS = {"tools": "tools", "vision": "vision", "structured_output": "json", "reasoning": "reasons"}
|
|
@@ -2831,7 +2914,7 @@ def render(theme: str | None = None) -> str:
|
|
|
2831
2914
|
"<button class='btn primary' id='onboard-open'>Onboard a model</button></div></header>"
|
|
2832
2915
|
# What needs a person first, then the pages; endpoints are managed from
|
|
2833
2916
|
# Connections now (the header button moved there).
|
|
2834
|
-
f"<main>{first_run()}
|
|
2917
|
+
f"<main>{first_run()}<div class='layout'>{tabs()}</div></main>{editor()}"
|
|
2835
2918
|
f"<script>window.BENCH={directory_json()};</script>"
|
|
2836
2919
|
# Observable Plot (on d3) draws the Benchmarks charts. Pinned versions from
|
|
2837
2920
|
# jsDelivr; without a network the tables under each chart still work.
|
|
@@ -3233,6 +3316,7 @@ def collections_tab() -> str:
|
|
|
3233
3316
|
# pages were too many for a row of tabs. Collections holds the gateway's
|
|
3234
3317
|
# requests under the collections they ran; Benchmarks holds Performance.
|
|
3235
3318
|
SECTIONS = (("Observe", (("glance", "At a glance", "roster"),
|
|
3319
|
+
("needs", "Action needed", "actions_page"),
|
|
3236
3320
|
("suite", "Benchmarks", "benchmarks_tab"),
|
|
3237
3321
|
("quotas", "Quota management", "quotas_page"),
|
|
3238
3322
|
("logs", "Logs", "logs_page"))),
|
|
@@ -3251,11 +3335,21 @@ def collections_and_gateway() -> str:
|
|
|
3251
3335
|
|
|
3252
3336
|
|
|
3253
3337
|
def tabs() -> str:
|
|
3338
|
+
# Read once a render: Action needed's page, its count in the sidebar and
|
|
3339
|
+
# At a glance's line about it.
|
|
3340
|
+
groups, auto = open_actions()
|
|
3341
|
+
count = {"needs": open_count(groups)}
|
|
3342
|
+
page = {"needs": lambda: actions_page(groups, auto), "glance": lambda: actions_line(groups) + roster()}
|
|
3343
|
+
|
|
3344
|
+
def button(key: str, label: str) -> str:
|
|
3345
|
+
n = count.get(key)
|
|
3346
|
+
return (f"<button role='tab' data-tab='{key}' aria-selected='false'"
|
|
3347
|
+
+ (f" aria-label='{esc(label)}, {n} open'>{esc(label)}<span class='n' aria-hidden='true'>{n}</span>"
|
|
3348
|
+
if n else f">{esc(label)}") + "</button>")
|
|
3254
3349
|
nav = "".join(f"<div class='group'><h2>{esc(group)}</h2>"
|
|
3255
|
-
+ "".join(
|
|
3256
|
-
for key, label, _ in pages) + "</div>"
|
|
3350
|
+
+ "".join(button(key, label) for key, label, _ in pages) + "</div>"
|
|
3257
3351
|
for group, pages in SECTIONS)
|
|
3258
|
-
panes = "".join(f"<section class='tab' id='tab-{key}'>{globals()[fn]()}</section>"
|
|
3352
|
+
panes = "".join(f"<section class='tab' id='tab-{key}'>{page[key]() if key in page else globals()[fn]()}</section>"
|
|
3259
3353
|
for key, _, fn in TABS)
|
|
3260
3354
|
return f"<nav class='tabs' role='tablist' aria-label='Pages'>{nav}</nav><div class='panes'>{panes}</div>"
|
|
3261
3355
|
|
package/suite/run.py
CHANGED
|
@@ -554,6 +554,7 @@ STRUCTURAL = (
|
|
|
554
554
|
"unknown model",
|
|
555
555
|
"invalid model",
|
|
556
556
|
"model_not_found",
|
|
557
|
+
"no model found", # Routeway: "No model found: <id>"
|
|
557
558
|
)
|
|
558
559
|
# ENTITLEMENT is its own class, and the difference decides what to DO.
|
|
559
560
|
# `structural` means the model is not there and the registration should go.
|
|
@@ -631,6 +632,9 @@ CAPACITY = (
|
|
|
631
632
|
# way, filed as `other`, which the 3-day auto-retire rule would act on.
|
|
632
633
|
"request limit reached",
|
|
633
634
|
"resourceexhausted",
|
|
635
|
+
# xKiro: "The free usage quota for this model is used up" (503). Out of
|
|
636
|
+
# quota, not gone; filed as `other` it was 3 days from auto-retire.
|
|
637
|
+
"free usage quota",
|
|
634
638
|
)
|
|
635
639
|
|
|
636
640
|
|
package/ui/bench.css
CHANGED
|
@@ -99,33 +99,57 @@ button { font: inherit; cursor: pointer; }
|
|
|
99
99
|
/* ---- layout --------------------------------------------------------- */
|
|
100
100
|
main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter) 4rem; }
|
|
101
101
|
|
|
102
|
-
/*
|
|
102
|
+
/* first run: a fresh install's next step, above every page until it is done */
|
|
103
103
|
.problems { background: var(--sheet, var(--panel)); border-radius: 14px; padding: 1rem 1.4rem; margin: .5rem 0 1.5rem;
|
|
104
104
|
box-shadow: 0 1px 2px rgb(0 0 0 / .04), 0 12px 32px -14px rgb(0 0 0 / .14);
|
|
105
105
|
-webkit-backdrop-filter: var(--glass, none); backdrop-filter: var(--glass, none); }
|
|
106
106
|
.problems h2 { margin: 0 0 .25rem; font-size: 1.25rem; font-weight: 700; }
|
|
107
|
-
.problems > p { margin: 0 0 .5rem; color: var(--muted); font-size: .9375rem; }
|
|
108
107
|
.problem { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .25rem 1rem;
|
|
109
108
|
align-items: center; padding: .55rem .75rem; margin: 0 -.75rem; border-radius: var(--radius); }
|
|
110
109
|
.problem:hover { background: var(--step-1); }
|
|
111
|
-
.problems details { margin-top: .5rem; }
|
|
112
|
-
.problems details > summary { cursor: pointer; font-weight: 500; padding: .4rem 0; }
|
|
113
|
-
.problems details > summary .muted { font-weight: 400; }
|
|
114
|
-
.problem .what { font-weight: 500; }
|
|
115
110
|
.problem .why { color: var(--muted); font-size: .875rem; overflow-wrap: anywhere; }
|
|
116
111
|
.problem .acts, .endpoint .acts, .ep .acts { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: flex-end; }
|
|
117
112
|
.row-input { display: flex; gap: .5rem; align-items: center; margin-top: .35rem; }
|
|
118
113
|
.row-input input { flex: 1; min-width: 0; margin: 0 !important; }
|
|
119
114
|
.sub-head { font-weight: 600; margin: 1rem 0 .25rem; }
|
|
120
|
-
.problem .chip { margin-right: .4rem; }
|
|
121
115
|
.problems .head { display: flex; align-items: baseline; gap: .5rem; }
|
|
122
116
|
.problems .head h2 { margin-bottom: .25rem; }
|
|
123
|
-
|
|
124
|
-
/*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
.
|
|
128
|
-
.
|
|
117
|
+
|
|
118
|
+
/* Action needed: its own page, by connection. Each model is one line of a
|
|
119
|
+
grid shared by its group (name, state, the provider's words, actions), so
|
|
120
|
+
the columns line up down the group. */
|
|
121
|
+
.a-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; margin: 1.4rem 1.6rem 0;
|
|
122
|
+
padding: .45rem .75rem; background: var(--err-ground); border-radius: var(--radius); }
|
|
123
|
+
.a-group { padding: 1rem 0 .75rem; border-top: 1px solid var(--line); }
|
|
124
|
+
.a-group:first-of-type { border-top: 0; padding-top: .5rem; }
|
|
125
|
+
.a-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; margin-bottom: .4rem; }
|
|
126
|
+
.a-head .conn { font-size: 1.0625rem; font-weight: 700; }
|
|
127
|
+
.a-head .count { color: var(--muted); }
|
|
128
|
+
.a-head .why { font-size: .875rem; }
|
|
129
|
+
.a-head .why:empty { display: none; }
|
|
130
|
+
.a-head .acts { margin-left: auto; display: flex; gap: .5rem; }
|
|
131
|
+
.a-held { display: flex; gap: .6rem; align-items: baseline; margin: .35rem 0 .6rem; padding: .5rem .75rem;
|
|
132
|
+
background: var(--warn-ground); border-radius: var(--radius); font-size: .9375rem; overflow-wrap: anywhere; }
|
|
133
|
+
.a-held .chip { flex: none; background: var(--ink); color: var(--paper); }
|
|
134
|
+
.a-held a { color: var(--ink); font-weight: 500; }
|
|
135
|
+
.a-rows { display: grid; grid-template-columns: minmax(10rem, max-content) max-content minmax(0, 1fr) max-content;
|
|
136
|
+
gap: 0 1rem; align-items: baseline; }
|
|
137
|
+
.a-row { display: contents; }
|
|
138
|
+
.a-row > * { padding: .3rem 0; border-top: 1px solid color-mix(in oklab, var(--line) 55%, transparent); }
|
|
139
|
+
.a-row:first-child > * { border-top: 0; }
|
|
140
|
+
.a-row .alias, .a-row .what { font-weight: 500; }
|
|
141
|
+
.a-row .what { max-width: 24rem; } /* a notice's sentence wraps, leaving its detail room */
|
|
142
|
+
.a-row .why, .a-row .when { color: var(--muted); font-size: .875rem; overflow-wrap: anywhere; }
|
|
143
|
+
/* One line a model: the provider's words cut to the column, whole on hover; its link stays whole. */
|
|
144
|
+
.a-row .why:has(> .said) { display: flex; gap: .5rem; min-width: 0; }
|
|
145
|
+
.a-row .said { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
146
|
+
.a-row .why > a { flex: none; }
|
|
147
|
+
.a-row .acts { display: flex; gap: .9rem; font-size: .875rem; }
|
|
148
|
+
.linkish.danger { color: var(--err); }
|
|
149
|
+
.a-retired { margin-top: .75rem; border-top: 1px solid var(--line); padding-top: .5rem; }
|
|
150
|
+
.a-retired > summary { cursor: pointer; font-weight: 500; padding: .35rem 0; }
|
|
151
|
+
.a-retired > summary .muted { color: var(--muted); font-weight: 400; }
|
|
152
|
+
.a-none { color: var(--muted); }
|
|
129
153
|
|
|
130
154
|
/* pages: a grouped sidebar (Observe, Gateway, Control); the group names and
|
|
131
155
|
the active page carry the theme's brand colour. A theme with a floor backs
|
|
@@ -156,6 +180,12 @@ main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter)
|
|
|
156
180
|
content: ""; position: absolute; left: calc(100% + .3em); top: -.05em; width: .75em; height: .75em;
|
|
157
181
|
background: var(--brand);
|
|
158
182
|
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%); }
|
|
183
|
+
/* A page's count (Action needed): hung outside the button like the sparkle,
|
|
184
|
+
so the active page's stroke runs under the word and not the number. */
|
|
185
|
+
.tabs button:has(> .n) { position: relative; }
|
|
186
|
+
.tabs button .n { position: absolute; left: calc(100% + .4rem); top: 50%; transform: translateY(-50%);
|
|
187
|
+
padding: .05rem .4rem; border-radius: 999px; font: 700 .75rem/1.25 var(--font-mono);
|
|
188
|
+
background: var(--err-ground); color: var(--err); }
|
|
159
189
|
.tab { display: none; } .tab.active { display: block; }
|
|
160
190
|
/* One surface for the page's content: its panels are sections of it,
|
|
161
191
|
divided by a hairline, rather than separate boxes with gaps between. A
|
|
@@ -170,6 +200,7 @@ main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter)
|
|
|
170
200
|
.tabs { position: static; flex-direction: row; flex-wrap: wrap; gap: .5rem 1.5rem; }
|
|
171
201
|
.tabs .group { flex-direction: row; flex-wrap: wrap; align-items: baseline; gap: .1rem 1rem; }
|
|
172
202
|
.tabs h2 { margin: 0; }
|
|
203
|
+
.tabs button:has(> .n) { margin-right: 2rem; } /* room for its count in a row */
|
|
173
204
|
}
|
|
174
205
|
|
|
175
206
|
/* settings, connections, logs, info */
|
|
@@ -510,6 +541,12 @@ dialog .pick { display: flex; gap: 1.5rem; }
|
|
|
510
541
|
.toolbar input[type=search] { min-width: 0; width: 100%; }
|
|
511
542
|
button.c-row { grid-template-columns: minmax(0, 1fr); } /* the seats get the width; the whole row opens */
|
|
512
543
|
.c-row .open-hint { display: none; }
|
|
544
|
+
.a-line { margin: 1rem 1rem 0; }
|
|
545
|
+
.a-rows { grid-template-columns: minmax(0, 1fr) max-content; } /* name and state; the words and actions under them */
|
|
546
|
+
.a-row .why, .a-row .acts { grid-column: 1 / -1; padding-top: 0; border-top: 0; }
|
|
547
|
+
.a-row .acts { padding-bottom: .5rem; }
|
|
548
|
+
.a-row .why:has(> .said) { flex-wrap: wrap; }
|
|
549
|
+
.a-row .said { white-space: normal; } /* a phone has the height to read it whole */
|
|
513
550
|
}
|
|
514
551
|
@media (prefers-reduced-motion: reduce) {
|
|
515
552
|
* { transition: none !important; }
|
package/ui/bench.js
CHANGED
|
@@ -123,26 +123,10 @@
|
|
|
123
123
|
c.addEventListener(c.tagName === 'SELECT' ? 'change' : 'input', function () { applyFilter(scope); });
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
// ----
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
var keys = Array.prototype.map.call(acts.querySelectorAll('.problem[data-key]'), function (p) { return p.dataset.key; });
|
|
131
|
-
var hid = null;
|
|
132
|
-
try { hid = JSON.parse(store('bench.actions.hidden') || 'null'); } catch (err) { hid = null; }
|
|
133
|
-
var fold = function (on) {
|
|
134
|
-
acts.classList.toggle('collapsed', on);
|
|
135
|
-
$('actions-hide').textContent = on ? 'show' : 'Hide';
|
|
136
|
-
$('actions-hide').setAttribute('aria-expanded', String(!on));
|
|
137
|
-
};
|
|
138
|
-
fold(!!(hid && hid.keys && Date.now() - hid.at < 12 * 3600 * 1000
|
|
139
|
-
&& keys.every(function (k) { return hid.keys.indexOf(k) !== -1; })));
|
|
140
|
-
$('actions-hide').addEventListener('click', function () {
|
|
141
|
-
var on = !acts.classList.contains('collapsed');
|
|
142
|
-
store('bench.actions.hidden', on ? JSON.stringify({at: Date.now(), keys: keys}) : '');
|
|
143
|
-
fold(on);
|
|
144
|
-
});
|
|
145
|
-
}
|
|
126
|
+
// ---- a link to another page (At a glance's line to Action needed) ----
|
|
127
|
+
document.querySelectorAll('[data-go]').forEach(function (b) {
|
|
128
|
+
b.addEventListener('click', function () { show(b.dataset.go); scrollTo(0, 0); });
|
|
129
|
+
});
|
|
146
130
|
|
|
147
131
|
// ---- retired rows: hidden unless asked for ---------------------------
|
|
148
132
|
var showRetired = store('bench.retired') === '1';
|
|
@@ -409,8 +393,7 @@
|
|
|
409
393
|
document.querySelectorAll('[data-act]').forEach(function (b) {
|
|
410
394
|
b.addEventListener('click', function () {
|
|
411
395
|
var act = b.dataset.act, aliases = (b.dataset.aliases || b.dataset.alias || '').split(' ').filter(Boolean);
|
|
412
|
-
var status = b.closest('
|
|
413
|
-
if (act === 'open') { openModel(aliases[0]); return; }
|
|
396
|
+
var status = b.closest('[data-row]').querySelector('.why'); // a model's line, or its group's head
|
|
414
397
|
if (act === 'dismiss') { // a notice: gone once seen
|
|
415
398
|
b.disabled = true;
|
|
416
399
|
post('/api/alerts/dismiss', {key: b.dataset.key}).then(function (res) {
|
|
@@ -419,12 +402,22 @@
|
|
|
419
402
|
}).catch(function (err) { status.textContent = String(err); b.disabled = false; });
|
|
420
403
|
return;
|
|
421
404
|
}
|
|
422
|
-
if (act === 'retry') { // no confirm: one call, and only an answer changes anything
|
|
405
|
+
if (act === 'retry') { // no confirm: one call each, and only an answer changes anything
|
|
406
|
+
var label = b.textContent, said = [], failed = [];
|
|
423
407
|
b.disabled = true; b.textContent = 'Asking…';
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
408
|
+
aliases.reduce(function (chain, alias) {
|
|
409
|
+
return chain.then(function () {
|
|
410
|
+
return post('/api/retry', {alias: alias}).then(function (res) {
|
|
411
|
+
(res.ok ? said : failed).push(res.ok ? res.changed.join(' · ') : res.errors.join('; '));
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
}, Promise.resolve()).then(function () {
|
|
415
|
+
// A group's Retry all says how many answered; each failure is on hover, not ten lines in its heading.
|
|
416
|
+
var told = aliases.length > 1 ? said.length + ' of ' + aliases.length + ' answered' : said.concat(failed).join(' · ');
|
|
417
|
+
status.title = failed.join('\n');
|
|
418
|
+
if (!said.length) { status.textContent = told; status.classList.add('err'); b.disabled = false; b.textContent = label; return; }
|
|
419
|
+
status.textContent = told + '. Reloading…'; setTimeout(function () { location.reload(); }, 900);
|
|
420
|
+
}).catch(function (err) { status.textContent = String(err); b.disabled = false; b.textContent = label; });
|
|
428
421
|
return;
|
|
429
422
|
}
|
|
430
423
|
var verb = act === 'restore' ? 'Restore' : 'Retire';
|