@wendellresearch-ai/benchwarmers 0.1.1 → 0.2.0
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/GATEWAY.md +1 -0
- package/README.md +39 -0
- package/USAGE.md +8 -0
- package/VERSION +1 -1
- package/bin/benchwarmers.js +5 -3
- package/gateway.py +56 -13
- package/mcp_server.py +266 -0
- package/metrics/client.py +22 -1
- package/metrics/route.py +6 -1
- package/package.json +2 -1
- package/scripts/check.py +2 -0
- package/scripts/test_llm_log.py +81 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +21 -0
- package/scripts/test_serve_edit.py +55 -3
- package/serve.py +127 -25
- package/suite/run.py +4 -2
- package/ui/bench.css +1 -0
- package/ui/bench.js +25 -4
package/serve.py
CHANGED
|
@@ -13,8 +13,9 @@ It writes three kinds of thing, each behind a popup and each logged to
|
|
|
13
13
|
the bench itself — a role the config defines; an alias in the standing form,
|
|
14
14
|
unused, never a retired name — written atomically, the rename recorded in
|
|
15
15
|
`config/alias-history.json`, the `llm` registry re-synced. A model's REMOVAL
|
|
16
|
-
(same popup, typed back to confirm, reason required): deregistered
|
|
17
|
-
recorded in `config/retired.json`; no evidence
|
|
16
|
+
(same popup, typed back to confirm, reason required): deregistered, taken
|
|
17
|
+
out of every collection, and recorded in `config/retired.json`; no evidence
|
|
18
|
+
is deleted. And ONBOARDING
|
|
18
19
|
(header button): `scripts/onboard.py` started in the background with its log
|
|
19
20
|
tailed here. The upstream model id is never editable. Nothing else can write.
|
|
20
21
|
|
|
@@ -60,6 +61,16 @@ import guard # noqa: E402
|
|
|
60
61
|
import locked # noqa: E402
|
|
61
62
|
|
|
62
63
|
|
|
64
|
+
def gateway_url() -> str:
|
|
65
|
+
"""Where a caller reaches the gateway: this dashboard's own address (it
|
|
66
|
+
answers the gateway's routes), or BENCHWARMERS_PUBLIC_URL behind a proxy."""
|
|
67
|
+
if public := guard.public_url():
|
|
68
|
+
return public + "/v1"
|
|
69
|
+
address = guard.bind()
|
|
70
|
+
host = "127.0.0.1" if address in ("127.0.0.1", "0.0.0.0", "::") else address
|
|
71
|
+
return f"http://{'[' + host + ']' if ':' in host else host}:{PORT}/v1"
|
|
72
|
+
|
|
73
|
+
|
|
63
74
|
UI = ROOT / "ui"
|
|
64
75
|
|
|
65
76
|
# THE THEME IS A FILE IN THIS REPO. Until 2026-10-19 the colours and faces were
|
|
@@ -480,6 +491,65 @@ def _log_change(stamp: str, who: str, alias: str, field: str, old, new) -> None:
|
|
|
480
491
|
RETIRED_PATH = DATA / "config" / "retired.json"
|
|
481
492
|
|
|
482
493
|
|
|
494
|
+
def _drop_from_collections(alias: str, stamp: str, who: str) -> dict[str, list[str]]:
|
|
495
|
+
"""Take a retired alias out of every collection: off `members`, and off
|
|
496
|
+
`merger` (empty then, which means chosen by verdicts). Otherwise each call
|
|
497
|
+
to that collection seats a model that is no longer registered. Returns
|
|
498
|
+
{collection: [the fields it was in]} for retired.json, so Restore can put
|
|
499
|
+
it back. An unreadable collections.json is left alone (a save would wipe
|
|
500
|
+
it); the gateway skips a retired member anyway."""
|
|
501
|
+
if bench_collections.unreadable(bench_collections.FILE):
|
|
502
|
+
return {}
|
|
503
|
+
data = bench_collections.load()
|
|
504
|
+
was: dict[str, list[str]] = {}
|
|
505
|
+
for name, c in data.items():
|
|
506
|
+
if not isinstance(c, dict):
|
|
507
|
+
continue
|
|
508
|
+
fields = []
|
|
509
|
+
if alias in (c.get("members") or []):
|
|
510
|
+
fields.append("members")
|
|
511
|
+
if c.get("merger") == alias:
|
|
512
|
+
fields.append("merger")
|
|
513
|
+
if not fields:
|
|
514
|
+
continue
|
|
515
|
+
old = json.loads(json.dumps(c))
|
|
516
|
+
if "members" in fields:
|
|
517
|
+
c["members"] = [m for m in c["members"] if m != alias]
|
|
518
|
+
if "merger" in fields:
|
|
519
|
+
c["merger"] = ""
|
|
520
|
+
was[name] = fields
|
|
521
|
+
_log_change(stamp, who, alias, f"collection {name}", old, c)
|
|
522
|
+
if was:
|
|
523
|
+
_write_atomic(bench_collections.FILE, _dump(data))
|
|
524
|
+
return was
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _return_to_collections(alias: str, was: dict, stamp: str, who: str) -> list[str]:
|
|
528
|
+
"""Undo _drop_from_collections for the collections that still exist; a
|
|
529
|
+
collection deleted since, or one now full (max_calls), is left as it is.
|
|
530
|
+
Returns the names it went back into."""
|
|
531
|
+
if not was or bench_collections.unreadable(bench_collections.FILE):
|
|
532
|
+
return []
|
|
533
|
+
data, cap = bench_collections.load(), bench_collections.settings()["max_calls"]
|
|
534
|
+
back = []
|
|
535
|
+
for name, fields in was.items():
|
|
536
|
+
c = data.get(name)
|
|
537
|
+
if not isinstance(c, dict):
|
|
538
|
+
continue
|
|
539
|
+
old = json.loads(json.dumps(c))
|
|
540
|
+
members = c.get("members") or []
|
|
541
|
+
if "members" in fields and alias not in members and len(members) < cap:
|
|
542
|
+
c["members"] = members + [alias]
|
|
543
|
+
if "merger" in fields and not c.get("merger"):
|
|
544
|
+
c["merger"] = alias
|
|
545
|
+
if c != old:
|
|
546
|
+
back.append(name)
|
|
547
|
+
_log_change(stamp, who, alias, f"collection {name}", old, c)
|
|
548
|
+
if back:
|
|
549
|
+
_write_atomic(bench_collections.FILE, _dump(data))
|
|
550
|
+
return back
|
|
551
|
+
|
|
552
|
+
|
|
483
553
|
def _sync_cli() -> str:
|
|
484
554
|
try:
|
|
485
555
|
result = subprocess.run(
|
|
@@ -500,7 +570,8 @@ def remove_model(alias: str, typed: str, reason: str, who: str = "ui") -> dict:
|
|
|
500
570
|
The daily check (`scripts/check_deprecations.py`, via `auto_retire.py`)
|
|
501
571
|
calls this too, with `who="auto"`, when a model is clearly gone; this is
|
|
502
572
|
also the button for a person who knows sooner. It deregisters the alias from the llm registry (and re-syncs),
|
|
503
|
-
drops it from the role and direct-endpoint configs
|
|
573
|
+
drops it from the role and direct-endpoint configs and from every
|
|
574
|
+
collection (members and merger), records it in
|
|
504
575
|
`config/retired.json` — so the alias is never reused, and onboarding the
|
|
505
576
|
same served model again is a deliberate act — clears that model's alerts,
|
|
506
577
|
and logs the change. It deletes NO evidence: suite outputs, offloads,
|
|
@@ -527,11 +598,14 @@ def remove_model(alias: str, typed: str, reason: str, who: str = "ui") -> dict:
|
|
|
527
598
|
data = decide.load()
|
|
528
599
|
entry = data.pop(alias)
|
|
529
600
|
_write_atomic(decide.FILE, _dump(data))
|
|
601
|
+
# A decision model is never saved into a collection; a hand edit may have.
|
|
602
|
+
was = _drop_from_collections(alias, stamp, who)
|
|
530
603
|
retired = load_json(RETIRED_PATH)
|
|
531
604
|
retired[alias] = {"served": served, "tag": directory[alias]["tag"], "role": "decision",
|
|
532
605
|
"at": stamp, "by": who, "why": reason.strip(),
|
|
533
606
|
"endpoint": entry["endpoint"], "decision": entry,
|
|
534
|
-
**({"ad_hoc": True} if directory[alias]["ad_hoc"] else {})
|
|
607
|
+
**({"ad_hoc": True} if directory[alias]["ad_hoc"] else {}),
|
|
608
|
+
**({"collections": was} if was else {})}
|
|
535
609
|
_write_atomic(RETIRED_PATH, _dump(retired))
|
|
536
610
|
roles = roles_config()
|
|
537
611
|
roles.get("models", {}).pop(alias, None)
|
|
@@ -540,7 +614,8 @@ def remove_model(alias: str, typed: str, reason: str, who: str = "ui") -> dict:
|
|
|
540
614
|
_log_change(stamp, who, alias, "removed", served, reason.strip())
|
|
541
615
|
return {"ok": True, "errors": [], "sync": "",
|
|
542
616
|
"changed": ["config/decision-models.json", "config/retired.json",
|
|
543
|
-
"config/model-roles.json"]
|
|
617
|
+
"config/model-roles.json"]
|
|
618
|
+
+ (["config/collections.json"] if was else [])}
|
|
544
619
|
|
|
545
620
|
# 1. the registry block — model_id line plus its indented lines
|
|
546
621
|
text = read(YAML_PATH)
|
|
@@ -551,17 +626,23 @@ def remove_model(alias: str, typed: str, reason: str, who: str = "ui") -> dict:
|
|
|
551
626
|
"errors": [f"expected one registry block for {alias!r}, found {n}; nothing removed"]}
|
|
552
627
|
_write_atomic(YAML_PATH, text.rstrip("\n") + "\n")
|
|
553
628
|
changed.append("config/extra-openai-models.yaml")
|
|
554
|
-
# 2. the
|
|
629
|
+
# 2. the collections it sat in, so no call seats a model that is gone
|
|
630
|
+
was = _drop_from_collections(alias, stamp, who)
|
|
631
|
+
if was:
|
|
632
|
+
changed.append("config/collections.json")
|
|
633
|
+
# 3. the memory of it -- enough to restore it exactly: endpoint, options,
|
|
634
|
+
# collections too
|
|
555
635
|
retired = load_json(RETIRED_PATH)
|
|
556
636
|
options = load_json(OPTIONS_PATH)
|
|
557
637
|
retired[alias] = {"served": served, "tag": directory[alias]["tag"],
|
|
558
638
|
"role": directory[alias]["role"], "at": stamp, "by": who,
|
|
559
639
|
"why": reason.strip(), "endpoint": directory[alias]["endpoint"],
|
|
560
640
|
**({"ad_hoc": True} if directory[alias]["ad_hoc"] else {}),
|
|
561
|
-
**({"options": options[alias]} if alias in options else {})
|
|
641
|
+
**({"options": options[alias]} if alias in options else {}),
|
|
642
|
+
**({"collections": was} if was else {})}
|
|
562
643
|
_write_atomic(RETIRED_PATH, _dump(retired))
|
|
563
644
|
changed.append("config/retired.json")
|
|
564
|
-
#
|
|
645
|
+
# 4. configs keyed by alias
|
|
565
646
|
roles = roles_config()
|
|
566
647
|
if alias in roles.get("models", {}) or alias in roles.get("ad_hoc", []):
|
|
567
648
|
roles.get("models", {}).pop(alias, None)
|
|
@@ -573,7 +654,7 @@ def remove_model(alias: str, typed: str, reason: str, who: str = "ui") -> dict:
|
|
|
573
654
|
options.pop(alias)
|
|
574
655
|
_write_atomic(OPTIONS_PATH, _dump(options))
|
|
575
656
|
changed.append("config/model-options.json")
|
|
576
|
-
#
|
|
657
|
+
# 5. its alerts — the action they asked for has been taken
|
|
577
658
|
try:
|
|
578
659
|
import alerts as _alerts
|
|
579
660
|
for key in list(_alerts.active()):
|
|
@@ -652,13 +733,15 @@ def restore_model(alias: str, who: str = "ui") -> dict:
|
|
|
652
733
|
if entry.get("ad_hoc"):
|
|
653
734
|
roles["ad_hoc"] = sorted(set(roles.get("ad_hoc", [])) | {alias})
|
|
654
735
|
_write_atomic(ROLES_PATH, _dump(roles))
|
|
736
|
+
stamp = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
737
|
+
back = _return_to_collections(alias, entry.get("collections") or {}, stamp, who)
|
|
655
738
|
del retired[alias]
|
|
656
739
|
_write_atomic(RETIRED_PATH, _dump(retired))
|
|
657
|
-
_log_change(
|
|
658
|
-
who, alias, "restored", None, entry["served"])
|
|
740
|
+
_log_change(stamp, who, alias, "restored", None, entry["served"])
|
|
659
741
|
return {"ok": True, "errors": [], "sync": "",
|
|
660
742
|
"changed": ["config/decision-models.json", "config/model-roles.json",
|
|
661
|
-
"config/retired.json"]
|
|
743
|
+
"config/retired.json"]
|
|
744
|
+
+ (["config/collections.json"] if back else [])}
|
|
662
745
|
if entry["served"] in registered():
|
|
663
746
|
return {"ok": False, "changed": [], "sync": "",
|
|
664
747
|
"errors": [f"{entry['served']} is registered again as"
|
|
@@ -682,11 +765,13 @@ def restore_model(alias: str, who: str = "ui") -> dict:
|
|
|
682
765
|
options[alias] = entry["options"]
|
|
683
766
|
_write_atomic(OPTIONS_PATH, _dump(options))
|
|
684
767
|
changed.append("config/model-options.json")
|
|
768
|
+
stamp = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
769
|
+
if _return_to_collections(alias, entry.get("collections") or {}, stamp, who):
|
|
770
|
+
changed.append("config/collections.json")
|
|
685
771
|
del retired[alias]
|
|
686
772
|
_write_atomic(RETIRED_PATH, _dump(retired))
|
|
687
773
|
changed.append("config/retired.json")
|
|
688
|
-
_log_change(
|
|
689
|
-
who, alias, "restored", None, entry["served"])
|
|
774
|
+
_log_change(stamp, who, alias, "restored", None, entry["served"])
|
|
690
775
|
return {"ok": True, "errors": [], "changed": changed, "sync": _sync_cli()}
|
|
691
776
|
|
|
692
777
|
|
|
@@ -1247,8 +1332,11 @@ def collections_view() -> dict:
|
|
|
1247
1332
|
# collection already uses, so the same idea is spelled the same way.
|
|
1248
1333
|
used = [t for r in rows for t in r.get("tags") or []]
|
|
1249
1334
|
suggestions = list(dict.fromkeys(shapes + list(TAG_PURPOSES) + used))
|
|
1335
|
+
# The served name beside each alias: the member search matches either.
|
|
1336
|
+
directory = model_directory()
|
|
1337
|
+
served = {a: directory[a].get("served", "") for a in _chat_aliases()}
|
|
1250
1338
|
return {"ok": True, "errors": [], "collections": rows, "settings": s,
|
|
1251
|
-
"shapes": shapes, "models": _chat_aliases(), "tag_suggestions": suggestions}
|
|
1339
|
+
"shapes": shapes, "models": _chat_aliases(), "served": served, "tag_suggestions": suggestions}
|
|
1252
1340
|
|
|
1253
1341
|
|
|
1254
1342
|
TAG_PURPOSES = ("second-opinion", "security", "long-context", "fast", "free")
|
|
@@ -1683,7 +1771,7 @@ def first_run() -> str:
|
|
|
1683
1771
|
elif not bench_collections.load():
|
|
1684
1772
|
step, head, label = "collection", "Make your first collection", "New collection"
|
|
1685
1773
|
text = ("The models a panel always asks, and what it is for. Callers then ask for"
|
|
1686
|
-
" bench/<name> through the gateway (
|
|
1774
|
+
f" bench/<name> through the gateway at {esc(gateway_url())}.")
|
|
1687
1775
|
else:
|
|
1688
1776
|
return ""
|
|
1689
1777
|
return (f"<section class='problems' id='first-run' data-step='{step}' aria-labelledby='first-head'>"
|
|
@@ -2333,8 +2421,8 @@ def gateway_card(limit: int = 30) -> str:
|
|
|
2333
2421
|
f"<td>{' '.join(chips)}</td>"
|
|
2334
2422
|
f"<td class='num{' muted' if not row_owed else ''}'>{row_owed}</td></tr>")
|
|
2335
2423
|
if not records:
|
|
2336
|
-
body.append("<tr><td colspan='6' class='muted'>No gateway requests yet.
|
|
2337
|
-
" <code>
|
|
2424
|
+
body.append("<tr><td colspan='6' class='muted'>No gateway requests yet. Call a collection"
|
|
2425
|
+
f" at <code>{esc(gateway_url())}</code>.</td></tr>")
|
|
2338
2426
|
body.append("</tbody></table>")
|
|
2339
2427
|
key = """<dl>
|
|
2340
2428
|
<dt>Seats</dt><dd>Every model that answered the request, members first, then
|
|
@@ -2525,7 +2613,7 @@ def collections_tab() -> str:
|
|
|
2525
2613
|
return (
|
|
2526
2614
|
"<div class='panel form-panel'><h2 id='c-title'>Collections</h2>"
|
|
2527
2615
|
"<p class='served'>Named groups of models the bench gateway runs together. A caller"
|
|
2528
|
-
" asks for <code>bench/<name></code> at <code>
|
|
2616
|
+
f" asks for <code>bench/<name></code> at <code>{esc(gateway_url())}</code>;"
|
|
2529
2617
|
" every member answers, plus explorer seats the bench fills by staleness. Two to four"
|
|
2530
2618
|
" members is the sweet spot. Open one to edit or delete it.</p>"
|
|
2531
2619
|
"<div class='list-actions'><button class='btn primary' id='c-new'>New collection</button>"
|
|
@@ -2543,7 +2631,12 @@ def collections_tab() -> str:
|
|
|
2543
2631
|
" aria-describedby='c-name-rules'></div>"
|
|
2544
2632
|
"<p class='rules' id='c-name-rules'>Callers ask for <code>bench/<name></code>. Say what"
|
|
2545
2633
|
" it is for, not who is in it: members change. Lower-case letters, digits and '-'.</p>"
|
|
2546
|
-
|
|
2634
|
+
# Not data-filter: that binds to the whole panel. The same match (any
|
|
2635
|
+
# text, case-insensitive), on the member list only; ticked ones stay shown.
|
|
2636
|
+
"<label class='field' for='c-member-filter'>Members</label>"
|
|
2637
|
+
"<input type='search' id='c-member-filter' class='member-filter' spellcheck='false' autocomplete='off'"
|
|
2638
|
+
" placeholder='Filter by alias or served name' aria-controls='c-members' aria-describedby='c-count'>"
|
|
2639
|
+
"<div id='c-members' class='members' role='group' aria-label='Members'></div>"
|
|
2547
2640
|
"<p class='rules' id='c-count'></p>"
|
|
2548
2641
|
"<label class='field' for='c-shape'>Explorer shape</label><select id='c-shape'></select>"
|
|
2549
2642
|
"<p class='rules'>Which models may fill the explorer seats. Automatic asks a decision"
|
|
@@ -3009,16 +3102,24 @@ def models_page() -> str:
|
|
|
3009
3102
|
# the connection (provider) it comes FROM: an OpenRouter model may be on
|
|
3010
3103
|
# OpenRouter's own endpoint or arrive through Open WebUI's.
|
|
3011
3104
|
through = {r["alias"]: r["endpoint"] for r in endpoints.registry()}
|
|
3012
|
-
|
|
3013
|
-
for
|
|
3105
|
+
decisions = decide.load()
|
|
3106
|
+
through.update({a: d["endpoint"] for a, d in decisions.items()})
|
|
3107
|
+
# Every model, decision models too (connections_view is chat models only,
|
|
3108
|
+
# for what a collection may hold): they are on the bench like any other.
|
|
3109
|
+
by: dict[str, list[str]] = {}
|
|
3110
|
+
for a in model_directory():
|
|
3111
|
+
by.setdefault(catalog.connection_of(a), []).append(a)
|
|
3112
|
+
for c in ({"name": n, "models": sorted(m)} for n, m in sorted(by.items())):
|
|
3014
3113
|
rows = []
|
|
3015
3114
|
for a in c["models"]:
|
|
3016
3115
|
f = catalog.info(a)
|
|
3017
3116
|
lim = f.get("limits") or {}
|
|
3018
3117
|
num = lambda v: f"{v:,}" if isinstance(v, int) else "—" # noqa: E731
|
|
3019
3118
|
ep = through.get(a, "?")
|
|
3119
|
+
kind = (" " + chip("decision", "", "a decision model: answers typed questions, not chat")
|
|
3120
|
+
if a in decisions else "")
|
|
3020
3121
|
rows.append(f"<tr><td><a href='#' class='model' data-alias='{esc(a)}'>"
|
|
3021
|
-
f"<span class='alias'>{esc(a)}</span></a
|
|
3122
|
+
f"<span class='alias'>{esc(a)}</span></a>{kind}</td>"
|
|
3022
3123
|
f"<td data-sort='{esc(ep)}'>{chip(ep, 'via', f'reached through the {ep} endpoint')}</td>"
|
|
3023
3124
|
f"<td class='num' data-sort='{f.get('context_window') or 0}'>{num(f.get('context_window'))}</td>"
|
|
3024
3125
|
f"<td class='num' data-sort='{f.get('max_output') or 0}'>{num(f.get('max_output'))}</td>"
|
|
@@ -3254,8 +3355,9 @@ def editor() -> str:
|
|
|
3254
3355
|
"<button class='btn primary' id='m-save'>Save</button></div>"
|
|
3255
3356
|
"<details class='remove' id='m-remove-box'><summary>Retire this model…</summary>"
|
|
3256
3357
|
"<p class='rules'>Deregisters the alias, records it in <code>retired.json</code> so the"
|
|
3257
|
-
" name is never reused,
|
|
3258
|
-
"
|
|
3358
|
+
" name is never reused, takes it out of every collection it is a member or merger of,"
|
|
3359
|
+
" and clears its problems. Deletes no evidence, and Restore brings it back, into the"
|
|
3360
|
+
" collections that still exist. The daily check also retires models on its own when they are"
|
|
3259
3361
|
" clearly gone.</p>"
|
|
3260
3362
|
"<label class='field' for='m-reason'>Why</label>"
|
|
3261
3363
|
"<input id='m-reason' placeholder='e.g. free tier withdrawn 2026-10-15'>"
|
package/suite/run.py
CHANGED
|
@@ -826,8 +826,10 @@ def ask_once(binary: str, model: str, prompt: str,
|
|
|
826
826
|
# mid-stream crash `out.stdout` holds real but TRUNCATED text, so
|
|
827
827
|
# `stdout or stderr` prefers it and the failure becomes invisible --
|
|
828
828
|
# it reads as a model that simply stopped early. The exit code is the
|
|
829
|
-
# only signal that separates those, so it decides, not the text
|
|
830
|
-
|
|
829
|
+
# only signal that separates those, so it decides, not the text --
|
|
830
|
+
# except when it is only llm failing to log an answer it printed in
|
|
831
|
+
# full, its logs.db locked by another llm (metrics/client.py).
|
|
832
|
+
if out.returncode != 0 and not client.llm_log_failed_only(out.stdout, out.stderr):
|
|
831
833
|
detail = (out.stderr or body or "").strip().replace("\n", " ")
|
|
832
834
|
body = f"Error: llm exited {out.returncode}: {detail[:300]}"
|
|
833
835
|
except subprocess.TimeoutExpired:
|
package/ui/bench.css
CHANGED
|
@@ -286,6 +286,7 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
|
|
|
286
286
|
:is(dialog, .form-panel) .members { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 0 1rem;
|
|
287
287
|
max-height: 14rem; overflow: auto; }
|
|
288
288
|
:is(dialog, .form-panel) .members label.check { margin-top: .35rem; font-size: .875rem; }
|
|
289
|
+
:is(dialog, .form-panel) input.member-filter { font-size: .9375rem; padding: .4rem .6rem; margin-bottom: .2rem; }
|
|
289
290
|
:is(dialog, .form-panel) .members input:disabled + span { color: var(--muted); }
|
|
290
291
|
:is(dialog, .form-panel) .limits { display: flex; flex-wrap: wrap; gap: .3rem .8rem; margin-top: .3rem; font-size: .875rem; }
|
|
291
292
|
:is(dialog, .form-panel) .limits input { width: 6.5rem !important; padding: .2rem .4rem !important; }
|
package/ui/bench.js
CHANGED
|
@@ -1138,7 +1138,7 @@
|
|
|
1138
1138
|
shape: $('c-shape'), desc: $('c-desc'), tags: $('c-tags'), errors: $('c-errors'), done: $('c-done'),
|
|
1139
1139
|
head: $('c-form-head'), maxCalls: $('g-max-calls'), maxExp: $('g-max-explorers'),
|
|
1140
1140
|
gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
|
|
1141
|
-
del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger')};
|
|
1141
|
+
del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter')};
|
|
1142
1142
|
var C = null, open = null; // the collection whose details are showing
|
|
1143
1143
|
function explorersFor(n) {
|
|
1144
1144
|
return Math.max(0, Math.min(C.settings.max_explorers, C.settings.max_calls - n));
|
|
@@ -1149,10 +1149,28 @@
|
|
|
1149
1149
|
}
|
|
1150
1150
|
function recount() {
|
|
1151
1151
|
var n = picked().length, ex = explorersFor(n);
|
|
1152
|
+
var rows = c.members.querySelectorAll('label.check'), shown = c.members.querySelectorAll('label.check:not([hidden])');
|
|
1152
1153
|
c.count.textContent = n + ' member' + (n === 1 ? '' : 's') + ' + ' + ex + ' explorer' + (ex === 1 ? '' : 's') +
|
|
1153
|
-
' = ' + (n + ex) + ' calls per request' + (n && (n < 2 || n > 4) ? ' · two to four members is recommended' : '')
|
|
1154
|
+
' = ' + (n + ex) + ' calls per request' + (n && (n < 2 || n > 4) ? ' · two to four members is recommended' : '') +
|
|
1155
|
+
(shown.length < rows.length ? ' · showing ' + shown.length + ' of ' + rows.length + ' models' : '');
|
|
1154
1156
|
c.members.querySelectorAll('input').forEach(function (x) { x.disabled = !x.checked && n >= C.settings.max_calls; });
|
|
1155
1157
|
}
|
|
1158
|
+
// The member search: the filter boxes' match (any text, case-insensitive),
|
|
1159
|
+
// over alias and served name. It only hides rows, and never a ticked one, so
|
|
1160
|
+
// what is picked is always in view and every count still includes it.
|
|
1161
|
+
function filterMembers() {
|
|
1162
|
+
var q = c.find.value.trim().toLowerCase();
|
|
1163
|
+
c.members.querySelectorAll('label.check').forEach(function (lab) {
|
|
1164
|
+
var box = lab.querySelector('input');
|
|
1165
|
+
lab.hidden = !!q && !box.checked && (box.value + ' ' + (lab.dataset.served || '')).toLowerCase().indexOf(q) === -1;
|
|
1166
|
+
});
|
|
1167
|
+
recount();
|
|
1168
|
+
}
|
|
1169
|
+
c.find.addEventListener('input', filterMembers);
|
|
1170
|
+
// Escape in a box with text clears it; only an empty box lets Escape close the popup.
|
|
1171
|
+
c.find.addEventListener('keydown', function (e) {
|
|
1172
|
+
if (e.key === 'Escape' && c.find.value) { e.preventDefault(); e.stopPropagation(); c.find.value = ''; filterMembers(); }
|
|
1173
|
+
});
|
|
1156
1174
|
// Suggested tags under the field: a click adds or removes one, and the
|
|
1157
1175
|
// field stays free for any other.
|
|
1158
1176
|
function tagList() {
|
|
@@ -1188,12 +1206,15 @@
|
|
|
1188
1206
|
c.name.value = row ? row.name : ''; c.name.readOnly = !!row;
|
|
1189
1207
|
c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
|
|
1190
1208
|
drawTagPicks();
|
|
1191
|
-
c.members.textContent = '';
|
|
1209
|
+
c.members.textContent = ''; c.find.value = '';
|
|
1192
1210
|
C.models.forEach(function (alias) {
|
|
1193
1211
|
var box = el('input', {type: 'checkbox', value: alias});
|
|
1194
1212
|
box.checked = !!row && row.members.indexOf(alias) !== -1;
|
|
1195
1213
|
box.addEventListener('change', recount);
|
|
1196
|
-
var
|
|
1214
|
+
var served = (C.served || {})[alias] || '';
|
|
1215
|
+
var lab = el('label', {class: 'check', 'data-served': served});
|
|
1216
|
+
if (served && served !== alias) lab.title = served;
|
|
1217
|
+
lab.appendChild(box); lab.appendChild(el('span', {class: 'mono'}, alias));
|
|
1197
1218
|
c.members.appendChild(lab);
|
|
1198
1219
|
});
|
|
1199
1220
|
c.shape.textContent = '';
|