@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/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 and
17
- recorded in `config/retired.json`; no evidence is deleted. And ONBOARDING
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, records it in
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 memory of it -- enough to restore it exactly: endpoint, options too
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
- # 3. configs keyed by alias
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
- # 4. its alerts — the action they asked for has been taken
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(datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
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(datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
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/&lt;name&gt; through the gateway (python gateway.py).")
1774
+ f" bench/&lt;name&gt; 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. Run"
2337
- " <code>python gateway.py</code> and call a collection.</td></tr>")
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/&lt;name&gt;</code> at <code>http://127.0.0.1:8123/v1</code>;"
2616
+ f" asks for <code>bench/&lt;name&gt;</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/&lt;name&gt;</code>. Say what"
2545
2633
  " it is for, not who is in it: members change. Lower-case letters, digits and '-'.</p>"
2546
- "<label class='field'>Members</label><div id='c-members' class='members'></div>"
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
- through.update({a: d["endpoint"] for a, d in decide.load().items()})
3013
- for c in connections_view()["connections"]:
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></td>"
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, and clears its problems. Deletes no evidence, and Restore"
3258
- " brings it back. The daily check also retires models on its own when they are"
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
- if out.returncode != 0:
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 lab = el('label', {class: 'check'}); lab.appendChild(box); lab.appendChild(el('span', {class: 'mono'}, alias));
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 = '';