@wendellresearch-ai/benchwarmers 0.6.3 → 0.6.4
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/AGENTS.md +3 -1
- package/BENCHMARKS.md +1 -1
- package/README.md +2 -0
- package/USAGE.md +4 -6
- package/VERSION +1 -1
- package/package.json +1 -1
- package/scripts/check.py +1 -0
- package/scripts/clients_doc.py +98 -0
- package/serve.py +32 -212
- package/ui/bench.css +9 -5
- package/ui/bench.js +76 -9
package/AGENTS.md
CHANGED
|
@@ -124,7 +124,9 @@ in `bench.skipped`. One the catalog knows nothing about sits once its model
|
|
|
124
124
|
popup ticks Vision / OCR.
|
|
125
125
|
After the person checks an answer against the real work, record a verdict
|
|
126
126
|
(dashboard, or `POST /bench/verdict`); verdicts choose the next panels. See
|
|
127
|
-
GATEWAY.md for the whole contract.
|
|
127
|
+
GATEWAY.md for the whole contract. To set an app up on the gateway (opencode,
|
|
128
|
+
VS Code, Codex, Zed, Continue, Cline, Kilo Code, Claude Code, goose, chat
|
|
129
|
+
apps), CLIENTS.md has each one's configuration, file by file.
|
|
128
130
|
|
|
129
131
|
## Beyond this machine
|
|
130
132
|
|
package/BENCHMARKS.md
CHANGED
|
@@ -70,7 +70,7 @@ earlier scores on the same tasks, over the last 30 days (or 60).
|
|
|
70
70
|
|
|
71
71
|
Whether the provider was there when the bench called it, from the calls the
|
|
72
72
|
bench makes anyway: one cell a day, by provider and then by model, over the
|
|
73
|
-
last 7 days (or 30).
|
|
73
|
+
last 7 days (or 30).
|
|
74
74
|
|
|
75
75
|
- **Down**: the provider was busy or overloaded (a 5xx, high demand, a shared
|
|
76
76
|
pool's 429), unreachable (a broken stream, no reply), timed out (the
|
package/README.md
CHANGED
|
@@ -419,6 +419,8 @@ collections and MCP server, with a Copy button: opencode, VS Code chat, Codex
|
|
|
419
419
|
CLI, Zed, Continue, Cline and Kilo Code, Claude Code, goose, and chat apps
|
|
420
420
|
(LobeHub, AnythingLLM, Cherry Studio, Chatbox, LibreChat). Where keys are
|
|
421
421
|
asked for, it makes one and fills it in, never into a file that may sync.
|
|
422
|
+
The same set-ups are in [CLIENTS.md](CLIENTS.md), with a fresh install's
|
|
423
|
+
address and collections, for reading here or handing to an agent.
|
|
422
424
|
|
|
423
425
|
<!-- The MCP Registry's proof that the PyPI package is this server (server.json):
|
|
424
426
|
mcp-name: io.github.WendellResearch-AI/benchwarmers -->
|
package/USAGE.md
CHANGED
|
@@ -25,15 +25,14 @@ coding agent doing it with you); the gateway's whole contract is in
|
|
|
25
25
|
|
|
26
26
|
| Group | Page | What it is for |
|
|
27
27
|
| --- | --- | --- |
|
|
28
|
-
| Observe | **
|
|
28
|
+
| Observe | **Benchmarks** | The home page. How models do on the suite and on real work: bugs found against false alarms, who is confidently wrong, each model against its own earlier scores, uptime, speed, and the decision models. Each report shares as an image or its table. |
|
|
29
29
|
| | **Action needed** | What will not fix itself, by the connection it comes from, with Retry and Retire on each model. |
|
|
30
|
-
| | **Benchmarks** | How models do on the suite and on real work: bugs found against false alarms, who is confidently wrong, each model against its own earlier scores, uptime, speed, and the decision models. Each report shares as an image or its table. |
|
|
31
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. |
|
|
32
31
|
| | **Logs** | Gateway calls (who sat and how it went, never the prompt), changes made from the dashboard, the scheduler, and onboarding runs. |
|
|
33
32
|
| Gateway | **Endpoints** | Where models are served from. Add an endpoint (a provider's sign-in, or any OpenAI-compatible API key), change a key, and see what each one serves. A model server running on this machine, at its usual port or any other its process is listening on (LM Studio, Jan, llama.cpp, vLLM and the like), is offered under Found on this machine: Add opens the dialog with it filled in, to test and save. Also your own app registrations and rate limits per connection. |
|
|
34
|
-
| | **Models** | Every model on the bench, by the connection it comes from. |
|
|
33
|
+
| | **Models** | Every model on the bench, by the connection it comes from: what it holds, its reply time and its limits. One search above every table, each table folds, and Collapse all / Expand all. Click a name for its popup: role, alias, facts, options, retire. |
|
|
35
34
|
| | **Collections** | The named panels your apps ask for, and the gateway's settings. |
|
|
36
|
-
| | **Clients** | Each popular app's set-up (opencode, VS Code, Codex, Zed, Continue, Cline, Kilo Code, Claude Code, goose, chat apps), filled in from this install, with Copy and a gateway key made on the spot. |
|
|
35
|
+
| | **Clients** | Each popular app's set-up (opencode, VS Code, Codex, Zed, Continue, Cline, Kilo Code, Claude Code, goose, chat apps), filled in from this install, with Copy and a gateway key made on the spot. [CLIENTS.md](CLIENTS.md) has the same, for a fresh install. |
|
|
37
36
|
| | **Discover** | Models worth adding, from public catalogs, with the ways this install can reach each one. |
|
|
38
37
|
| Control | **Settings** | Theme, gateway keys, the scheduler, the model client, providers whose free models work only in their own app, export and import, how the install is reached (shown only), and Restart. |
|
|
39
38
|
| | **Info** | The source repository, the version (linking its release), Python, data folder and counts. |
|
|
@@ -52,8 +51,7 @@ clears the alert) and Retire. A group of two or more has Retry all and Retire
|
|
|
52
51
|
all; a connection held because every model on it fails says so above its
|
|
53
52
|
models, with any link the provider gave. Automatic retirements from the last
|
|
54
53
|
week are folded at the end, each with Restore. Its count sits beside it in the
|
|
55
|
-
sidebar
|
|
56
|
-
soon. An alert whose models have all been retired goes at once. A model that
|
|
54
|
+
sidebar. An alert whose models have all been retired goes at once. A model that
|
|
57
55
|
is out of quota is not counting down: days the bench held its calls back until
|
|
58
56
|
a stated reset, an account's shared free quota was spent, or the provider said
|
|
59
57
|
the model's free quota is used up, pause its clock, and while it is still out
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.6.
|
|
1
|
+
0.6.4
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
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/check.py
CHANGED
|
@@ -49,6 +49,7 @@ CHECKS = [
|
|
|
49
49
|
("scripts/onboard.py", ["--selftest"], False),
|
|
50
50
|
("scripts/export_check.py", ["--selftest"], False),
|
|
51
51
|
("scripts/notice.py", ["--check"], False),
|
|
52
|
+
("scripts/clients_doc.py", ["--check"], False), # CLIENTS.md is what the Clients page shows
|
|
52
53
|
("scripts/check_themes.py", [], False),
|
|
53
54
|
("scripts/test_owui.py", [], False),
|
|
54
55
|
("scripts/test_hubs.py", [], False),
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Write CLIENTS.md from the dashboard's Clients page, so each app's set-up is in
|
|
2
|
+
the repository for people and agents to read, and never drifts from the page.
|
|
3
|
+
|
|
4
|
+
python scripts/clients_doc.py # write CLIENTS.md
|
|
5
|
+
python scripts/clients_doc.py --check # fail if CLIENTS.md is not what it would write (check.py)
|
|
6
|
+
|
|
7
|
+
One source: serve.client_setups, the function the Clients page draws. The page
|
|
8
|
+
fills in this install (its address, its collections, a key made on the spot);
|
|
9
|
+
here they are the defaults a fresh install has: the server at 127.0.0.1:8122,
|
|
10
|
+
collections `core` and `fast`, and every call needing a gateway key, read from
|
|
11
|
+
BENCHWARMERS_KEY. The page's HTML (code, links) becomes Markdown.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import html
|
|
17
|
+
import os
|
|
18
|
+
import pathlib
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
|
|
23
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
24
|
+
OUT = ROOT / "CLIENTS.md"
|
|
25
|
+
COLLECTIONS = [("core", None, False), ("fast", None, False)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def md(text: str) -> str:
|
|
29
|
+
"""The page's inline HTML as Markdown: code, links, line breaks; any other tag dropped."""
|
|
30
|
+
text = re.sub(r"<code>(.*?)</code>", lambda m: "`" + html.unescape(m.group(1)) + "`", text)
|
|
31
|
+
text = re.sub(r"<a [^>]*href='([^']*)'[^>]*>(.*?)</a>", r"[\2](\1)", text)
|
|
32
|
+
text = re.sub(r"<br\s*/?>", " \n", text)
|
|
33
|
+
return html.unescape(re.sub(r"<[^>]+>", "", text))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fence(lang: str, text: str) -> str:
|
|
37
|
+
tick = "````" if "```" in text else "```"
|
|
38
|
+
return f"{tick}{lang}\n{text}\n{tick}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render() -> str:
|
|
42
|
+
# serve reads the data folder at import: an empty temp one, never the install's.
|
|
43
|
+
os.environ.setdefault("BENCHWARMERS_HOME", tempfile.mkdtemp(prefix="bench-clients-doc-"))
|
|
44
|
+
sys.path[:0] = [str(ROOT), str(ROOT / "metrics"), str(ROOT / "suite")]
|
|
45
|
+
import serve
|
|
46
|
+
root = serve.DEFAULT_SERVER
|
|
47
|
+
out = ["# Setting up clients",
|
|
48
|
+
"",
|
|
49
|
+
"Every app Benchwarmers has a ready set-up for, as the dashboard's **Clients** page shows it,"
|
|
50
|
+
" written out for people and for agents setting an app up. The page fills in your own install:"
|
|
51
|
+
" its address, your collections and a gateway key made on the spot. Here they are a fresh"
|
|
52
|
+
f" install's: the server at `{root}`, the collections `core` and `fast`, and a gateway key"
|
|
53
|
+
f" read from `{serve.KEY_ENV}`. Replace them with yours.",
|
|
54
|
+
"",
|
|
55
|
+
"Agents get a collection in route mode (one member with its tools, `bench/<collection>:route`);"
|
|
56
|
+
" chat windows a merged answer (`:merged`); the MCP server the panel itself, as ask and verdict"
|
|
57
|
+
f" tools. Gateway `{root}/v1` · MCP server `{root}/mcp`.",
|
|
58
|
+
"",
|
|
59
|
+
f"_Generated from `serve.client_setups` by `scripts/clients_doc.py`; each app's format follows its"
|
|
60
|
+
f" own docs as read on {serve.CLIENTS_READ}. Do not edit by hand: run the script._",
|
|
61
|
+
"",
|
|
62
|
+
"## The gateway key",
|
|
63
|
+
"",
|
|
64
|
+
"When every call needs a key (`BENCHWARMERS_REQUIRE_KEYS=1`, or an admin token), make one on"
|
|
65
|
+
" Settings or the Clients page. Most set-ups read it from"
|
|
66
|
+
f" `{serve.KEY_ENV}`:",
|
|
67
|
+
""]
|
|
68
|
+
for s in serve._env_snips(serve.KEY_ENV):
|
|
69
|
+
out += [f"**{s['label']}**", "", fence(s["lang"], s["text"]), ""]
|
|
70
|
+
setups = serve.client_setups(root, COLLECTIONS, True)
|
|
71
|
+
out += ["## Contents", ""] + [f"- [{c['name']}](#{c['id']})" for c in setups] + [""]
|
|
72
|
+
for c in setups:
|
|
73
|
+
out += [f"<a id='{c['id']}'></a>", "", f"## {c['name']}", "", md(c["gets"]), "",
|
|
74
|
+
f"**Where it goes:** {md(c['where'])}", ""]
|
|
75
|
+
for s in c["blocks"]:
|
|
76
|
+
out += [f"**{s['label']}**", "", fence(s["lang"], s["text"]), ""]
|
|
77
|
+
docs = ", ".join(f"[{t}]({u})" for u, t in c["docs"])
|
|
78
|
+
out += [f"**Watch for:** {md(c['watch'])}", "",
|
|
79
|
+
f"Written from {docs}, read {serve.CLIENTS_READ}"
|
|
80
|
+
+ (f": {md(c['docs_note'])}" if c.get("docs_note") else "."), ""]
|
|
81
|
+
return "\n".join(out).rstrip() + "\n"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> int:
|
|
85
|
+
text = render()
|
|
86
|
+
if "--check" in sys.argv:
|
|
87
|
+
if not OUT.exists() or OUT.read_text(encoding="utf-8") != text:
|
|
88
|
+
print("CLIENTS.md is not what serve.client_setups gives: run python scripts/clients_doc.py")
|
|
89
|
+
return 1
|
|
90
|
+
print("CLIENTS.md matches the Clients page")
|
|
91
|
+
return 0
|
|
92
|
+
OUT.write_text(text, encoding="utf-8", newline="\n")
|
|
93
|
+
print(f"wrote {OUT.name}")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|
package/serve.py
CHANGED
|
@@ -2254,27 +2254,6 @@ def open_count(groups: dict) -> int:
|
|
|
2254
2254
|
return sum(len(g["rows"]) + len(g["held"]) for g in groups.values())
|
|
2255
2255
|
|
|
2256
2256
|
|
|
2257
|
-
def and_list(parts: list[str]) -> str:
|
|
2258
|
-
return " and ".join(parts) if len(parts) < 3 else ", ".join(parts[:-1]) + " and " + parts[-1]
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
def actions_line(groups: dict) -> str:
|
|
2262
|
-
"""At a glance's one line about Action needed, when anything is open:
|
|
2263
|
-
what is waiting and how soon, and the way to its page."""
|
|
2264
|
-
rows = [r for g in groups.values() for r in g["rows"]]
|
|
2265
|
-
held = sum(len(g["held"]) for g in groups.values())
|
|
2266
|
-
if not rows and not held:
|
|
2267
|
-
return ""
|
|
2268
|
-
models = sum(1 for r in rows if r["alias"])
|
|
2269
|
-
what = and_list([f"{n} {word}{'s' * (n != 1)}" for n, word in
|
|
2270
|
-
((models, "model"), (held, "connection"), (len(rows) - models, "notice")) if n])
|
|
2271
|
-
soon = min(r["days"] for r in rows) if rows else 99
|
|
2272
|
-
many = sum(r["days"] == soon for r in rows)
|
|
2273
|
-
when = (f"; {many} retire{'s' * (many == 1)} in {soon} day{'s' * (soon != 1)}" if soon < 99 else "")
|
|
2274
|
-
return (f"<p class='a-line'><strong>Action needed</strong> <span>{what}{when}.</span>"
|
|
2275
|
-
" <button type='button' class='linkish' data-go='needs'>Review →</button></p>")
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
2257
|
def actions_page(groups: dict, auto: list) -> str:
|
|
2279
2258
|
"""Action needed, its own page since 2026-11-02: it sat above every page
|
|
2280
2259
|
and grew to a screen and more of red. Grouped by connection, because
|
|
@@ -2373,167 +2352,6 @@ def price_of(alias: str, served: str, f: dict) -> str:
|
|
|
2373
2352
|
return "paid" if sources.is_free(f.get("cost")) is False else "unknown"
|
|
2374
2353
|
|
|
2375
2354
|
|
|
2376
|
-
def facet(name: str, label: str, values) -> str:
|
|
2377
|
-
"""A filter by one column's value, beside a panel's search box (bench.js
|
|
2378
|
-
matches it against each row's data-<name>)."""
|
|
2379
|
-
opts = "".join(f"<option value='{esc(v)}'>{esc(v)}</option>" for v in sorted(set(values) - {""}))
|
|
2380
|
-
return (f"<label>{esc(label)} <select data-facet='{esc(name)}'><option value=''>any</option>"
|
|
2381
|
-
f"{opts}</select></label>")
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
def facts_cells(alias: str, f: dict | None = None) -> str:
|
|
2385
|
-
"""Context window and capabilities, from metrics/catalog.py; a value you
|
|
2386
|
-
entered is marked, and an unknown one says so rather than looking empty."""
|
|
2387
|
-
f = f or catalog.info(alias)
|
|
2388
|
-
ctx = f.get("context_window")
|
|
2389
|
-
src = f["sources"].get("context_window", "")
|
|
2390
|
-
ctx_cell = (f"<td class='num' data-sort='{ctx}' title='{esc(src)}'>"
|
|
2391
|
-
f"{ctx // 1000:,}k{'*' if src == 'you' else ''}</td>" if ctx
|
|
2392
|
-
else "<td class='num muted' data-sort='-1' title='unknown: fill it in the model popup'>?</td>")
|
|
2393
|
-
caps = [CAP_LABELS[c] for c in catalog.CAPS if f.get(c)]
|
|
2394
|
-
return ctx_cell + (f"<td class='caps'>{' '.join(chip(c, '') for c in caps)}</td>" if caps
|
|
2395
|
-
else "<td class='muted'>—</td>")
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
def roster() -> str:
|
|
2399
|
-
"""Every model on one table: its state, what it has done, what it scored.
|
|
2400
|
-
|
|
2401
|
-
Replaces a grid of offload cards that answered only "what was it used
|
|
2402
|
-
for". Status first, because the first question on opening this page is
|
|
2403
|
-
whether anything is broken; real work second; benchmarks after."""
|
|
2404
|
-
try:
|
|
2405
|
-
from offload import summary
|
|
2406
|
-
work = summary()
|
|
2407
|
-
except Exception: # noqa: BLE001
|
|
2408
|
-
work = {}
|
|
2409
|
-
try:
|
|
2410
|
-
import auto_retire
|
|
2411
|
-
rows, answers, _ = auto_retire.evidence()
|
|
2412
|
-
except Exception: # noqa: BLE001
|
|
2413
|
-
rows, answers = [], {}
|
|
2414
|
-
last = dict(answers)
|
|
2415
|
-
for r in rows:
|
|
2416
|
-
if r["answered"]:
|
|
2417
|
-
last[r["alias"]] = max(last.get(r["alias"], r["date"]), r["date"])
|
|
2418
|
-
suite: dict[str, list[int]] = {}
|
|
2419
|
-
for (_task, model), hit in suite_scores().items():
|
|
2420
|
-
if (m := re.match(r"(\d+)/(\d+)", hit["found"])):
|
|
2421
|
-
got = suite.setdefault(model, [0, 0, 0])
|
|
2422
|
-
got[0] += int(m.group(1)); got[1] += int(m.group(2)); got[2] += 1
|
|
2423
|
-
tiny = {r["model"]: float(r["p50_s"]) for r in perf_rows()
|
|
2424
|
-
if r["prompt"] == "tiny" and int(r.get("answered") or 0) > 0}
|
|
2425
|
-
status = model_status()
|
|
2426
|
-
new = recently_onboarded()
|
|
2427
|
-
stuck = onboard_issues(status)
|
|
2428
|
-
directory = model_directory()
|
|
2429
|
-
severity = {"err": 0, "warn": 1, "": 2, "ok": 3}
|
|
2430
|
-
|
|
2431
|
-
up = uptime_rows()["models"]
|
|
2432
|
-
body = ["<table><thead><tr>", th("Model"), th("Status"), th("Uptime 7d", True), th("Role"),
|
|
2433
|
-
th("Context", True), th("Can"),
|
|
2434
|
-
th("Offloads", True), th("Helped"), th("Suite", True),
|
|
2435
|
-
th("Reply time", True), th("Last answer"), "</tr></thead><tbody>"]
|
|
2436
|
-
# The model browser: each row says its connection, status, role and price
|
|
2437
|
-
# for the toolbar's filters, and the filters offer what the rows hold.
|
|
2438
|
-
offered: dict[str, list[str]] = {"connection": [], "status": [], "role": [], "price": []}
|
|
2439
|
-
|
|
2440
|
-
def browse(**values) -> str:
|
|
2441
|
-
for k, v in values.items():
|
|
2442
|
-
offered[k].append(v)
|
|
2443
|
-
return "".join(f" data-{k}='{esc(v)}'" for k, v in values.items())
|
|
2444
|
-
for alias in sorted(status, key=lambda a: (-work.get(a, {}).get("offloads", 0), a)):
|
|
2445
|
-
label, css, why = status[alias]
|
|
2446
|
-
f = catalog.info(alias)
|
|
2447
|
-
served = directory.get(alias, {}).get("served") or retired().get(alias, {}).get("served", "")
|
|
2448
|
-
w = work.get(alias, {})
|
|
2449
|
-
judged, useful = w.get("judged", 0), w.get("useful", 0)
|
|
2450
|
-
if judged:
|
|
2451
|
-
rate = useful / judged
|
|
2452
|
-
helped = (f"<span class='helped'><span class='meter'><span"
|
|
2453
|
-
f"{' class=low' if rate < .5 else ''} style='width:{max(4, round(rate * 100))}%'>"
|
|
2454
|
-
f"</span></span><span class='mono'>{useful}/{judged}</span></span>")
|
|
2455
|
-
else:
|
|
2456
|
-
rate, helped = -1, "<span class='muted'>not judged</span>"
|
|
2457
|
-
s = suite.get(alias)
|
|
2458
|
-
suite_cell = (f"<td class='num' data-sort='{s[0] / s[1]:.3f}'"
|
|
2459
|
-
f" title='{s[2]} task{'s' * (s[2] != 1)}'>{s[0]}/{s[1]}</td>"
|
|
2460
|
-
if s and s[1] else "<td class='num muted' data-sort='-1'>—</td>")
|
|
2461
|
-
p50 = tiny.get(alias)
|
|
2462
|
-
seen = last.get(alias)
|
|
2463
|
-
role = directory.get(alias, {}).get("role") or retired().get(alias, {}).get("role", "")
|
|
2464
|
-
conn = catalog.connection_of(alias)
|
|
2465
|
-
if conn == "?": # retired: no registration left to say, but its record does
|
|
2466
|
-
conn = retired().get(alias, {}).get("tag", "").split("/")[0] or "unknown"
|
|
2467
|
-
attrs = browse(connection=conn, status=re.sub(r" in \d+d$", "", label),
|
|
2468
|
-
role=role, price=price_of(alias, served, f))
|
|
2469
|
-
body.append(
|
|
2470
|
-
f"{tr(alias, attrs)}<td>{named(alias)}</td>"
|
|
2471
|
-
f"<td data-sort='{severity[css]}'>{chip(label, css, why)}"
|
|
2472
|
-
+ (" " + chip("new", "new", "onboarded this week") if alias in new else "")
|
|
2473
|
-
+ (" " + chip("onboard stopped", stuck[alias]["css"],
|
|
2474
|
-
f"onboarding stopped at the {stuck[alias].get('step', 'probe')}:"
|
|
2475
|
-
f" {stuck[alias]['label']}. Open the model for the error.")
|
|
2476
|
-
if alias in stuck else "")
|
|
2477
|
-
+ "</td>" + uptime_cell(up.get(alias)) + f"<td>{esc(role)}</td>{facts_cells(alias, f)}"
|
|
2478
|
-
f"<td class='num'>{w.get('offloads', 0)}</td>"
|
|
2479
|
-
f"<td data-sort='{rate:.3f}'>{helped}</td>{suite_cell}"
|
|
2480
|
-
+ (f"<td class='num' data-sort='{p50}'>{p50:.1f}s</td>" if p50 is not None
|
|
2481
|
-
else "<td class='num muted' data-sort='99999'>—</td>")
|
|
2482
|
-
+ (f"<td class='mono' data-sort='{seen}'>{seen}</td>" if seen
|
|
2483
|
-
else "<td class='muted' data-sort='0'>never</td>")
|
|
2484
|
-
+ "</tr>")
|
|
2485
|
-
# Decision models are not in the llm yaml, so no probe, suite or reply time
|
|
2486
|
-
# applies; the role column says what they are and what they answer.
|
|
2487
|
-
for alias, d in sorted(decide.load().items()):
|
|
2488
|
-
w = work.get(alias, {})
|
|
2489
|
-
body.append(
|
|
2490
|
-
"<tr" + browse(connection=d.get("via", ""), status="no probe", role="decision",
|
|
2491
|
-
price="free" if ":free" in d["served"] else "unknown") + ">"
|
|
2492
|
-
f"<td>{named(alias)}</td>"
|
|
2493
|
-
"<td data-sort='2'><span class='muted' title='decision models have no"
|
|
2494
|
-
" availability probe yet'>—</span>"
|
|
2495
|
-
+ (" " + chip("new", "new", "onboarded this week") if d.get("onboarded", "") >=
|
|
2496
|
-
(datetime.date.today() - datetime.timedelta(days=7)).isoformat() else "")
|
|
2497
|
-
+ "</td>" + uptime_cell(up.get(alias)) + f"<td>decision · {esc(', '.join(d['question_types']))}</td>"
|
|
2498
|
-
"<td class='num muted' data-sort='-1'>—</td><td class='muted'>—</td>"
|
|
2499
|
-
f"<td class='num'>{w.get('offloads', 0)}</td>"
|
|
2500
|
-
"<td data-sort='-1'><span class='muted'>not judged</span></td>"
|
|
2501
|
-
"<td class='num muted' data-sort='-1'>—</td><td class='num muted' data-sort='99999'>—</td>"
|
|
2502
|
-
"<td class='muted' data-sort='0'>—</td></tr>")
|
|
2503
|
-
body.append("</tbody></table>")
|
|
2504
|
-
key = """<dl>
|
|
2505
|
-
<dt>Status</dt><dd>From the latest probe. <em>healthy</em> answered;
|
|
2506
|
-
<em>throttled</em> and <em>slow</em> are rate limits and timeouts, which
|
|
2507
|
-
never retire a model inside 7 days; <em>retiring in Nd</em> is the
|
|
2508
|
-
countdown before the bench retires it on its own. Hover a chip for the
|
|
2509
|
-
provider's own words.</dd>
|
|
2510
|
-
<dt>Uptime 7d</dt><dd>Of its calls in the last 7 days that say anything about the provider,
|
|
2511
|
-
the share answered: busy, unreachable and timed out count against it; our own quota,
|
|
2512
|
-
pacing and setup problems are left out. Benchmarks has it a day at a time.</dd>
|
|
2513
|
-
<dt>Offloads, Helped</dt><dd>Real work handed over, and how much of what
|
|
2514
|
-
was judged actually helped. Benchmarks never count here.</dd>
|
|
2515
|
-
<dt>Suite</dt><dd>Verified findings located across the internal tasks:
|
|
2516
|
-
the tier that decides routing.</dd>
|
|
2517
|
-
<dt>Reply time</dt><dd>Median seconds to answer a one-word prompt: the
|
|
2518
|
-
serving path's overhead, not the model's worth.</dd>
|
|
2519
|
-
<dt>Last answer</dt><dd>The latest real answer from any source: probe,
|
|
2520
|
-
suite or a useful offload. Seven days without one retires a model.</dd>
|
|
2521
|
-
<dt>decision</dt><dd>A decision model: it answers typed questions (yes/no,
|
|
2522
|
-
choice, score) with probabilities through a Decisions API, never chat.
|
|
2523
|
-
The role column lists the question types it takes. No probe, suite or
|
|
2524
|
-
reply time applies to it.</dd>
|
|
2525
|
-
<dt>Filters</dt><dd>Connection is where a model's calls go (its Open WebUI
|
|
2526
|
-
tag, or its own endpoint). Price: <em>free</em> for a <code>:free</code>
|
|
2527
|
-
slug, a local model, a list price of 0 or a free-tier listing;
|
|
2528
|
-
<em>paid</em> for a subscription or a list price; else unknown. Combine
|
|
2529
|
-
them with the search box.</dd>
|
|
2530
|
-
</dl>"""
|
|
2531
|
-
facets = "".join(facet(k, k.title(), v) for k, v in offered.items())
|
|
2532
|
-
return card("At a glance", "Every model on the bench: whether it works right "
|
|
2533
|
-
"now, what it has been used for, and what it scored.",
|
|
2534
|
-
"".join(body), key, facets)
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
2355
|
def on_bench() -> set[str]:
|
|
2538
2356
|
"""Aliases registered now: what the charts draw."""
|
|
2539
2357
|
return set(registered().values())
|
|
@@ -3378,15 +3196,6 @@ def _uptime_window(cells: list, days: int) -> dict:
|
|
|
3378
3196
|
"median": seconds[len(seconds) // 2] if seconds else None}
|
|
3379
3197
|
|
|
3380
3198
|
|
|
3381
|
-
def uptime_cell(cells: list | None, days: int = UPTIME_DAYS[0]) -> str:
|
|
3382
|
-
"""At a glance's Uptime column: answered of counted over the last `days`."""
|
|
3383
|
-
t = _uptime_window(cells or [], days)
|
|
3384
|
-
if t["pct"] is None:
|
|
3385
|
-
return "<td class='num muted' data-sort='-1' title='no counted calls'>—</td>"
|
|
3386
|
-
return (f"<td class='num{' err' if t['pct'] < .8 else ''}' data-sort='{t['pct']:.4f}'"
|
|
3387
|
-
f" title='{t['up']} of {t['up'] + t['down']} answered'>{t['pct'] * 100:.1f}%</td>")
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
3199
|
def _why(kinds: dict, which) -> str:
|
|
3391
3200
|
return ", ".join(f"{k} {n}" for k, n in sorted(kinds.items(), key=lambda kv: -kv[1]) if which(k))
|
|
3392
3201
|
|
|
@@ -3925,12 +3734,12 @@ def favicon(theme: str) -> str:
|
|
|
3925
3734
|
return f"<link rel='icon' type='image/svg+xml' href='data:image/svg+xml,{quote(svg)}'>"
|
|
3926
3735
|
|
|
3927
3736
|
|
|
3928
|
-
def render(theme: str | None = None, shown: str = "
|
|
3737
|
+
def render(theme: str | None = None, shown: str = "suite") -> str:
|
|
3929
3738
|
with locked.snapshot(): # each file read once a render (metrics/locked.py)
|
|
3930
3739
|
return _page(theme, shown)
|
|
3931
3740
|
|
|
3932
3741
|
|
|
3933
|
-
def _page(theme: str | None, shown: str = "
|
|
3742
|
+
def _page(theme: str | None, shown: str = "suite") -> str:
|
|
3934
3743
|
theme = theme if theme in themes() else saved_theme() # a ?theme preview, else the saved one
|
|
3935
3744
|
return (
|
|
3936
3745
|
f"<!doctype html><html lang='en' data-theme='{esc(theme)}'><meta charset='utf-8'>"
|
|
@@ -4406,9 +4215,10 @@ def collections_tab() -> str:
|
|
|
4406
4215
|
# A grouped sidebar since 2026-10-19 (RELEASE-PLAN "The dashboard"): eight
|
|
4407
4216
|
# pages were too many for a row of tabs. Collections holds the gateway's
|
|
4408
4217
|
# requests under the collections they ran; Benchmarks holds Performance.
|
|
4409
|
-
|
|
4218
|
+
# At a glance (every model's state and uptime) was retired 2026-11-10: Benchmarks,
|
|
4219
|
+
# Uptime and Models said it all again. Benchmarks is the home page.
|
|
4220
|
+
SECTIONS = (("Observe", (("suite", "Benchmarks", "benchmarks_tab"),
|
|
4410
4221
|
("needs", "Action needed", "actions_page"),
|
|
4411
|
-
("suite", "Benchmarks", "benchmarks_tab"),
|
|
4412
4222
|
("quotas", "Quota management", "quotas_page"),
|
|
4413
4223
|
("logs", "Logs", "logs_page"))),
|
|
4414
4224
|
("Gateway", (("endpoints", "Endpoints", "endpoints_page"),
|
|
@@ -4433,7 +4243,9 @@ def collections_and_gateway() -> str:
|
|
|
4433
4243
|
# names an environment variable, or the app asks, or (an MCP header in Zed,
|
|
4434
4244
|
# Cline or goose, which take no variable) KEY_HOLDER stays for the reader.
|
|
4435
4245
|
# Only blocks marked `key` (shell lines, fields typed into an app) get the key
|
|
4436
|
-
# made on the page, swapped in for KEY_HOLDER by bench.js.
|
|
4246
|
+
# made on the page, swapped in for KEY_HOLDER by bench.js. CLIENTS.md is written
|
|
4247
|
+
# from client_setups (scripts/clients_doc.py): after a change here, run it, as
|
|
4248
|
+
# check.py fails while the file differs.
|
|
4437
4249
|
KEY_HOLDER = "<your gateway key>"
|
|
4438
4250
|
KEY_ENV = "BENCHWARMERS_KEY" # the plugin's and the CLI's name for it too
|
|
4439
4251
|
CLIENTS_READ = "2026-11-08"
|
|
@@ -4729,20 +4541,20 @@ def _snip_html(group: str, i: int, s: dict) -> str:
|
|
|
4729
4541
|
def pane(key: str, actions: tuple | None = None) -> str:
|
|
4730
4542
|
"""One tab's page, as its section holds it. The page renders the tab shown
|
|
4731
4543
|
(tabs); bench.js fetches the others from GET /api/pane when first opened."""
|
|
4732
|
-
if key
|
|
4544
|
+
if key == "needs":
|
|
4733
4545
|
groups, auto = actions or open_actions()
|
|
4734
|
-
return actions_page(groups, auto)
|
|
4546
|
+
return actions_page(groups, auto)
|
|
4735
4547
|
return globals()[next(fn for k, _, fn in TABS if k == key)]()
|
|
4736
4548
|
|
|
4737
4549
|
|
|
4738
|
-
def tabs(shown: str = "
|
|
4739
|
-
# Read once a render: Action needed's count in the sidebar, and its page
|
|
4740
|
-
#
|
|
4741
|
-
#
|
|
4742
|
-
#
|
|
4550
|
+
def tabs(shown: str = "suite") -> str:
|
|
4551
|
+
# Read once a render: Action needed's count in the sidebar, and its page when
|
|
4552
|
+
# shown. Only the tab shown is rendered (the bench_tab cookie, set by bench.js):
|
|
4553
|
+
# rendering every one took seconds. The rest are empty until opened (data-lazy).
|
|
4554
|
+
# A tab that is gone (a cookie still saying glance) shows the home page.
|
|
4743
4555
|
actions = open_actions()
|
|
4744
4556
|
count = {"needs": open_count(actions[0])}
|
|
4745
|
-
shown = shown if any(k == shown for k, _, _ in TABS) else "
|
|
4557
|
+
shown = shown if any(k == shown for k, _, _ in TABS) else "suite"
|
|
4746
4558
|
|
|
4747
4559
|
def button(key: str, label: str) -> str:
|
|
4748
4560
|
n = count.get(key)
|
|
@@ -5438,7 +5250,11 @@ def models_page() -> str:
|
|
|
5438
5250
|
return ("<div class='panel'><h2>Models</h2><p class='sub'>Every model on the bench by the connection it"
|
|
5439
5251
|
" comes from, with the endpoint it is reached through and what is known of its limits. A name"
|
|
5440
5252
|
" opens the model.</p><div class='toolbar'><input type='search' data-filter='page'"
|
|
5441
|
-
" placeholder='Find a model or connection' aria-label='Filter models'
|
|
5253
|
+
" placeholder='Find a model or connection' aria-label='Filter models'>"
|
|
5254
|
+
# One labelled toggle, as accordions do it (GOV.UK's "Show all sections"): Collapse all
|
|
5255
|
+
# while every table is open, else Expand all (bench.js foldButtons keeps it right).
|
|
5256
|
+
"<button class='fold-all' type='button' data-fold-all='close' aria-expanded='true'>"
|
|
5257
|
+
"<span class='fold-chev' aria-hidden='true'></span>Collapse all</button></div></div>"
|
|
5442
5258
|
+ "".join(cards))
|
|
5443
5259
|
|
|
5444
5260
|
|
|
@@ -5615,11 +5431,16 @@ def logs_page() -> str:
|
|
|
5615
5431
|
for p in sorted((DATA / "runs").glob("onboard-*.log"), reverse=True)[:30])
|
|
5616
5432
|
|
|
5617
5433
|
def section(title, body, n, open_=False):
|
|
5618
|
-
|
|
5619
|
-
|
|
5434
|
+
# CSV: this log alone, its own columns, the filter applied (bench.js); for a spreadsheet.
|
|
5435
|
+
return (f"<details class='panel'{' open' if open_ else ''} data-log='{esc(title)}'><summary><b>{esc(title)}</b>"
|
|
5436
|
+
f" <span class='muted'>· {n}</span> <button class='btn small' type='button' data-csv"
|
|
5437
|
+
f" title='Save this log, as filtered, as a CSV file'>CSV</button></summary>{body}</details>")
|
|
5620
5438
|
return (
|
|
5621
|
-
"<div class='panel'><input data-filter='page' placeholder='Filter every log on this page'"
|
|
5622
|
-
" aria-label='Filter logs'
|
|
5439
|
+
"<div class='panel'><div class='toolbar'><input data-filter='page' placeholder='Filter every log on this page'"
|
|
5440
|
+
" aria-label='Filter logs'>"
|
|
5441
|
+
# bench.js: what the page shows, filter applied, as one .txt to read offline.
|
|
5442
|
+
"<button class='btn small' type='button' id='logs-export' title='Save the logs on this page, as"
|
|
5443
|
+
" filtered, to a text file'>Export as text</button></div></div>"
|
|
5623
5444
|
+ section("Gateway calls", "<table><thead><tr>" + th("When") + th("Collection") + th("Shape")
|
|
5624
5445
|
+ th("Seats") + f"</tr></thead><tbody>{''.join(gw)}</tbody></table>", len(gw), True)
|
|
5625
5446
|
+ section("Changes", "<table><thead><tr>" + th("When") + th("By") + th("Model") + th("What")
|
|
@@ -5938,8 +5759,7 @@ class Handler(gateway.Handler):
|
|
|
5938
5759
|
if self.path == "/api/needs": # after an action there: that page, not the whole one
|
|
5939
5760
|
with locked.snapshot():
|
|
5940
5761
|
groups, auto = open_actions()
|
|
5941
|
-
self._json(200, {"html": actions_page(groups, auto), "
|
|
5942
|
-
"count": open_count(groups)})
|
|
5762
|
+
self._json(200, {"html": actions_page(groups, auto), "count": open_count(groups)})
|
|
5943
5763
|
return
|
|
5944
5764
|
if path == "/api/pane": # a tab's page, when first opened (bench.js load)
|
|
5945
5765
|
key = q.get("tab", [""])[0]
|
|
@@ -6058,7 +5878,7 @@ class Handler(gateway.Handler):
|
|
|
6058
5878
|
theme = q.get("theme", [None])[0] # a preview; else the saved one
|
|
6059
5879
|
shown = re.search(r"(?:^|;)\s*bench_tab=(\w+)", self.headers.get("Cookie") or "") # the tab last shown
|
|
6060
5880
|
try:
|
|
6061
|
-
page = render(theme, shown.group(1) if shown else "
|
|
5881
|
+
page = render(theme, shown.group(1) if shown else "suite")
|
|
6062
5882
|
except RuntimeError as exc: # endpoints.json unreadable: say which and why
|
|
6063
5883
|
page = f"<!doctype html><meta charset='utf-8'><title>Benchwarmers</title><p>{esc(exc)}</p>"
|
|
6064
5884
|
self._page(page) # gzipped beyond this machine (_length)
|
package/ui/bench.css
CHANGED
|
@@ -117,8 +117,6 @@ main { max-width: var(--page, 104rem); margin: 0 auto; padding: 0 var(--gutter)
|
|
|
117
117
|
|
|
118
118
|
/* Action needed: its own page, a panel per connection as on Models, each a
|
|
119
119
|
table (name, state, the provider's words, actions). */
|
|
120
|
-
.a-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; margin: 1.4rem 1.6rem 0;
|
|
121
|
-
padding: .45rem .75rem; background: var(--err-ground); border-radius: var(--radius); }
|
|
122
120
|
.a-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; }
|
|
123
121
|
.a-head .why { font-size: .875rem; }
|
|
124
122
|
.a-head .why:empty { display: none; }
|
|
@@ -359,8 +357,7 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
|
|
|
359
357
|
:is(dialog, .form-panel) .members .m-row { border-bottom: 1px solid var(--line); }
|
|
360
358
|
:is(dialog, .form-panel) .members .m-row:nth-child(odd) { background: color-mix(in oklab, var(--band) 45%, transparent); }
|
|
361
359
|
:is(dialog, .form-panel) .members .m-row:has(input:checked) { box-shadow: inset 3px 0 0 var(--bench-seat, var(--brand)); }
|
|
362
|
-
:is(dialog, .form-panel) .members .m-name a.model {
|
|
363
|
-
:is(dialog, .form-panel) .members .m-name a.model:hover { text-decoration: underline; }
|
|
360
|
+
:is(dialog, .form-panel) .members .m-name a.model { font-size: .875rem; }
|
|
364
361
|
:is(dialog, .form-panel) .members .m-ctx { font-family: var(--font-mono); font-size: .8125rem; color: var(--muted); text-align: right; }
|
|
365
362
|
:is(dialog, .form-panel) .members .m-can { display: flex; flex-wrap: wrap; gap: .2rem; }
|
|
366
363
|
:is(dialog, .form-panel) .members .m-can .chip { font-size: .6875rem; padding: 0 .4rem; }
|
|
@@ -541,6 +538,14 @@ h3.chart-head { font-size: 1rem; margin: 1rem 0 .2rem; }
|
|
|
541
538
|
.chart-label { font-size: .875rem; font-weight: 600; margin: .9rem 0 .1rem; }
|
|
542
539
|
/* Models: each connection a fold, its heading the summary */
|
|
543
540
|
.model-card > summary h3.chart-head { display: inline; margin: 0; }
|
|
541
|
+
/* Expand all / Collapse all: a text toggle with a chevron that turns, like a fold's own marker */
|
|
542
|
+
button.fold-all { display: inline-flex; align-items: center; gap: .4rem; margin-left: auto; border: 0; padding: .2rem 0;
|
|
543
|
+
background: none; color: var(--link); font: inherit; font-size: .9375rem; cursor: pointer; }
|
|
544
|
+
button.fold-all:hover, button.fold-all:focus-visible { text-decoration: underline; text-underline-offset: .18em; }
|
|
545
|
+
.fold-chev { width: .5rem; height: .5rem; border: solid currentColor; border-width: 0 2px 2px 0;
|
|
546
|
+
transform: rotate(-45deg); transition: transform .15s; }
|
|
547
|
+
button.fold-all[aria-expanded="true"] .fold-chev { transform: rotate(45deg); margin-top: -.3rem; }
|
|
548
|
+
@media (prefers-reduced-motion: reduce) { .fold-chev { transition: none; } }
|
|
544
549
|
/* the Collections tab: the dialog's form, kept to a readable width */
|
|
545
550
|
.panel.form-panel > :not(#c-list, table) { max-width: 46rem; }
|
|
546
551
|
/* a list of things you open (collections), with its New button above */
|
|
@@ -595,7 +600,6 @@ dialog .pick { display: flex; gap: 1.5rem; }
|
|
|
595
600
|
.toolbar input[type=search] { min-width: 0; width: 100%; }
|
|
596
601
|
button.c-row { grid-template-columns: minmax(0, 1fr); } /* the seats get the width; the whole row opens */
|
|
597
602
|
.c-row .open-hint { display: none; }
|
|
598
|
-
.a-line { margin: 1rem 1rem 0; }
|
|
599
603
|
}
|
|
600
604
|
/* A narrow page (a phone, or a window with the sidebar beside it): name and
|
|
601
605
|
state on one line, the words and actions under them. No gap between name
|
package/ui/bench.js
CHANGED
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
var rendered = document.querySelector('.tab:not([data-lazy])');
|
|
214
214
|
Promise.resolve().then(function () { if (rendered) setUp(rendered.id.slice(4)); });
|
|
215
215
|
var wanted = store('bench.tab'); // else the one rendered: storage blocked or cleared, the cookie kept
|
|
216
|
-
show(wanted && $('tab-' + wanted) ? wanted : rendered ? rendered.id.slice(4) : '
|
|
216
|
+
show(wanted && $('tab-' + wanted) ? wanted : rendered ? rendered.id.slice(4) : 'suite');
|
|
217
217
|
|
|
218
218
|
// ---- sortable tables -------------------------------------------------
|
|
219
219
|
function key(cell) {
|
|
@@ -276,7 +276,75 @@
|
|
|
276
276
|
document.addEventListener('input', function (ev) { if (ev.target.matches('input[data-filter]')) applyFilter(scopeOf(ev.target)); });
|
|
277
277
|
document.addEventListener('change', function (ev) { if (ev.target.matches('select[data-facet]')) applyFilter(scopeOf(ev.target)); });
|
|
278
278
|
|
|
279
|
-
// ----
|
|
279
|
+
// ---- Expand all / Collapse all: every fold of the page's groups (Models' connections).
|
|
280
|
+
// One labelled toggle, as accordions have it: Collapse all while every one is open,
|
|
281
|
+
// else Expand all. Kept right after any fold opens or shuts, by hand, by it or by a live swap.
|
|
282
|
+
function foldButtons(tab) {
|
|
283
|
+
var folds = tab.querySelectorAll('details.model-card'), open = 0;
|
|
284
|
+
folds.forEach(function (d) { if (d.open) open++; });
|
|
285
|
+
var all = open === folds.length;
|
|
286
|
+
tab.querySelectorAll('[data-fold-all]').forEach(function (b) {
|
|
287
|
+
b.dataset.foldAll = all ? 'close' : 'open';
|
|
288
|
+
b.setAttribute('aria-expanded', String(all));
|
|
289
|
+
b.lastChild.textContent = all ? 'Collapse all' : 'Expand all';
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
document.addEventListener('click', function (e) {
|
|
293
|
+
var b = e.target.closest('[data-fold-all]');
|
|
294
|
+
if (!b) return;
|
|
295
|
+
var tab = b.closest('.tab');
|
|
296
|
+
tab.querySelectorAll('details.model-card').forEach(function (d) { d.open = b.dataset.foldAll === 'open'; });
|
|
297
|
+
foldButtons(tab);
|
|
298
|
+
});
|
|
299
|
+
document.addEventListener('toggle', function (e) { // toggle does not bubble: caught on its way down
|
|
300
|
+
if (e.target.matches && e.target.matches('details.model-card')) foldButtons(e.target.closest('.tab'));
|
|
301
|
+
}, true);
|
|
302
|
+
|
|
303
|
+
// ---- Logs: Export as text (every log in one .txt, to read) and CSV (one log, its
|
|
304
|
+
// own columns, for a spreadsheet). Both as the page shows them, the filter applied.
|
|
305
|
+
function stampNow() { // local time, as the page shows
|
|
306
|
+
var now = new Date(), two = function (n) { return String(n).padStart(2, '0'); };
|
|
307
|
+
return now.getFullYear() + '-' + two(now.getMonth() + 1) + '-' + two(now.getDate()) + '-' + two(now.getHours()) + two(now.getMinutes());
|
|
308
|
+
}
|
|
309
|
+
// The file itself: Share's saveFile(blob, name) and csv(rows) (RFC 4180, with a BOM).
|
|
310
|
+
document.addEventListener('click', function (e) {
|
|
311
|
+
var b = e.target.closest('[data-csv]');
|
|
312
|
+
if (!b) return;
|
|
313
|
+
e.preventDefault(); // in the summary: no fold
|
|
314
|
+
var d = b.closest('details'), rows = [];
|
|
315
|
+
var cells = function (tr) {
|
|
316
|
+
return Array.prototype.map.call(tr.children, function (c) { return c.textContent.trim().replace(/\s+/g, ' '); });
|
|
317
|
+
};
|
|
318
|
+
var head = d.querySelector('thead tr');
|
|
319
|
+
if (head) {
|
|
320
|
+
rows.push(cells(head));
|
|
321
|
+
d.querySelectorAll('tbody tr').forEach(function (r) { if (!r.hidden) rows.push(cells(r)); });
|
|
322
|
+
} else {
|
|
323
|
+
rows.push(['Line']);
|
|
324
|
+
d.querySelectorAll('.log-line').forEach(function (r) { if (!r.hidden) rows.push([r.textContent.trim()]); });
|
|
325
|
+
}
|
|
326
|
+
var slug = d.dataset.log.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
327
|
+
saveFile(new Blob([csv(rows)], {type: 'text/csv;charset=utf-8'}), 'benchwarmers-' + slug + '-' + stampNow() + '.csv');
|
|
328
|
+
});
|
|
329
|
+
on('click', 'logs-export', function () {
|
|
330
|
+
var tab = $('tab-logs'), out = ['Benchwarmers logs, exported ' + new Date().toString()];
|
|
331
|
+
var q = (tab.querySelector('input[data-filter]') || {}).value;
|
|
332
|
+
if (q) out.push('Filtered by: ' + q);
|
|
333
|
+
tab.querySelectorAll('details.panel').forEach(function (d) {
|
|
334
|
+
out.push('', '== ' + d.dataset.log + ' ==');
|
|
335
|
+
var cells = function (tr) {
|
|
336
|
+
return Array.prototype.map.call(tr.children, function (c) { return c.textContent.trim().replace(/\s+/g, ' '); }).join('\t');
|
|
337
|
+
};
|
|
338
|
+
var head = d.querySelector('thead tr');
|
|
339
|
+
if (head) out.push(cells(head));
|
|
340
|
+
d.querySelectorAll('tbody tr, .log-line').forEach(function (r) {
|
|
341
|
+
if (!r.hidden) out.push(r.matches('tr') ? cells(r) : r.textContent.trim());
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
saveFile(new Blob([out.join('\n') + '\n'], {type: 'text/plain;charset=utf-8'}), 'benchwarmers-logs-' + stampNow() + '.txt');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ---- a link to another page ----
|
|
280
348
|
document.addEventListener('click', function (e) {
|
|
281
349
|
var b = e.target.closest('[data-go]');
|
|
282
350
|
if (b) { show(b.dataset.go); scrollTo(0, 0); }
|
|
@@ -767,8 +835,8 @@
|
|
|
767
835
|
});
|
|
768
836
|
|
|
769
837
|
// ---- after an action: the part it changed, not the whole page ----------
|
|
770
|
-
// Action needed's page
|
|
771
|
-
//
|
|
838
|
+
// Action needed's page and its count in the sidebar, from the server
|
|
839
|
+
// (GET /api/needs, a few ms): a page reload
|
|
772
840
|
// renders every tab, seconds.
|
|
773
841
|
var needsAsked = 0;
|
|
774
842
|
function refreshNeeds(live) {
|
|
@@ -783,10 +851,6 @@
|
|
|
783
851
|
count.textContent = n.count;
|
|
784
852
|
tab.setAttribute('aria-label', tab.firstChild.textContent + ', ' + n.count + ' open');
|
|
785
853
|
}
|
|
786
|
-
var line = document.querySelector('#tab-glance > .a-line');
|
|
787
|
-
if (line) line.remove();
|
|
788
|
-
// Not into a page not fetched yet: it says Loading… while empty, and comes with its line.
|
|
789
|
-
if (n.line && $('tab-glance').firstChild) $('tab-glance').insertAdjacentHTML('afterbegin', n.line);
|
|
790
854
|
}).catch(function () { location.reload(); }); // could not ask: the old way
|
|
791
855
|
}
|
|
792
856
|
// A retired model's rows grey out and hide behind Show retired, as the
|
|
@@ -2125,7 +2189,10 @@
|
|
|
2125
2189
|
var first = fact(g.name), caps = first.caps || {};
|
|
2126
2190
|
var line = el('div', {class: 'm-row', role: 'group', 'aria-label': g.name, 'data-family': g.family, 'data-name': g.name});
|
|
2127
2191
|
var name = el('div', {class: 'm-name'});
|
|
2128
|
-
|
|
2192
|
+
// The same link as every model name elsewhere (a.model > .alias: the highlight on hover).
|
|
2193
|
+
var link = el('a', {href: '#', class: 'model', 'data-alias': g.name, title: 'Open ' + g.name});
|
|
2194
|
+
link.appendChild(el('span', {class: 'alias'}, g.name));
|
|
2195
|
+
name.appendChild(link);
|
|
2129
2196
|
line.appendChild(name);
|
|
2130
2197
|
line.appendChild(el('span', {class: 'm-ctx'}, first.context ? tokens(first.context) : '—'));
|
|
2131
2198
|
var can = el('span', {class: 'm-can'});
|