@wendellresearch-ai/benchwarmers 0.4.0 → 0.5.1

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/ui/bench.js CHANGED
@@ -20,9 +20,11 @@
20
20
  // The page's own confirm, a Promise of true/false: window.confirm is
21
21
  // silently false where a browser blocks dialogs. Opens over any open popup.
22
22
  var askD = $('ask-dialog'), askDone = null;
23
- function ask(text, yes, danger) {
23
+ function ask(text, yes, danger, onboard) {
24
24
  if (askDone) askDone(false);
25
25
  $('ask-text').textContent = text;
26
+ $('ask-onboard').hidden = !onboard; // Discover's Add: the Onboard dialog's mode and Vision box
27
+ $('ask-vision').checked = false;
26
28
  $('ask-yes').textContent = yes || 'OK';
27
29
  $('ask-yes').className = 'btn ' + (danger ? 'danger' : 'primary');
28
30
  askD.showModal(); $('ask-no').focus();
@@ -33,6 +35,34 @@
33
35
  $('ask-no').addEventListener('click', function () { answer(false); });
34
36
  askD.addEventListener('close', function () { answer(false); }); // Escape
35
37
 
38
+ // ---- restart (serve.restart): the page reloads once the new one answers ----
39
+ var rs = $('restart');
40
+ function restart(force) {
41
+ var out = $('restart-out');
42
+ rs.disabled = true; out.textContent = 'Restarting…';
43
+ post('/api/restart', {force: force}).then(function (res) {
44
+ if (res.ok) return back(Date.now());
45
+ rs.disabled = false; out.textContent = '';
46
+ if (!res.busy) { listErrors(out, res.errors); return; }
47
+ ask('Still in progress: ' + res.errors.join('; ') + '. Restart anyway and cut them off?', 'Restart anyway', true)
48
+ .then(function (yes) { if (yes) restart(true); });
49
+ }).catch(function (err) { rs.disabled = false; out.textContent = String(err); });
50
+ }
51
+ // The old one stops half a second after answering; /health answers again once the new one is up.
52
+ function back(since) {
53
+ setTimeout(function () {
54
+ fetch('/health', {cache: 'no-store'}).then(function (r) { if (!r.ok) throw r.status; location.reload(); })
55
+ .catch(function () {
56
+ if (Date.now() - since < 120000) back(since);
57
+ else $('restart-out').textContent = 'Not back after two minutes: look at whatever runs it.';
58
+ });
59
+ }, 1500);
60
+ }
61
+ if (rs) rs.addEventListener('click', function () {
62
+ ask('Restart Benchwarmers? The dashboard and the gateway on this port stop for a few seconds.', 'Restart')
63
+ .then(function (yes) { if (yes) restart(false); });
64
+ });
65
+
36
66
  // ---- tabs, remembered per browser ----------------------------------
37
67
  var tabs = document.querySelectorAll('.tabs button[data-tab]'); // not the sidebar's own toggle
38
68
  var onShow = {}; // a page's loader, run each time it is shown
@@ -381,6 +411,14 @@
381
411
  var act = b.dataset.act, aliases = (b.dataset.aliases || b.dataset.alias || '').split(' ').filter(Boolean);
382
412
  var status = b.closest('.problem').querySelector('.why');
383
413
  if (act === 'open') { openModel(aliases[0]); return; }
414
+ if (act === 'dismiss') { // a notice: gone once seen
415
+ b.disabled = true;
416
+ post('/api/alerts/dismiss', {key: b.dataset.key}).then(function (res) {
417
+ if (!res.ok) { status.textContent = res.errors.join('; '); status.classList.add('err'); b.disabled = false; return; }
418
+ location.reload();
419
+ }).catch(function (err) { status.textContent = String(err); b.disabled = false; });
420
+ return;
421
+ }
384
422
  if (act === 'retry') { // no confirm: one call, and only an answer changes anything
385
423
  b.disabled = true; b.textContent = 'Asking…';
386
424
  post('/api/retry', {alias: aliases[0]}).then(function (res) {
@@ -743,19 +781,21 @@
743
781
  function addVia(x, b) {
744
782
  var direct = x.via === 'direct';
745
783
  ask('Add ' + x.model + ' via ' + x.label + (x.free ? '' : ' (PAID)') + '?\n\n'
746
- + (direct ? 'The bench only: register it on the ' + x.endpoint + ' endpoint, a 4-call probe, then the suite.'
784
+ + (direct ? 'The bench only: register it on the ' + x.endpoint + ' endpoint and onboard it as chosen below.'
747
785
  // A LiteLLM or CLI Proxy API route says its own steps: only what that hub takes.
748
786
  : x.steps ? x.steps.map(function (s, i) { return (i + 1) + '. ' + s; }).join('\n')
749
787
  : '1. Open WebUI: add it to the ' + x.connection + ' connection\'s Model IDs (if that connection lists them)\n'
750
788
  + '2. Open WebUI: a public model record, tagged and with the logo of the connection\'s other models\n'
751
- + '3. The bench: register, a 4-call probe, then the suite'), 'Add').then(function (yes) { if (yes) addNow(x, b, direct); });
789
+ + '3. The bench: register it and onboard it as chosen below'), 'Add', false, true).then(function (yes) {
790
+ if (yes) addNow(x, b, direct, {mode: $('ask-mode').value, vision: $('ask-vision').checked});
791
+ });
752
792
  }
753
- function addNow(x, b, direct) {
793
+ function addNow(x, b, direct, how) {
754
794
  var title = 'Adding ' + x.model + ' via ' + x.label;
755
795
  $('d-errors').textContent = '';
756
796
  if (direct) {
757
797
  b.disabled = true; b.textContent = 'Adding…';
758
- post('/api/onboard-queue', {items: [{served: x.model, endpoint: x.endpoint}]}).then(function (res) {
798
+ post('/api/onboard-queue', {items: [Object.assign({served: x.model, endpoint: x.endpoint}, how)]}).then(function (res) {
759
799
  if (!res.ok) { listErrors($('d-errors'), res.errors); b.disabled = false; b.textContent = 'Add via this'; return; }
760
800
  dd.close(); preStep = null; o.errors.textContent = ''; od.showModal();
761
801
  afterQueued(res, {served: x.model, endpoint: x.endpoint}, title, '');
@@ -770,7 +810,7 @@
770
810
  runView(title, 'Adding it to ' + x.label + '…');
771
811
  oq.box.hidden = false; oq.chip.classList.remove('done', 'fail'); oq.chip.style.setProperty('--fill', '3%');
772
812
  oq.text.textContent = oq.text.title = title; oq.stop.hidden = oq.clear.hidden = true;
773
- post('/api/discover/add', {connection: x.connection, model: x.model, endpoint: x.endpoint}).then(function (res) {
813
+ post('/api/discover/add', Object.assign({connection: x.connection, model: x.model, endpoint: x.endpoint}, how)).then(function (res) {
774
814
  var hubDone = !!(res.served && res.steps && res.steps.length); // in the hub, even if queueing it was refused
775
815
  preStep.state = hubDone ? 'done' : 'fail';
776
816
  preStep.note = (res.steps || []).join('. ') + (hubDone ? '' : (res.steps && res.steps.length ? '. ' : '') + res.errors.join(' '));
@@ -802,8 +842,16 @@
802
842
  if (res.errors.length) listErrors($('d-errors'), res.errors); // a hub that could not be asked
803
843
  var r = res.model, p = $('d-profile'), list = $('d-routes');
804
844
  $('d-title').textContent = r.name;
805
- $('d-served').textContent = r.provider_name + ' · ' + r.model + ' · listed by ' + r.sources.join(' and ')
806
- + (r.on_bench_as ? ' · on the bench as ' + r.on_bench_as : '');
845
+ // Each source a link: FreeLLMAPI's page for the model where its sitemap has one, else its catalog.
846
+ var sv = $('d-served');
847
+ sv.textContent = r.provider_name + ' · ' + r.model + ' · listed by ';
848
+ r.sources.forEach(function (s, i) {
849
+ var fl = s === 'freellmapi';
850
+ if (i) sv.appendChild(document.createTextNode(' and '));
851
+ sv.appendChild(el('a', {href: fl ? r.page || 'https://freellmapi.co/models' : 'https://models.dev/',
852
+ target: '_blank', rel: 'noopener'}, fl ? 'FreeLLMAPI' : 'models.dev'));
853
+ });
854
+ if (r.on_bench_as) sv.appendChild(document.createTextNode(' · on the bench as ' + r.on_bench_as));
807
855
  p.textContent = '';
808
856
  [['Input', r.in_mods], ['Output', r.out_mods]].forEach(function (x) {
809
857
  var row = el('div', {class: 'prof'});
@@ -814,16 +862,26 @@
814
862
  var can = [['tools', r.tools], ['vision', r.vision], ['reasons', r.reasoning]]
815
863
  .filter(function (x) { return x[1]; }).map(function (x) { return x[0]; });
816
864
  var facts = [
865
+ r.rank != null ? 'smartest #' + r.rank + (r.speed ? ', fastest #' + r.speed : '') + ' on FreeLLMAPI' : '',
866
+ r.size || '',
817
867
  r.context ? 'context ' + Number(r.context).toLocaleString() + ' tokens' : 'context unknown',
818
868
  r.output ? 'output up to ' + Number(r.output).toLocaleString() : '',
819
869
  can.length ? 'can: ' + can.join(', ') : '',
820
870
  price(r),
821
871
  r.limits ? 'limits ' + limits(r.limits) : '',
872
+ r.budget ? r.budget + ' tokens a month' : '',
822
873
  r.released ? 'released ' + r.released : '',
823
874
  r.knowledge ? 'knowledge to ' + r.knowledge : '',
824
875
  r.open_weights != null ? (r.open_weights ? 'open weights' : 'closed weights') : '',
825
876
  'first seen ' + (r.first_seen === 'baseline' ? 'before the list began' : r.first_seen)];
826
877
  p.appendChild(el('div', {class: 'prof'}, facts.filter(Boolean).join(' · ')));
878
+ // FreeLLMAPI's operating notes for this provider, e.g. thinking tokens that eat the output cap.
879
+ (r.quirks || []).forEach(function (q) {
880
+ var row = el('div', {class: 'prof'});
881
+ row.appendChild(el('strong', {}, q.title + ': '));
882
+ row.appendChild(document.createTextNode(q.body || ''));
883
+ p.appendChild(row);
884
+ });
827
885
  if (r.also_on && r.also_on.length) p.appendChild(el('div', {class: 'prof muted'}, 'Also on the bench as ' + r.also_on.join(', ')));
828
886
  list.textContent = '';
829
887
  res.routes.forEach(function (x) {
@@ -1279,7 +1337,8 @@
1279
1337
  shape: $('c-shape'), desc: $('c-desc'), tags: $('c-tags'), errors: $('c-errors'), done: $('c-done'),
1280
1338
  head: $('c-form-head'), maxCalls: $('g-max-calls'), maxExp: $('g-max-explorers'),
1281
1339
  gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
1282
- del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter')};
1340
+ del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
1341
+ seats: $('c-seats'), prov: $('c-member-provider')};
1283
1342
  var C = null, open = null; // the collection whose details are showing
1284
1343
  function explorersFor(n) {
1285
1344
  return Math.max(0, Math.min(C.settings.max_explorers, C.settings.max_calls - n));
@@ -1299,10 +1358,45 @@
1299
1358
  (shown.length < rows.length ? ' · showing ' + shown.length + ' of ' + rows.length + ' models' : '') +
1300
1359
  (paid ? ' (' + paid + ' paid hidden by the free tag)' : '') +
1301
1360
  (greyed ? ' · ' + greyed + ' greyed out by the tags (hover one for why)' : '') +
1302
- (clash ? ' · ' + clash + ' ticked member' + (clash === 1 ? ' does' : 's do') + ' not fit the tags' : '');
1361
+ (clash ? ' · ' + clash + ' ticked member' + (clash === 1 ? ' does' : 's do') + ' not fit the tags' : '') +
1362
+ twice().map(function (t) {
1363
+ return ' · ' + t.name + ' is ticked on ' + t.on.join(' and ') + ': one model agrees with itself, it does not check itself';
1364
+ }).join('');
1303
1365
  c.members.querySelectorAll('input').forEach(function (x) {
1304
1366
  x.disabled = !x.checked && (n >= C.settings.max_calls || !!x.dataset.blocked);
1305
1367
  });
1368
+ drawSeats(ex);
1369
+ }
1370
+ function fact(alias) { return (C.facts || {})[alias] || {}; }
1371
+ // The same model ticked on two providers (route.family_of, as the gateway
1372
+ // keeps explorers off a member's model): allowed, and said.
1373
+ function twice() {
1374
+ var by = {};
1375
+ picked().forEach(function (a) { var f = fact(a).family || a; (by[f] = by[f] || []).push(a); });
1376
+ return Object.keys(by).filter(function (f) { return by[f].length > 1; }).map(function (f) {
1377
+ var row = c.members.querySelector('.m-row[data-family="' + CSS.escape(f) + '"]');
1378
+ return {name: row ? row.dataset.name : by[f][0], on: by[f].map(function (a) { return fact(a).provider || a; })};
1379
+ });
1380
+ }
1381
+ // The panel as it will sit, as the Collections list draws it; a member's ×
1382
+ // unticks it, as unticking its chip does.
1383
+ function drawSeats(ex) {
1384
+ c.seats.textContent = '';
1385
+ picked().forEach(function (a) {
1386
+ var seat = el('span', {class: 'seat'}, a);
1387
+ if (fact(a).provider) seat.appendChild(el('span', {class: 'k after'}, fact(a).provider));
1388
+ var x = el('button', {type: 'button', class: 'seat-x', 'aria-label': 'Remove ' + a + ' from the collection',
1389
+ title: 'Remove from the collection'}, '×');
1390
+ x.addEventListener('click', function () {
1391
+ var box = c.members.querySelector('input[value="' + CSS.escape(a) + '"]');
1392
+ if (box) { box.checked = false; applyTags(); }
1393
+ });
1394
+ seat.appendChild(x);
1395
+ c.seats.appendChild(seat);
1396
+ });
1397
+ for (var i = 0; i < ex; i++) {
1398
+ c.seats.appendChild(el('span', {class: 'seat explorer'}, 'explorer' + (c.shape.value ? ' · ' + c.shape.value : '')));
1399
+ }
1306
1400
  }
1307
1401
  // Tags that say what every member must do: why a model does not fit them
1308
1402
  // (none: it fits). From collections_view's facts; an unknown context is
@@ -1340,24 +1434,35 @@
1340
1434
  lab.classList.toggle('clash', why.length > 0 && box.checked);
1341
1435
  lab.dataset.paid = paid ? '1' : '';
1342
1436
  if (why.length) box.dataset.blocked = '1'; else delete box.dataset.blocked;
1437
+ // A chip says only the provider: the alias and served name are its first line.
1343
1438
  var served = lab.dataset.served;
1344
- lab.title = (served && served !== box.value ? served : '') + (why.length ? (served && served !== box.value ? '\n' : '') + why.join('\n') : '');
1439
+ lab.title = [box.value + (served && served !== box.value ? ' (' + served + ')' : '')].concat(why).join('\n');
1345
1440
  });
1346
1441
  filterMembers();
1347
1442
  }
1348
1443
  // The member search: the filter boxes' match (any text, case-insensitive),
1349
- // over alias and served name. It only hides rows, and never a ticked one, so
1350
- // what is picked is always in view and every count still includes it.
1444
+ // over the model's name and each chip's alias, served name and provider,
1445
+ // and the provider select beside it. It only hides chips, and never a ticked
1446
+ // one, so what is picked is always in view and every count still includes
1447
+ // it; a row goes when all its chips do.
1351
1448
  function filterMembers() {
1352
- var q = c.find.value.trim().toLowerCase();
1353
- c.members.querySelectorAll('label.check').forEach(function (lab) {
1354
- var box = lab.querySelector('input');
1355
- lab.hidden = !box.checked && (!!lab.dataset.paid ||
1356
- !!q && (box.value + ' ' + (lab.dataset.served || '')).toLowerCase().indexOf(q) === -1);
1449
+ var q = c.find.value.trim().toLowerCase(), prov = c.prov.value;
1450
+ c.members.querySelectorAll('.m-row').forEach(function (row) {
1451
+ var named = !q || row.dataset.name.toLowerCase().indexOf(q) !== -1, any = false;
1452
+ row.querySelectorAll('label.check').forEach(function (lab) {
1453
+ var box = lab.querySelector('input');
1454
+ var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
1455
+ lab.textContent).toLowerCase();
1456
+ lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1
1457
+ || !!prov && (fact(box.value).provider || '') !== prov);
1458
+ any = any || !lab.hidden;
1459
+ });
1460
+ row.hidden = !any;
1357
1461
  });
1358
1462
  recount();
1359
1463
  }
1360
1464
  c.find.addEventListener('input', filterMembers);
1465
+ c.prov.addEventListener('change', filterMembers);
1361
1466
  // Escape in a box with text clears it; only an empty box lets Escape close the popup.
1362
1467
  c.find.addEventListener('keydown', function (e) {
1363
1468
  if (e.key === 'Escape' && c.find.value) { e.preventDefault(); e.stopPropagation(); c.find.value = ''; filterMembers(); }
@@ -1384,6 +1489,7 @@
1384
1489
  c.tags.addEventListener('input', function () { drawTagPicks(); applyTags(); });
1385
1490
  function showMerger() { $('c-merger-row').hidden = c.reply.value !== 'merged'; }
1386
1491
  c.reply.addEventListener('change', showMerger);
1492
+ c.shape.addEventListener('change', recount); // the explorer seats say their shape
1387
1493
  // A collection opens in a popup, as every other edit on the dashboard does:
1388
1494
  // the one clicked, or a new one from New collection.
1389
1495
  function closeForm() {
@@ -1400,14 +1506,40 @@
1400
1506
  c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
1401
1507
  drawTagPicks();
1402
1508
  c.members.textContent = ''; c.find.value = '';
1403
- C.models.forEach(function (alias) {
1404
- var box = el('input', {type: 'checkbox', value: alias});
1405
- box.checked = !!row && row.members.indexOf(alias) !== -1;
1406
- box.addEventListener('change', applyTags); // ticked or not changes what a misfit shows as
1407
- var served = (C.served || {})[alias] || '';
1408
- var lab = el('label', {class: 'check', 'data-served': served});
1409
- lab.appendChild(box); lab.appendChild(el('span', {class: 'mono'}, alias));
1410
- c.members.appendChild(lab);
1509
+ c.prov.length = 1; // "Every provider", then each one a model is on
1510
+ Array.from(new Set(C.models.map(function (a) { return fact(a).provider; }).filter(Boolean))).sort()
1511
+ .forEach(function (p) { c.prov.appendChild(el('option', {value: p}, p)); });
1512
+ // One row per model, named by its shortest alias, with what the first
1513
+ // route says it holds and can do; each provider it is on is a chip that
1514
+ // ticks that alias. A chip whose window differs from the row's says its own.
1515
+ var groups = {};
1516
+ C.models.forEach(function (a) { var f = fact(a).family || a; (groups[f] = groups[f] || []).push(a); });
1517
+ Object.keys(groups).map(function (f) {
1518
+ var routes = groups[f].slice().sort(function (x, y) { return x.length - y.length || (x < y ? -1 : 1); });
1519
+ return {family: f, name: routes[0], routes: routes};
1520
+ }).sort(function (x, y) { return x.name.toLowerCase() < y.name.toLowerCase() ? -1 : 1; }).forEach(function (g) {
1521
+ var first = fact(g.name), caps = first.caps || {};
1522
+ var can = ['vision', 'tools'].filter(function (k) { return caps[k]; });
1523
+ var line = el('div', {class: 'm-row', role: 'group', 'aria-label': g.name, 'data-family': g.family, 'data-name': g.name});
1524
+ var name = el('div', {class: 'm-name'});
1525
+ name.appendChild(el('span', {class: 'mono'}, g.name));
1526
+ name.appendChild(el('span', {class: 'm-facts'}, [first.context ? tokens(first.context) : '', can.join(', '),
1527
+ first.price === 'unknown' ? '' : first.price].filter(Boolean).join(' · ')));
1528
+ line.appendChild(name);
1529
+ var chips = el('div', {class: 'm-routes'});
1530
+ g.routes.forEach(function (alias) {
1531
+ var f = fact(alias);
1532
+ var box = el('input', {type: 'checkbox', value: alias, 'aria-label': alias + ' on ' + (f.provider || 'its endpoint')});
1533
+ box.checked = !!row && row.members.indexOf(alias) !== -1;
1534
+ box.addEventListener('change', applyTags); // ticked or not changes what a misfit shows as
1535
+ var lab = el('label', {class: 'check', 'data-served': (C.served || {})[alias] || ''});
1536
+ lab.appendChild(box);
1537
+ lab.appendChild(el('span', null, (f.provider || alias) +
1538
+ (f.context && first.context && f.context !== first.context ? ' · ' + tokens(f.context) : '')));
1539
+ chips.appendChild(lab);
1540
+ });
1541
+ line.appendChild(chips);
1542
+ c.members.appendChild(line);
1411
1543
  });
1412
1544
  c.shape.textContent = '';
1413
1545
  c.shape.appendChild(el('option', {value: ''}, 'automatic'));
@@ -1418,10 +1550,18 @@
1418
1550
  c.merger.textContent = '';
1419
1551
  c.merger.appendChild(el('option', {value: ''}, 'automatic'));
1420
1552
  C.models.forEach(function (alias) {
1421
- var o = el('option', {value: alias}, alias); if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
1553
+ var o = el('option', {value: alias}, alias + (fact(alias).provider ? ' · ' + fact(alias).provider : ''));
1554
+ if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
1422
1555
  });
1423
1556
  showMerger(); applyTags();
1424
1557
  }
1558
+ // What a collection's members can all do, in the words the tag checks use.
1559
+ var CAN = {reasoning: 'reason'};
1560
+ Object.keys(CAP_TAGS).forEach(function (t) { CAN[CAP_TAGS[t][0]] = CAP_TAGS[t][1]; });
1561
+ function tokens(n) { // 512k, 1M (2^20), 1.9M
1562
+ return n >= 1000000 ? String(Math.round(n / 104857.6) / 10).replace(/\.0$/, '') + 'M'
1563
+ : Math.floor(n / 1000).toLocaleString() + 'k';
1564
+ }
1425
1565
  function loadCollections(keep) {
1426
1566
  fetch('/api/collections').then(function (r) { return r.json(); }).then(function (res) {
1427
1567
  C = res; c.list.textContent = '';
@@ -1430,24 +1570,38 @@
1430
1570
  var box = el('button', {class: 'endpoint c-row', type: 'button', 'data-name': row.name,
1431
1571
  'aria-label': 'Open bench/' + row.name});
1432
1572
  var left = el('div');
1433
- var title = el('div', {class: 'what'});
1434
- title.appendChild(el('span', {class: 'alias'}, 'bench/' + row.name + ' '));
1435
- row.tags.forEach(function (t) { title.appendChild(el('span', {class: 'chip', style: 'margin-left:.3rem'}, t)); });
1573
+ var title = el('div', {class: 'top'});
1574
+ title.appendChild(el('span', {class: 'alias'}, 'bench/' + row.name));
1575
+ row.tags.forEach(function (t) { title.appendChild(el('span', {class: 'chip'}, t)); });
1436
1576
  left.appendChild(title);
1437
- if (row.description) left.appendChild(el('div', {}, row.description));
1438
- left.appendChild(el('div', {class: 'base'}, row.members.join(', ') + ' + ' + row.explorers + ' explorer' +
1439
- (row.explorers === 1 ? '' : 's') + (row.shape ? ' (' + row.shape + ')' : '') +
1440
- (row.reply === 'merged' ? ' · merged by ' + (row.merger || 'verdicts') : '')));
1577
+ if (row.description) left.appendChild(el('p', {class: 'desc'}, row.description));
1578
+ // The panel as it sits: a seat per member, an empty one per explorer,
1579
+ // and the merger after an arrow.
1580
+ var seats = el('div', {class: 'seats'});
1581
+ row.members.forEach(function (m) { seats.appendChild(el('span', {class: 'seat'}, m)); });
1582
+ for (var i = 0; i < row.explorers; i++) {
1583
+ seats.appendChild(el('span', {class: 'seat explorer'}, 'explorer' + (row.shape ? ' · ' + row.shape : '')));
1584
+ }
1585
+ if (row.reply === 'merged') {
1586
+ var merge = el('span', {class: 'merge'}), seat = el('span', {class: 'seat merger'});
1587
+ merge.appendChild(el('span', {'aria-hidden': 'true'}, '→'));
1588
+ seat.appendChild(el('span', {class: 'k'}, 'merger'));
1589
+ seat.appendChild(document.createTextNode(row.merger || 'chosen by verdicts'));
1590
+ merge.appendChild(seat); seats.appendChild(merge);
1591
+ }
1592
+ left.appendChild(seats);
1441
1593
  var t = row.together;
1442
1594
  if (t) {
1443
1595
  var lim = Object.keys(t.limits || {}).filter(function (k) { return t.limits[k]; })
1444
1596
  .map(function (k) { return t.limits[k].toLocaleString() + ' ' + k; });
1445
- left.appendChild(el('div', {class: 'base'}, 'Together: ' +
1446
- (t.context_window ? 'prompts up to ' + Math.floor(t.context_window / 1000).toLocaleString() + 'k tokens' : 'context unknown') +
1447
- (t.context_unknown.length ? ' (unknown for ' + t.context_unknown.join(', ') + ')' : '') +
1448
- ' · all can: ' + (t.capabilities.join(', ') || 'nothing known in common') +
1449
- ' · ' + t.connections.length + ' connection' + (t.connections.length === 1 ? '' : 's') +
1450
- (lim.length ? ' · tightest ' + lim.join(', ') : '')));
1597
+ var can = t.capabilities.map(function (k) { return CAN[k] || k; });
1598
+ var n = t.connections.length + ' connection' + (t.connections.length === 1 ? '' : 's');
1599
+ left.appendChild(el('p', {class: 'together'},
1600
+ (t.context_window ? 'Prompts up to ' + tokens(t.context_window) + ' tokens' : 'Context window unknown') +
1601
+ (t.context_unknown.length ? ' (unknown for ' + t.context_unknown.join(', ') + '). ' : '. ') +
1602
+ (can.length ? 'All can ' + (can.length > 1 ? can.slice(0, -1).join(', ') + ' and ' : '') + can[can.length - 1] + '. '
1603
+ : 'Nothing known they can all do. ') +
1604
+ (lim.length ? 'Tightest limit ' + lim.join(', ') + ' across ' + n + '.' : 'No limits known across ' + n + '.')));
1451
1605
  }
1452
1606
  box.appendChild(left);
1453
1607
  box.appendChild(el('span', {class: 'open-hint', 'aria-hidden': 'true'}, 'Open ›'));
@@ -1544,6 +1698,355 @@
1544
1698
  }).catch(function (err) { $('v-errors').textContent = String(err); });
1545
1699
  });
1546
1700
 
1701
+ // ---- Quota: a tile per provider (serve.quota_tiles, metrics/quotaview.py) ----
1702
+ // Each tile takes its provider's shape: groups of windows (a 5-hour and a
1703
+ // weekly limit boxed together), one pool (OpenRouter's free requests a day,
1704
+ // a bar per key) or, where nothing is stated, each model's last day. A
1705
+ // meter is ten segments filled like a bar: 85% is eight and a half.
1706
+ var QD = $('quota-data') ? JSON.parse($('quota-data').textContent) : null;
1707
+ var qBox = $('q-tiles');
1708
+ function qWhen(iso) {
1709
+ if (!iso) return '';
1710
+ var d = new Date(iso), s = (d - Date.now()) / 1000;
1711
+ if (s <= 0) return 'now';
1712
+ if (s < 86400) {
1713
+ var h = Math.floor(s / 3600), m = Math.round((s % 3600) / 60);
1714
+ return 'in ' + (h ? h + 'h ' : '') + m + 'm';
1715
+ }
1716
+ if (s < 6 * 86400) return d.toLocaleDateString([], {weekday: 'short'}) + ' ' + d.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
1717
+ return d.toLocaleDateString([], {month: 'numeric', day: 'numeric'}) + ' ' + d.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
1718
+ }
1719
+ function qAgo(iso) {
1720
+ var s = (Date.now() - new Date(iso)) / 1000;
1721
+ return s < 90 ? 'just now' : s < 5400 ? Math.round(s / 60) + ' min ago' : s < 129600 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' days ago';
1722
+ }
1723
+ function qTone(pct) { return pct == null ? 'none' : pct >= 70 ? 'ok' : pct >= 25 ? 'warn' : 'err'; }
1724
+ // `pct` null draws the ten segments empty: nothing to count yet. Calls
1725
+ // ("answered") fill green over red, the failed share showing as red.
1726
+ function qSegs(pct, what) {
1727
+ var tone = pct != null && what === 'answered' ? 'calls' : qTone(pct);
1728
+ var box = el('div', {class: 'segs ' + tone, role: 'img',
1729
+ 'aria-label': pct == null ? 'nothing yet' : pct + '% ' + (what || 'left')});
1730
+ for (var i = 0; i < 10; i++) {
1731
+ var s = el('span'), f = el('i');
1732
+ f.style.width = Math.round(Math.min(Math.max((pct || 0) / 10 - i, 0), 1) * 100) + '%';
1733
+ s.appendChild(f); box.appendChild(s);
1734
+ }
1735
+ return box;
1736
+ }
1737
+ function qNum(n) { return Number(n).toLocaleString(); }
1738
+ function qAlias(a) { // as serve.named draws a model: its popup on a click
1739
+ var link = el('a', {href: '#', class: 'model', 'data-alias': a, draggable: 'false', title: a});
1740
+ link.appendChild(el('span', {class: 'alias mono'}, a));
1741
+ return link;
1742
+ }
1743
+ // 189, 19,997, 494k, 12.5M: whole below 100,000, then short.
1744
+ function qShort(n) {
1745
+ n = Number(n);
1746
+ if (n < 1e5) return Math.round(n * 100) / 100 >= 1000 ? Math.round(n).toLocaleString() : String(Math.round(n * 100) / 100);
1747
+ if (n < 1e6) return Math.round(n / 1e3) + 'k';
1748
+ return String(n >= 1e8 ? Math.round(n / 1e6) : Math.round(n / 1e5) / 10).replace(/\.0$/, '') + 'M';
1749
+ }
1750
+ // Every figure on a tile is one meter: what is left of what, in the
1751
+ // provider's own unit, on the left (it may wrap); when it resets on the
1752
+ // right (it never does); the segments under it.
1753
+ function qMeter(label, value, pct, what, title) {
1754
+ var box = el('div', {class: 'q-meter'}), row = el('div', {class: 'q-line'});
1755
+ var l = typeof label === 'string' ? el('span', {class: 'q-label'}) : label;
1756
+ if (typeof label === 'string') l.innerHTML = label;
1757
+ row.appendChild(l);
1758
+ var v = el('span', {class: 'q-val'}); v.innerHTML = value; row.appendChild(v);
1759
+ if (title) box.title = title;
1760
+ box.appendChild(row); box.appendChild(qSegs(pct, what));
1761
+ return box;
1762
+ }
1763
+ function qReset(iso) {
1764
+ return iso ? '<span title="Resets ' + new Date(iso).toLocaleString() + '">' + qWhen(iso) + '</span>' : '';
1765
+ }
1766
+ function qWindow(w) {
1767
+ var raw = w.of != null && w.remaining != null;
1768
+ // The percentage leads every meter, raw or not, so they line up.
1769
+ return qMeter('<b>' + w.left + '%</b> left · ' + (raw ? '<b>' + qShort(w.remaining) + '</b> of '
1770
+ + qShort(w.of) + ' ' + w.label : w.label),
1771
+ qReset(w.reset_at), w.left, 'left',
1772
+ (raw ? qNum(w.remaining) + ' of ' + qNum(w.of) + ' left, ' : '') + w.left + '%');
1773
+ }
1774
+ // How much of a last day answered: a model's (its link), or `label`'s.
1775
+ function qCalls(m, label) {
1776
+ var n = m.ok + m.failed, pct = n ? Math.round(100 * m.ok / n) : null;
1777
+ var value = qResting(m) ? '<span class="warn-t">resting · ' + qWhen(m.until) + '</span>'
1778
+ : n ? '<b>' + m.ok + '</b> of ' + n + ' answered' : '<span class="muted">no calls</span>';
1779
+ return qMeter(label || qAlias(m.alias), value, pct, 'answered',
1780
+ n ? m.ok + ' answered, ' + m.failed + ' failed in the last 24 hours' : 'not called in the last 24 hours');
1781
+ }
1782
+ function qResting(m) { return m.until && new Date(m.until) > Date.now(); }
1783
+ function qTile(t) {
1784
+ var node = el('article', {class: 'q-tile' + (t.out ? ' out' : ''), draggable: 'true', tabindex: '0',
1785
+ 'data-id': t.id, 'aria-label': t.name + ': drag, or use the arrow keys, to move it'});
1786
+ var head = el('div', {class: 'q-head'});
1787
+ head.appendChild(el('b', {class: 'nm', title: t.name}, t.name));
1788
+ var f = t.fresh, how = {live: 'read ', seen: 'seen ', answered: 'answered '}[f.how] || '';
1789
+ head.appendChild(el('span', {class: 'chip ' + (f.how === 'live' ? 'ok' : 'via'),
1790
+ title: {live: 'Last asked of the provider', seen: 'Last stated in a reply\'s headers',
1791
+ answered: 'Last answer from one of its models'}[f.how] || 'Nothing known yet'},
1792
+ f.at ? how + qAgo(f.at) : 'nothing yet'));
1793
+ node.appendChild(head);
1794
+ // Every word about the figures in one place, under the name: where they
1795
+ // come from, its plan, your per-minute caps, what a live read said. The
1796
+ // connection's own name and address are in Models.
1797
+ var about = el('div', {class: 'q-about'});
1798
+ var src = t.source || [];
1799
+ about.appendChild(el('div', {class: 'q-from'}, (src.length && src[0] !== 'nothing stated'
1800
+ ? 'From ' + src.join('; ') : 'No limit stated') + (t.plan ? ' · ' + t.plan + ' plan' : '')));
1801
+ if (t.conn_limit) about.appendChild(el('div', {}, 'Your cap: ' + t.conn_limit + '.'));
1802
+ (t.notes || []).forEach(function (n) { about.appendChild(el('div', {}, n)); });
1803
+ node.appendChild(about);
1804
+ var body = el('div', {class: 'q-body'});
1805
+ // Day and month caps, counted by the bench: yours, else what the provider
1806
+ // documents. The From line names the source; the title says the rest.
1807
+ (t.caps || []).forEach(function (g) {
1808
+ var box = el('section', {class: 'q-group bare', title: g.title || ''});
1809
+ g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
1810
+ body.appendChild(box);
1811
+ });
1812
+ if (t.kind === 'groups') {
1813
+ t.groups.forEach(function (g) {
1814
+ var box = el('section', {class: g.label ? 'q-group' : 'q-group bare'});
1815
+ if (g.label) box.appendChild(el('h4', {}, g.label));
1816
+ g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
1817
+ body.appendChild(box);
1818
+ });
1819
+ } else if (t.kind === 'pool') {
1820
+ t.pool.forEach(function (p) {
1821
+ body.appendChild(p.of
1822
+ ? qMeter('<b>' + p.left + '%</b> left · <b>' + qShort(Math.max(0, p.of - p.used)) + '</b> of '
1823
+ + qShort(p.of) + ' ' + p.label,
1824
+ qReset(p.reset_at), p.left, 'left')
1825
+ : qMeter('<b>' + qShort(p.used) + '</b> ' + p.label + ' · limit not known', qReset(p.reset_at), null));
1826
+ });
1827
+ } else if (!(t.caps || []).length) {
1828
+ // Nothing stated to count against: how the provider's calls went, all
1829
+ // its models together (each one's are in Models).
1830
+ var all = t.models.reduce(function (s, m) { return {ok: s.ok + m.ok, failed: s.failed + m.failed}; }, {ok: 0, failed: 0});
1831
+ body.appendChild(qCalls({alias: '', ok: all.ok, failed: all.failed, until: null}, 'Calls, last 24 hours'));
1832
+ }
1833
+ if (t.kind !== 'models') {
1834
+ t.models.filter(qResting).forEach(function (m) {
1835
+ var r = el('div', {class: 'q-rest'});
1836
+ r.appendChild(qAlias(m.alias)); r.appendChild(document.createTextNode(' resting · back ' + qWhen(m.until)));
1837
+ body.appendChild(r);
1838
+ });
1839
+ }
1840
+ node.appendChild(body);
1841
+ var msg = el('div', {class: 'q-msg', 'aria-live': 'polite'});
1842
+ node.appendChild(msg);
1843
+ // One Refresh: the server picks a free read, one short call or the records.
1844
+ var foot = el('div', {class: 'q-foot'});
1845
+ var rb = el('button', {class: 'btn small', type: 'button', 'aria-label': 'Refresh ' + t.name,
1846
+ title: {live: 'Asks the provider what is left (free)',
1847
+ check: 'Sends one 16-token request; the reply\'s headers say what is left'}[t.refresh]
1848
+ || 'Reads the bench\'s own records again (this provider states no limits to ask about)'});
1849
+ rb.innerHTML = '&#x21bb; Refresh';
1850
+ rb.addEventListener('click', function () { qAct(t.id, node); });
1851
+ foot.appendChild(rb);
1852
+ var mb = el('button', {class: 'btn small', type: 'button'}, 'Models ');
1853
+ mb.appendChild(el('span', {class: 'chip ' + (t.live === t.bench ? 'ok' : t.live ? 'warn' : 'err')}, t.live + '/' + t.bench));
1854
+ mb.title = t.live + ' of the ' + t.bench + ' bench models on it are free to call';
1855
+ mb.addEventListener('click', function () { qModels(t); });
1856
+ foot.appendChild(mb);
1857
+ node.appendChild(foot);
1858
+ return node;
1859
+ }
1860
+ function qAct(id, node) {
1861
+ var msg = node.querySelector('.q-msg');
1862
+ msg.className = 'q-msg muted'; msg.textContent = 'Refreshing…';
1863
+ return post('/api/quota/refresh', {tag: id}).then(function (r) {
1864
+ if (!r.ok) { msg.className = 'q-msg errors'; msg.textContent = r.errors.join('; '); return; }
1865
+ var i = QD.tiles.findIndex(function (t) { return t.id === id; });
1866
+ if (r.tile && i >= 0) {
1867
+ QD.tiles[i] = r.tile;
1868
+ var fresh = qTile(r.tile);
1869
+ node.replaceWith(fresh);
1870
+ if (r.note) { var m = fresh.querySelector('.q-msg'); m.className = 'q-msg muted'; m.textContent = r.note; }
1871
+ }
1872
+ drawCharts();
1873
+ }).catch(function (e) { msg.className = 'q-msg errors'; msg.textContent = String(e); });
1874
+ }
1875
+ function qModels(t) {
1876
+ $('q-models-h').textContent = t.name + ': ' + t.bench + ' on the bench';
1877
+ $('q-models-sub').textContent = t.live + ' free to call now · the last 24 hours of calls';
1878
+ // How it is reached, asked when the popup opens (it reads Open WebUI's connections).
1879
+ var reach = $('q-models-about');
1880
+ function reachLine(after) {
1881
+ reach.textContent = '';
1882
+ var line = el('div'); line.appendChild(el('span', {class: 'mono'}, t.tag));
1883
+ if (after) line.appendChild(document.createTextNode(' · ' + after));
1884
+ reach.appendChild(line);
1885
+ }
1886
+ reachLine('finding how it is reached…');
1887
+ post('/api/quota/about', {tag: t.id}).then(function (r) {
1888
+ if (!r.ok) { reachLine(r.errors.join('; ')); return; }
1889
+ reachLine(r.routes.length ? '' : 'no endpoint or Open WebUI connection by this name');
1890
+ r.routes.forEach(function (x) {
1891
+ var d = el('div', {}, 'Through ' + x.via + ': ');
1892
+ d.appendChild(el('code', {}, x.url)); reach.appendChild(d);
1893
+ });
1894
+ }).catch(function (e) { reachLine(String(e)); });
1895
+ var list = $('q-models-list'); list.textContent = '';
1896
+ var tb = el('table'), hd = el('tr');
1897
+ ['Model', 'Now', 'Last 24 h', 'Last answered', 'Known limit'].forEach(function (h) { hd.appendChild(el('th', {}, h)); });
1898
+ tb.appendChild(el('thead')).appendChild(hd);
1899
+ var body = tb.appendChild(el('tbody'));
1900
+ t.models.forEach(function (m) {
1901
+ var tr = el('tr'), name = el('td');
1902
+ name.appendChild(qAlias(m.alias)); tr.appendChild(name);
1903
+ var now = el('td'); now.appendChild(el('span', {class: 'chip ' + (qResting(m) ? 'warn' : 'ok')}, qResting(m) ? 'resting · ' + qWhen(m.until) : 'ready'));
1904
+ tr.appendChild(now);
1905
+ var n = m.ok + m.failed, calls = el('td', {class: 'q-calls', title: m.failed + ' failed'});
1906
+ calls.appendChild(qSegs(n ? Math.round(100 * m.ok / n) : null, 'answered'));
1907
+ calls.appendChild(el('span', {}, n ? m.ok + ' of ' + n + ' answered' : 'no calls'));
1908
+ tr.appendChild(calls);
1909
+ tr.appendChild(el('td', {}, m.last_ok ? qAgo(m.last_ok) : '—'));
1910
+ tr.appendChild(el('td', {}, m.limit || '—'));
1911
+ body.appendChild(tr);
1912
+ });
1913
+ list.appendChild(tb);
1914
+ $('q-models').showModal();
1915
+ }
1916
+ function qDefault(a, b) {
1917
+ return (b.out - a.out) || ((a.next_reset || '9999') < (b.next_reset || '9999') ? -1 : (a.next_reset || '9999') > (b.next_reset || '9999') ? 1 : 0)
1918
+ || (a.id < b.id ? -1 : 1);
1919
+ }
1920
+ function qDraw() {
1921
+ qBox.textContent = '';
1922
+ QD.tiles.forEach(function (t) { qBox.appendChild(qTile(t)); });
1923
+ if (!QD.tiles.length) qBox.appendChild(el('p', {class: 'muted'}, 'No model on the bench yet.'));
1924
+ }
1925
+ function qSaveOrder() {
1926
+ var ids = Array.prototype.map.call(qBox.querySelectorAll('.q-tile'), function (n) { return n.dataset.id; });
1927
+ var at = {}; ids.forEach(function (id, i) { at[id] = i; });
1928
+ QD.tiles.sort(function (a, b) { return at[a.id] - at[b.id]; });
1929
+ post('/api/quota/order', {order: ids}).then(function (r) {
1930
+ $('q-done').textContent = r.ok ? 'Order kept.' : ''; $('q-errors').textContent = r.ok ? '' : r.errors.join('; ');
1931
+ });
1932
+ }
1933
+ if (QD && qBox) {
1934
+ qDraw();
1935
+ $('q-models-close').addEventListener('click', function () { $('q-models').close(); });
1936
+ var dragged = null;
1937
+ qBox.addEventListener('dragstart', function (e) {
1938
+ dragged = e.target.closest && e.target.closest('.q-tile');
1939
+ if (!dragged) return;
1940
+ dragged.classList.add('dragging');
1941
+ e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragged.dataset.id);
1942
+ });
1943
+ qBox.addEventListener('dragover', function (e) {
1944
+ if (!dragged) return;
1945
+ e.preventDefault();
1946
+ var over = e.target.closest && e.target.closest('.q-tile');
1947
+ if (!over || over === dragged) return;
1948
+ var r = over.getBoundingClientRect();
1949
+ var after = e.clientX > r.left + r.width / 2;
1950
+ over.parentNode.insertBefore(dragged, after ? over.nextSibling : over);
1951
+ });
1952
+ qBox.addEventListener('dragend', function () {
1953
+ if (!dragged) return;
1954
+ dragged.classList.remove('dragging'); dragged = null; qSaveOrder();
1955
+ });
1956
+ qBox.addEventListener('keydown', function (e) {
1957
+ var t = e.target;
1958
+ if (!t.classList || !t.classList.contains('q-tile')) return;
1959
+ var back = e.key === 'ArrowLeft' || e.key === 'ArrowUp', fwd = e.key === 'ArrowRight' || e.key === 'ArrowDown';
1960
+ if (!back && !fwd) return;
1961
+ e.preventDefault();
1962
+ var sib = back ? t.previousElementSibling : t.nextElementSibling;
1963
+ if (!sib) return;
1964
+ qBox.insertBefore(t, back ? sib : sib.nextSibling);
1965
+ t.focus(); qSaveOrder();
1966
+ });
1967
+ $('q-reorder').addEventListener('click', function () {
1968
+ QD.tiles.sort(qDefault); qDraw();
1969
+ post('/api/quota/order', {order: []}).then(function (r) { $('q-done').textContent = r.ok ? 'Sorted: out first, then the soonest reset.' : ''; });
1970
+ });
1971
+ $('q-all').addEventListener('click', function () {
1972
+ var live = QD.tiles.filter(function (t) { return t.refresh === 'live'; });
1973
+ $('q-done').textContent = 'Refreshing ' + live.length + '…';
1974
+ live.reduce(function (p, t) {
1975
+ return p.then(function () {
1976
+ var node = qBox.querySelector('.q-tile[data-id="' + CSS.escape(t.id) + '"]');
1977
+ return node ? qAct(t.id, node) : null;
1978
+ });
1979
+ }, Promise.resolve()).then(function () { $('q-done').textContent = 'Refreshed ' + live.length + '.'; });
1980
+ });
1981
+ }
1982
+
1983
+ // The Quota page's two charts, in the theme's colours like the Benchmarks ones.
1984
+ function drawQuotaCharts(tok, style, width) {
1985
+ if (!QD) return;
1986
+ if (!window.Plot) {
1987
+ ['chart-sent', 'chart-resets'].forEach(function (id) {
1988
+ if ($(id)) $(id).textContent = 'Charts need Observable Plot from cdn.jsdelivr.net, which did not load.';
1989
+ });
1990
+ return;
1991
+ }
1992
+ var ink = tok('--ink', '#24331f'), good = tok('--ok', '#327a16'), warn = tok('--warn', '#7a4d00'), line = tok('--line', '#e4dfd2');
1993
+ var sent = $('chart-sent');
1994
+ if (sent) {
1995
+ var R = QD.sent.slice().sort(function (a, b) { return b.month - a.month; });
1996
+ sent.textContent = '';
1997
+ var lg = el('div', {class: 'q-legend'});
1998
+ [['This month, requests', 0.3], ['Last 24 hours, requests', 1]].forEach(function (k) {
1999
+ var s = el('span'), sw = el('i'); sw.style.background = good; sw.style.opacity = k[1];
2000
+ s.appendChild(sw); s.appendChild(document.createTextNode(k[0])); lg.appendChild(s);
2001
+ });
2002
+ sent.appendChild(lg);
2003
+ sent.appendChild(R.length ? Plot.plot({
2004
+ width: width(), height: 22 * R.length + 60, style: style, marginLeft: 120, marginRight: 90,
2005
+ x: {label: 'Requests →', grid: true}, y: {domain: R.map(function (r) { return r.provider; }), label: null},
2006
+ marks: [
2007
+ Plot.barX(R, {x: 'month', y: 'provider', fill: good, fillOpacity: 0.3,
2008
+ title: function (r) { return r.provider + '\nthis month: ' + qNum(r.month) + '\nlast 24 hours: ' + qNum(r.day); }}),
2009
+ Plot.barX(R, {x: 'day', y: 'provider', fill: good, insetTop: 4, insetBottom: 4}),
2010
+ Plot.text(R, {x: 'month', y: 'provider', text: function (r) { return qNum(r.day) + ' / ' + qNum(r.month); }, dx: 6, textAnchor: 'start', fill: ink}),
2011
+ Plot.ruleX([0], {stroke: ink})
2012
+ ]}) : el('p', {class: 'hint'}, 'No call in the ledger this month.'));
2013
+ }
2014
+ var res = $('chart-resets');
2015
+ if (res) {
2016
+ var now = Date.now(), end = now + 7 * 86400e3, dots = [], bars = [];
2017
+ QD.tiles.forEach(function (t) {
2018
+ var wins = [];
2019
+ t.groups.forEach(function (g) { g.windows.forEach(function (w) { wins.push({label: (g.label ? g.label + ' · ' : '') + w.label, at: w.reset_at}); }); });
2020
+ (t.caps || []).forEach(function (g) { g.windows.forEach(function (w) { wins.push({label: w.label + ' (' + g.label + ')', at: w.reset_at, daily: / day$/.test(w.label)}); }); });
2021
+ t.pool.forEach(function (p) { if (p.reset_at) wins.push({label: p.label, at: p.reset_at, daily: true}); });
2022
+ wins.forEach(function (w) {
2023
+ var at = +new Date(w.at), daily = w.daily || / day$/.test(w.label);
2024
+ for (; at && at <= end; at += 86400e3) {
2025
+ if (at >= now) dots.push({tile: t.name, at: new Date(at), what: w.label});
2026
+ if (!daily) break;
2027
+ }
2028
+ });
2029
+ t.models.filter(qResting).forEach(function (m) {
2030
+ bars.push({tile: t.name, x1: new Date(now), x2: new Date(Math.min(+new Date(m.until), end)), what: m.alias + ' resting until ' + new Date(m.until).toLocaleString()});
2031
+ });
2032
+ });
2033
+ var names = QD.tiles.map(function (t) { return t.name; }).filter(function (n) {
2034
+ return dots.some(function (d) { return d.tile === n; }) || bars.some(function (b) { return b.tile === n; });
2035
+ });
2036
+ res.textContent = '';
2037
+ res.appendChild(names.length ? Plot.plot({
2038
+ width: width(), height: 26 * names.length + 60, style: style, marginLeft: 120,
2039
+ x: {domain: [new Date(now), new Date(end)], label: null, grid: true}, y: {domain: names, label: null},
2040
+ marks: [
2041
+ Plot.barX(bars, {x1: 'x1', x2: 'x2', y: 'tile', fill: warn, fillOpacity: 0.35, insetTop: 6, insetBottom: 6, title: 'what'}),
2042
+ Plot.dot(dots, {x: 'at', y: 'tile', r: 4, fill: good, stroke: ink, strokeWidth: 0.5,
2043
+ title: function (d) { return d.what + ' resets ' + d.at.toLocaleString(); }}),
2044
+ Plot.ruleX([new Date(now)], {stroke: tok('--err', '#a12b2b'), strokeWidth: 1.5}),
2045
+ Plot.ruleY(names, {stroke: line, strokeOpacity: 0.5})
2046
+ ]}) : el('p', {class: 'hint'}, 'No reset or rest known for the next 7 days.'));
2047
+ }
2048
+ }
2049
+
1547
2050
  // ---- Benchmarks charts: Observable Plot, data from serve.charts_data ------
1548
2051
  // A function, not run once: a theme switched on Settings redraws them in its colours.
1549
2052
  function drawCharts() {
@@ -1566,6 +2069,7 @@
1566
2069
  var col = document.querySelector('.panes') || document.querySelector('main');
1567
2070
  return Math.max(520, (col.clientWidth || 900) - 50);
1568
2071
  }
2072
+ drawQuotaCharts(tok, style, width);
1569
2073
  function put(id, make) {
1570
2074
  var box = $(id);
1571
2075
  if (!box) return;