@wendellresearch-ai/benchwarmers 0.5.0 → 0.5.2
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/PRIVACY.md +9 -2
- package/README.md +11 -6
- package/USAGE.md +58 -21
- package/VERSION +1 -1
- package/metrics/catalog.py +5 -0
- package/metrics/learn.py +25 -1
- package/metrics/quota.py +41 -4
- package/metrics/quotaview.py +366 -34
- package/package.json +1 -1
- package/scripts/auto_retire.py +44 -4
- package/scripts/test_serve_edit.py +9 -0
- package/serve.py +40 -14
- package/suite/run.py +1 -0
- package/ui/bench.css +31 -10
- package/ui/bench.js +156 -72
|
@@ -486,6 +486,15 @@ def main() -> int:
|
|
|
486
486
|
print(("ok " if ok else "FAIL ") + "provider chip names" + ("" if ok else f" -> {got}"))
|
|
487
487
|
failures += 0 if ok else 1
|
|
488
488
|
|
|
489
|
+
# Got it clears a notice (NVIDIA changed its figures), never a state still wrong.
|
|
490
|
+
alerts.raise_alert("test:notice", "limits", "a notice", dismiss=True)
|
|
491
|
+
alerts.raise_alert("test:state", "limits", "a state")
|
|
492
|
+
ok = (serve.dismiss_alert("test:notice")["ok"] and not serve.dismiss_alert("test:state")["ok"]
|
|
493
|
+
and "test:notice" not in alerts.active() and "test:state" in alerts.active())
|
|
494
|
+
alerts.clear("test:state")
|
|
495
|
+
print(("ok " if ok else "FAIL ") + "Got it dismisses a notice, not a state")
|
|
496
|
+
failures += 0 if ok else 1
|
|
497
|
+
|
|
489
498
|
# The owner's files are as they were. alerts.json can change under a
|
|
490
499
|
# running suite, so only what this test could do counts: the victim's
|
|
491
500
|
# alerts cleared from the real file.
|
package/serve.py
CHANGED
|
@@ -686,6 +686,17 @@ def remove_model(alias: str, reason: str = "", who: str = "ui") -> dict:
|
|
|
686
686
|
return {"ok": True, "errors": [], "changed": changed, "sync": _sync_cli()}
|
|
687
687
|
|
|
688
688
|
|
|
689
|
+
def dismiss_alert(key: str) -> dict:
|
|
690
|
+
"""Got it on a notice (an alert raised with dismiss=True, such as NVIDIA
|
|
691
|
+
changing its stated limits). A state that is still wrong is not dismissed:
|
|
692
|
+
its check clears it when it is put right."""
|
|
693
|
+
import alerts as _alerts
|
|
694
|
+
if not (_alerts.active().get(key) or {}).get("dismiss"):
|
|
695
|
+
return {"ok": False, "errors": [f"{key!r} is not a notice that can be dismissed"]}
|
|
696
|
+
_alerts.clear(key)
|
|
697
|
+
return {"ok": True, "errors": []}
|
|
698
|
+
|
|
699
|
+
|
|
689
700
|
def retry_model(alias: str, who: str = "ui") -> dict:
|
|
690
701
|
"""One fresh call to a model on a retiring alert. An answer is written to
|
|
691
702
|
perf/history.csv as evidence (scripts/auto_retire.py reads it, so the
|
|
@@ -2038,6 +2049,8 @@ def problems() -> str:
|
|
|
2038
2049
|
acts.append(f"<button class='btn small' data-act='open' data-alias='{esc(alias)}'>Open</button>")
|
|
2039
2050
|
acts.append(f"<button class='btn small danger' data-act='retire' data-alias='{esc(alias)}'"
|
|
2040
2051
|
f" data-why='{esc(a.get('message', ''))}'>Retire now</button>")
|
|
2052
|
+
if a.get("dismiss"): # a notice, not a state: it goes once seen
|
|
2053
|
+
acts.append(f"<button class='btn small' data-act='dismiss' data-key='{esc(key)}'>Got it</button>")
|
|
2041
2054
|
items.append((key, a.get("kind", "?"), KIND.get(a.get("kind"), "warn"),
|
|
2042
2055
|
a.get("message", ""), why, "".join(acts)))
|
|
2043
2056
|
cutoff = (today - datetime.timedelta(days=7)).isoformat()
|
|
@@ -2065,11 +2078,13 @@ def problems() -> str:
|
|
|
2065
2078
|
folded = (f"<details><summary>Retired automatically this week · {len(auto)}"
|
|
2066
2079
|
" <span class='muted'>— each can be restored</span></summary>"
|
|
2067
2080
|
f"{render_rows(auto)}</details>") if auto else ""
|
|
2068
|
-
|
|
2081
|
+
# Folded by default, the count in its heading; bench.js keeps it open for
|
|
2082
|
+
# 12 hours once shown.
|
|
2083
|
+
return (f"<section class='problems collapsed' id='actions' aria-labelledby='actions-head'>"
|
|
2069
2084
|
f"<div class='head'><h2 id='actions-head'>Action needed · {len(items)}</h2>"
|
|
2070
2085
|
"<span class='sep' aria-hidden='true'>·</span>"
|
|
2071
|
-
"<button type='button' class='linkish' id='actions-hide' aria-expanded='
|
|
2072
|
-
" title='
|
|
2086
|
+
"<button type='button' class='linkish' id='actions-hide' aria-expanded='false'"
|
|
2087
|
+
" title='Open it here for 12 hours'>show</button></div>"
|
|
2073
2088
|
+ ("<p>States that will not fix themselves.</p>" if items
|
|
2074
2089
|
else "<p>Nothing open.</p>")
|
|
2075
2090
|
+ f"{render_rows(items)}{folded}</section>")
|
|
@@ -3170,8 +3185,10 @@ def collections_tab() -> str:
|
|
|
3170
3185
|
# The panel as it will sit, drawn as the Collections list draws it
|
|
3171
3186
|
# (bench.js recount): a seat per ticked member, explorer seats dashed.
|
|
3172
3187
|
"<div id='c-seats' class='seats' aria-live='polite'></div>"
|
|
3173
|
-
"<input type='search' id='c-member-filter' class='member-filter' spellcheck='false'
|
|
3174
|
-
" placeholder='Filter by model, provider or served name' aria-controls='c-members'
|
|
3188
|
+
"<div class='member-find'><input type='search' id='c-member-filter' class='member-filter' spellcheck='false'"
|
|
3189
|
+
" autocomplete='off' placeholder='Filter by model, provider or served name' aria-controls='c-members'"
|
|
3190
|
+
" aria-describedby='c-count'><select id='c-member-provider' aria-label='Provider' aria-controls='c-members'>"
|
|
3191
|
+
"<option value=''>Every provider</option></select></div>"
|
|
3175
3192
|
"<div id='c-members' class='members' role='group' aria-label='Members'></div>"
|
|
3176
3193
|
"<p class='rules' id='c-count'></p>"
|
|
3177
3194
|
"<label class='field' for='c-shape'>Explorer shape</label><select id='c-shape'></select>"
|
|
@@ -3745,15 +3762,21 @@ def quota_tiles() -> list[dict]:
|
|
|
3745
3762
|
|
|
3746
3763
|
|
|
3747
3764
|
def quota_op(which: str, tag) -> dict:
|
|
3748
|
-
"""Refresh
|
|
3749
|
-
|
|
3765
|
+
"""Refresh on one tile, which picks a free read, one short call or the
|
|
3766
|
+
records (quotaview.refresh): {ok, errors, note, tile}; "order" saves the
|
|
3767
|
+
tiles' order (a list of tags, empty for the default sort)."""
|
|
3750
3768
|
import quotaview
|
|
3751
3769
|
if which == "order":
|
|
3752
3770
|
return quotaview.save_order(tag)
|
|
3753
3771
|
if tag not in {t["id"] for t in quota_tiles()}:
|
|
3754
3772
|
return {"ok": False, "errors": [f"no tile called {tag!r}"]}
|
|
3773
|
+
if which == "about": # the Models popup: how it is reached
|
|
3774
|
+
try:
|
|
3775
|
+
return {"ok": True, "errors": [], **quotaview.about(tag)}
|
|
3776
|
+
except (ValueError, OSError) as exc:
|
|
3777
|
+
return {"ok": False, "errors": [str(exc)]}
|
|
3755
3778
|
try:
|
|
3756
|
-
r =
|
|
3779
|
+
r = quotaview.refresh(tag)
|
|
3757
3780
|
except (ValueError, OSError) as exc: # OSError: URLError and timeouts
|
|
3758
3781
|
return {"ok": False, "errors": [str(exc)]}
|
|
3759
3782
|
if not r.get("ok"):
|
|
@@ -3764,8 +3787,8 @@ def quota_op(which: str, tag) -> dict:
|
|
|
3764
3787
|
|
|
3765
3788
|
def quotas_page() -> str:
|
|
3766
3789
|
"""A tile per provider: what is left and when it resets, shaped by how the
|
|
3767
|
-
provider limits (metrics/quotaview.py), who is resting, Refresh
|
|
3768
|
-
|
|
3790
|
+
provider limits (metrics/quotaview.py), who is resting, one Refresh; the
|
|
3791
|
+
resets of the next week; what the bench sent (its usage ledger,
|
|
3769
3792
|
metrics/ledger.py); every limit known. bench.js draws tiles and charts."""
|
|
3770
3793
|
import learn
|
|
3771
3794
|
import ledger
|
|
@@ -3808,9 +3831,10 @@ def quotas_page() -> str:
|
|
|
3808
3831
|
" Retries count. Calls through the llm CLI are not in the ledger.</p>")
|
|
3809
3832
|
return (
|
|
3810
3833
|
"<div class='panel'><h2>Quota</h2><p class='sub'>What is left with each provider and when it"
|
|
3811
|
-
" resets, and who is resting after a rate limit.
|
|
3812
|
-
"
|
|
3813
|
-
"
|
|
3834
|
+
" resets, and who is resting after a rate limit. Refresh asks the provider for free where it can"
|
|
3835
|
+
" (CLI Proxy API's credentials, OpenRouter); where a provider states its limits only in its replies,"
|
|
3836
|
+
" it sends one 16-token request to read them; elsewhere it reads the bench's own records again."
|
|
3837
|
+
" Hover a Refresh to see which it will do."
|
|
3814
3838
|
" Drag a tile, or move it with the arrow keys, to keep it where you want it.</p>"
|
|
3815
3839
|
"<div class='list-actions'><button class='btn' id='q-all'>Refresh all live</button>"
|
|
3816
3840
|
"<button class='btn' id='q-reorder'>Sort: out first, then soonest reset</button>"
|
|
@@ -3832,6 +3856,7 @@ def quotas_page() -> str:
|
|
|
3832
3856
|
else "<p class='muted'>None known yet.</p>")
|
|
3833
3857
|
+ "</details>"
|
|
3834
3858
|
"<dialog id='q-models' aria-labelledby='q-models-h'><h3 id='q-models-h'></h3>"
|
|
3859
|
+
"<div class='sub q-reach' id='q-models-about'></div>"
|
|
3835
3860
|
"<p class='sub' id='q-models-sub'></p><div class='scroll' id='q-models-list'></div>"
|
|
3836
3861
|
"<div class='list-actions'><button class='btn' id='q-models-close'>Close</button></div></dialog>"
|
|
3837
3862
|
f"<script type='application/json' id='quota-data'>{data}</script>")
|
|
@@ -4347,7 +4372,8 @@ class Handler(gateway.Handler):
|
|
|
4347
4372
|
"/api/gateway-key/remove": lambda p: revoke_gateway_key(str(p.get("name") or "")),
|
|
4348
4373
|
"/api/theme": lambda p: save_theme(str(p.get("theme") or "")),
|
|
4349
4374
|
"/api/quota/refresh": lambda p: quota_op("refresh", str(p.get("tag") or "")),
|
|
4350
|
-
"/api/quota/
|
|
4375
|
+
"/api/quota/about": lambda p: quota_op("about", str(p.get("tag") or "")),
|
|
4376
|
+
"/api/alerts/dismiss": lambda p: dismiss_alert(str(p.get("key") or "")),
|
|
4351
4377
|
"/api/quota/order": lambda p: quota_op("order", p.get("order")),
|
|
4352
4378
|
"/api/restart": lambda p: restart(p.get("force") is True)}
|
|
4353
4379
|
# SAME ORIGIN ONLY. Any page open in the browser can POST a plain form
|
package/suite/run.py
CHANGED
|
@@ -554,6 +554,7 @@ STRUCTURAL = (
|
|
|
554
554
|
"unknown model",
|
|
555
555
|
"invalid model",
|
|
556
556
|
"model_not_found",
|
|
557
|
+
"no model found", # Routeway: "No model found: <id>"
|
|
557
558
|
)
|
|
558
559
|
# ENTITLEMENT is its own class, and the difference decides what to DO.
|
|
559
560
|
# `structural` means the model is not there and the registration should go.
|
package/ui/bench.css
CHANGED
|
@@ -299,6 +299,12 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
|
|
|
299
299
|
#c-form .seats { min-height: 2rem; }
|
|
300
300
|
#c-form .seat .k.after { margin: 0 0 0 .4rem; }
|
|
301
301
|
:is(dialog, .form-panel) input.member-filter { font-size: .9375rem; padding: .4rem .6rem; margin-bottom: .2rem; }
|
|
302
|
+
#c-form .member-find { display: flex; gap: .5rem; }
|
|
303
|
+
#c-form .member-find input { flex: 1; min-width: 0; }
|
|
304
|
+
#c-form .member-find select { flex: none; width: auto; max-width: 45%; margin-bottom: .2rem; }
|
|
305
|
+
#c-form .seat .seat-x { border: 0; background: none; padding: 0 0 0 .45rem; font: inherit; font-size: 1rem; line-height: 1;
|
|
306
|
+
color: var(--muted); cursor: pointer; }
|
|
307
|
+
#c-form .seat .seat-x:hover, #c-form .seat .seat-x:focus-visible { color: var(--err); }
|
|
302
308
|
:is(dialog, .form-panel) .members input:disabled + span { color: var(--muted); }
|
|
303
309
|
/* Ruled out by the collection's tags (bench.js applyTags): greyed; a ticked
|
|
304
310
|
one stays tickable and is marked, so it can be taken off. */
|
|
@@ -400,21 +406,35 @@ pre.log.answer { max-height: 40vh; }
|
|
|
400
406
|
/* Quota: a tile per provider, as many columns as fit; a group of windows (a
|
|
401
407
|
5-hour and a weekly limit of one model group) boxed together; a meter of
|
|
402
408
|
ten segments filled like a bar. */
|
|
403
|
-
|
|
404
|
-
|
|
409
|
+
/* Tiles in a row share a height, so every footer sits on one line; nothing in
|
|
410
|
+
a tile is wider than it: names shorten, values never wrap. */
|
|
411
|
+
.q-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: .75rem; align-items: stretch; margin-top: .75rem; }
|
|
412
|
+
.q-tile { display: flex; flex-direction: column; gap: .5rem; padding: .75rem .85rem; background: var(--panel); min-width: 0;
|
|
405
413
|
border: 1px solid var(--line); border-radius: var(--radius); cursor: grab; }
|
|
406
414
|
.q-tile:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
|
407
415
|
.q-tile.out { border-color: var(--err); }
|
|
408
416
|
.q-tile.dragging { opacity: .45; }
|
|
409
|
-
.q-tile .q-head { display: flex; justify-content: space-between; align-items:
|
|
410
|
-
.q-tile .nm { font-size: 1.0625rem; }
|
|
411
|
-
.q-
|
|
412
|
-
.q-
|
|
417
|
+
.q-tile .q-head { display: flex; justify-content: space-between; align-items: center; gap: .5rem; min-width: 0; }
|
|
418
|
+
.q-tile .nm { font-size: 1.0625rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
419
|
+
.q-tile .q-head .chip { flex: none; white-space: nowrap; font-size: .75rem; }
|
|
420
|
+
.q-about { display: flex; flex-direction: column; gap: .1rem; margin-top: -.3rem; font-size: .8125rem; color: var(--muted); }
|
|
421
|
+
.q-about .q-from { font-style: italic; }
|
|
422
|
+
.q-small { font-size: .8125rem; color: var(--muted); }
|
|
423
|
+
.q-body { display: flex; flex-direction: column; gap: .5rem; min-width: 0; }
|
|
424
|
+
.q-group { border: 1px solid var(--line); border-radius: var(--radius); padding: .45rem .55rem .55rem; display: flex; flex-direction: column; gap: .45rem; }
|
|
413
425
|
.q-group.bare { border: 0; padding: 0; }
|
|
414
426
|
.q-group h4 { margin: 0; font-size: .8125rem; font-weight: 500; color: var(--muted); }
|
|
415
|
-
.q-
|
|
427
|
+
.q-meter { display: flex; flex-direction: column; gap: .2rem; min-width: 0; }
|
|
428
|
+
.q-line { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; font-size: .875rem; min-width: 0; }
|
|
416
429
|
.q-line b { font-weight: 600; }
|
|
417
|
-
.q-line
|
|
430
|
+
.q-line > :first-child { min-width: 0; }
|
|
431
|
+
.q-line > a.model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
432
|
+
.q-line .q-val { flex: none; white-space: nowrap; }.segs.none span { background: var(--step-1); opacity: .6; }
|
|
433
|
+
/* Calls: green for what answered, red for what failed, out of every call. */
|
|
434
|
+
.segs.calls span { background: var(--err); }
|
|
435
|
+
.segs.calls i { background: var(--ok); }
|
|
436
|
+
.q-calls { white-space: nowrap; }
|
|
437
|
+
.q-calls .segs { display: inline-flex; width: 5.5rem; margin-right: .5rem; vertical-align: middle; }
|
|
418
438
|
.segs { display: flex; gap: 2px; }
|
|
419
439
|
.segs span { flex: 1; height: .5rem; border-radius: 2px; background: var(--step-1); overflow: hidden; }
|
|
420
440
|
.segs i { display: block; height: 100%; background: var(--ok); }
|
|
@@ -424,12 +444,13 @@ pre.log.answer { max-height: 40vh; }
|
|
|
424
444
|
.warn-t { color: var(--warn); }
|
|
425
445
|
.q-msg { font-size: .8125rem; }
|
|
426
446
|
.q-msg:empty { display: none; }
|
|
427
|
-
.q-tile .q-foot { display: flex;
|
|
447
|
+
.q-tile .q-foot { display: flex; gap: .4rem; justify-content: space-between; margin-top: auto;
|
|
428
448
|
padding-top: .5rem; border-top: 1px solid var(--line); }
|
|
429
449
|
.q-tile .q-foot .chip { margin-left: .2rem; font-size: .75rem; }
|
|
430
450
|
.q-legend { display: flex; flex-wrap: wrap; gap: 1rem; font-size: .875rem; margin: 0 0 .4rem; }
|
|
431
451
|
.q-legend i { display: inline-block; width: .8rem; height: .7rem; border-radius: 2px; margin-right: .35rem; vertical-align: -1px; }
|
|
432
|
-
#q-models {
|
|
452
|
+
#q-models { width: min(48rem, 94vw); max-width: none; }
|
|
453
|
+
#q-models td:first-child { white-space: nowrap; }
|
|
433
454
|
|
|
434
455
|
/* Benchmarks charts (Observable Plot) */
|
|
435
456
|
.chart { margin: .5rem 0 .75rem; overflow-x: auto; }
|
package/ui/bench.js
CHANGED
|
@@ -123,23 +123,20 @@
|
|
|
123
123
|
c.addEventListener(c.tagName === 'SELECT' ? 'change' : 'input', function () { applyFilter(scope); });
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
// ---- Action needed:
|
|
127
|
-
//
|
|
126
|
+
// ---- Action needed: folded to its heading by default; show keeps it open
|
|
127
|
+
// in this browser for 12 hours, Hide folds it again ----
|
|
128
128
|
var acts = $('actions');
|
|
129
129
|
if (acts) {
|
|
130
|
-
var
|
|
131
|
-
var hid = null;
|
|
132
|
-
try { hid = JSON.parse(store('bench.actions.hidden') || 'null'); } catch (err) { hid = null; }
|
|
130
|
+
var opened = Number(store('bench.actions.open')) || 0;
|
|
133
131
|
var fold = function (on) {
|
|
134
132
|
acts.classList.toggle('collapsed', on);
|
|
135
133
|
$('actions-hide').textContent = on ? 'show' : 'Hide';
|
|
136
134
|
$('actions-hide').setAttribute('aria-expanded', String(!on));
|
|
137
135
|
};
|
|
138
|
-
fold(
|
|
139
|
-
&& keys.every(function (k) { return hid.keys.indexOf(k) !== -1; })));
|
|
136
|
+
fold(!(Date.now() - opened < 12 * 3600 * 1000));
|
|
140
137
|
$('actions-hide').addEventListener('click', function () {
|
|
141
138
|
var on = !acts.classList.contains('collapsed');
|
|
142
|
-
store('bench.actions.
|
|
139
|
+
store('bench.actions.open', on ? '' : String(Date.now()));
|
|
143
140
|
fold(on);
|
|
144
141
|
});
|
|
145
142
|
}
|
|
@@ -411,6 +408,14 @@
|
|
|
411
408
|
var act = b.dataset.act, aliases = (b.dataset.aliases || b.dataset.alias || '').split(' ').filter(Boolean);
|
|
412
409
|
var status = b.closest('.problem').querySelector('.why');
|
|
413
410
|
if (act === 'open') { openModel(aliases[0]); return; }
|
|
411
|
+
if (act === 'dismiss') { // a notice: gone once seen
|
|
412
|
+
b.disabled = true;
|
|
413
|
+
post('/api/alerts/dismiss', {key: b.dataset.key}).then(function (res) {
|
|
414
|
+
if (!res.ok) { status.textContent = res.errors.join('; '); status.classList.add('err'); b.disabled = false; return; }
|
|
415
|
+
location.reload();
|
|
416
|
+
}).catch(function (err) { status.textContent = String(err); b.disabled = false; });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
414
419
|
if (act === 'retry') { // no confirm: one call, and only an answer changes anything
|
|
415
420
|
b.disabled = true; b.textContent = 'Asking…';
|
|
416
421
|
post('/api/retry', {alias: aliases[0]}).then(function (res) {
|
|
@@ -1330,7 +1335,7 @@
|
|
|
1330
1335
|
head: $('c-form-head'), maxCalls: $('g-max-calls'), maxExp: $('g-max-explorers'),
|
|
1331
1336
|
gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
|
|
1332
1337
|
del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
|
|
1333
|
-
seats: $('c-seats')};
|
|
1338
|
+
seats: $('c-seats'), prov: $('c-member-provider')};
|
|
1334
1339
|
var C = null, open = null; // the collection whose details are showing
|
|
1335
1340
|
function explorersFor(n) {
|
|
1336
1341
|
return Math.max(0, Math.min(C.settings.max_explorers, C.settings.max_calls - n));
|
|
@@ -1370,12 +1375,20 @@
|
|
|
1370
1375
|
return {name: row ? row.dataset.name : by[f][0], on: by[f].map(function (a) { return fact(a).provider || a; })};
|
|
1371
1376
|
});
|
|
1372
1377
|
}
|
|
1373
|
-
// The panel as it will sit, as the Collections list draws it
|
|
1378
|
+
// The panel as it will sit, as the Collections list draws it; a member's ×
|
|
1379
|
+
// unticks it, as unticking its chip does.
|
|
1374
1380
|
function drawSeats(ex) {
|
|
1375
1381
|
c.seats.textContent = '';
|
|
1376
1382
|
picked().forEach(function (a) {
|
|
1377
1383
|
var seat = el('span', {class: 'seat'}, a);
|
|
1378
1384
|
if (fact(a).provider) seat.appendChild(el('span', {class: 'k after'}, fact(a).provider));
|
|
1385
|
+
var x = el('button', {type: 'button', class: 'seat-x', 'aria-label': 'Remove ' + a + ' from the collection',
|
|
1386
|
+
title: 'Remove from the collection'}, '×');
|
|
1387
|
+
x.addEventListener('click', function () {
|
|
1388
|
+
var box = c.members.querySelector('input[value="' + CSS.escape(a) + '"]');
|
|
1389
|
+
if (box) { box.checked = false; applyTags(); }
|
|
1390
|
+
});
|
|
1391
|
+
seat.appendChild(x);
|
|
1379
1392
|
c.seats.appendChild(seat);
|
|
1380
1393
|
});
|
|
1381
1394
|
for (var i = 0; i < ex; i++) {
|
|
@@ -1425,18 +1438,20 @@
|
|
|
1425
1438
|
filterMembers();
|
|
1426
1439
|
}
|
|
1427
1440
|
// The member search: the filter boxes' match (any text, case-insensitive),
|
|
1428
|
-
// over the model's name and each chip's alias, served name and provider
|
|
1429
|
-
// It only hides chips, and never a ticked
|
|
1430
|
-
// in view and every count still includes
|
|
1441
|
+
// over the model's name and each chip's alias, served name and provider,
|
|
1442
|
+
// and the provider select beside it. It only hides chips, and never a ticked
|
|
1443
|
+
// one, so what is picked is always in view and every count still includes
|
|
1444
|
+
// it; a row goes when all its chips do.
|
|
1431
1445
|
function filterMembers() {
|
|
1432
|
-
var q = c.find.value.trim().toLowerCase();
|
|
1446
|
+
var q = c.find.value.trim().toLowerCase(), prov = c.prov.value;
|
|
1433
1447
|
c.members.querySelectorAll('.m-row').forEach(function (row) {
|
|
1434
1448
|
var named = !q || row.dataset.name.toLowerCase().indexOf(q) !== -1, any = false;
|
|
1435
1449
|
row.querySelectorAll('label.check').forEach(function (lab) {
|
|
1436
1450
|
var box = lab.querySelector('input');
|
|
1437
1451
|
var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
|
|
1438
1452
|
lab.textContent).toLowerCase();
|
|
1439
|
-
lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1
|
|
1453
|
+
lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1
|
|
1454
|
+
|| !!prov && (fact(box.value).provider || '') !== prov);
|
|
1440
1455
|
any = any || !lab.hidden;
|
|
1441
1456
|
});
|
|
1442
1457
|
row.hidden = !any;
|
|
@@ -1444,6 +1459,7 @@
|
|
|
1444
1459
|
recount();
|
|
1445
1460
|
}
|
|
1446
1461
|
c.find.addEventListener('input', filterMembers);
|
|
1462
|
+
c.prov.addEventListener('change', filterMembers);
|
|
1447
1463
|
// Escape in a box with text clears it; only an empty box lets Escape close the popup.
|
|
1448
1464
|
c.find.addEventListener('keydown', function (e) {
|
|
1449
1465
|
if (e.key === 'Escape' && c.find.value) { e.preventDefault(); e.stopPropagation(); c.find.value = ''; filterMembers(); }
|
|
@@ -1487,6 +1503,9 @@
|
|
|
1487
1503
|
c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
|
|
1488
1504
|
drawTagPicks();
|
|
1489
1505
|
c.members.textContent = ''; c.find.value = '';
|
|
1506
|
+
c.prov.length = 1; // "Every provider", then each one a model is on
|
|
1507
|
+
Array.from(new Set(C.models.map(function (a) { return fact(a).provider; }).filter(Boolean))).sort()
|
|
1508
|
+
.forEach(function (p) { c.prov.appendChild(el('option', {value: p}, p)); });
|
|
1490
1509
|
// One row per model, named by its shortest alias, with what the first
|
|
1491
1510
|
// route says it holds and can do; each provider it is on is a chip that
|
|
1492
1511
|
// ticks that alias. A chip whose window differs from the row's says its own.
|
|
@@ -1698,91 +1717,135 @@
|
|
|
1698
1717
|
var s = (Date.now() - new Date(iso)) / 1000;
|
|
1699
1718
|
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';
|
|
1700
1719
|
}
|
|
1701
|
-
function qTone(
|
|
1702
|
-
|
|
1703
|
-
|
|
1720
|
+
function qTone(pct) { return pct == null ? 'none' : pct >= 70 ? 'ok' : pct >= 25 ? 'warn' : 'err'; }
|
|
1721
|
+
// `pct` null draws the ten segments empty: nothing to count yet. Calls
|
|
1722
|
+
// ("answered") fill green over red, the failed share showing as red.
|
|
1723
|
+
function qSegs(pct, what) {
|
|
1724
|
+
var tone = pct != null && what === 'answered' ? 'calls' : qTone(pct);
|
|
1725
|
+
var box = el('div', {class: 'segs ' + tone, role: 'img',
|
|
1726
|
+
'aria-label': pct == null ? 'nothing yet' : pct + '% ' + (what || 'left')});
|
|
1704
1727
|
for (var i = 0; i < 10; i++) {
|
|
1705
1728
|
var s = el('span'), f = el('i');
|
|
1706
|
-
f.style.width = Math.round(Math.min(Math.max(
|
|
1729
|
+
f.style.width = Math.round(Math.min(Math.max((pct || 0) / 10 - i, 0), 1) * 100) + '%';
|
|
1707
1730
|
s.appendChild(f); box.appendChild(s);
|
|
1708
1731
|
}
|
|
1709
1732
|
return box;
|
|
1710
1733
|
}
|
|
1711
|
-
function qLine(label, right) {
|
|
1712
|
-
var row = el('div', {class: 'q-line'});
|
|
1713
|
-
row.appendChild(el('span', {}, label));
|
|
1714
|
-
var r = el('span'); r.innerHTML = right; row.appendChild(r);
|
|
1715
|
-
return row;
|
|
1716
|
-
}
|
|
1717
1734
|
function qNum(n) { return Number(n).toLocaleString(); }
|
|
1735
|
+
function qAlias(a) { // as serve.named draws a model: its popup on a click
|
|
1736
|
+
var link = el('a', {href: '#', class: 'model', 'data-alias': a, draggable: 'false', title: a});
|
|
1737
|
+
link.appendChild(el('span', {class: 'alias mono'}, a));
|
|
1738
|
+
return link;
|
|
1739
|
+
}
|
|
1740
|
+
// 189, 19,997, 494k, 12.5M: whole below 100,000, then short.
|
|
1741
|
+
function qShort(n) {
|
|
1742
|
+
n = Number(n);
|
|
1743
|
+
if (n < 1e5) return Math.round(n * 100) / 100 >= 1000 ? Math.round(n).toLocaleString() : String(Math.round(n * 100) / 100);
|
|
1744
|
+
if (n < 1e6) return Math.round(n / 1e3) + 'k';
|
|
1745
|
+
return String(n >= 1e8 ? Math.round(n / 1e6) : Math.round(n / 1e5) / 10).replace(/\.0$/, '') + 'M';
|
|
1746
|
+
}
|
|
1747
|
+
// Every figure on a tile is one meter: what is left of what, in the
|
|
1748
|
+
// provider's own unit, on the left (it may wrap); when it resets on the
|
|
1749
|
+
// right (it never does); the segments under it.
|
|
1750
|
+
function qMeter(label, value, pct, what, title) {
|
|
1751
|
+
var box = el('div', {class: 'q-meter'}), row = el('div', {class: 'q-line'});
|
|
1752
|
+
var l = typeof label === 'string' ? el('span', {class: 'q-label'}) : label;
|
|
1753
|
+
if (typeof label === 'string') l.innerHTML = label;
|
|
1754
|
+
row.appendChild(l);
|
|
1755
|
+
var v = el('span', {class: 'q-val'}); v.innerHTML = value; row.appendChild(v);
|
|
1756
|
+
if (title) box.title = title;
|
|
1757
|
+
box.appendChild(row); box.appendChild(qSegs(pct, what));
|
|
1758
|
+
return box;
|
|
1759
|
+
}
|
|
1760
|
+
function qReset(iso) {
|
|
1761
|
+
return iso ? '<span title="Resets ' + new Date(iso).toLocaleString() + '">' + qWhen(iso) + '</span>' : '';
|
|
1762
|
+
}
|
|
1718
1763
|
function qWindow(w) {
|
|
1719
|
-
var
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1764
|
+
var raw = w.of != null && w.remaining != null;
|
|
1765
|
+
// The percentage leads every meter, raw or not, so they line up.
|
|
1766
|
+
return qMeter('<b>' + w.left + '%</b> left · ' + (raw ? '<b>' + qShort(w.remaining) + '</b> of '
|
|
1767
|
+
+ qShort(w.of) + ' ' + w.label : w.label),
|
|
1768
|
+
qReset(w.reset_at), w.left, 'left',
|
|
1769
|
+
(raw ? qNum(w.remaining) + ' of ' + qNum(w.of) + ' left, ' : '') + w.left + '%');
|
|
1770
|
+
}
|
|
1771
|
+
// How much of a last day answered: a model's (its link), or `label`'s.
|
|
1772
|
+
function qCalls(m, label) {
|
|
1773
|
+
var n = m.ok + m.failed, pct = n ? Math.round(100 * m.ok / n) : null;
|
|
1774
|
+
var value = qResting(m) ? '<span class="warn-t">resting · ' + qWhen(m.until) + '</span>'
|
|
1775
|
+
: n ? '<b>' + m.ok + '</b> of ' + n + ' answered' : '<span class="muted">no calls</span>';
|
|
1776
|
+
return qMeter(label || qAlias(m.alias), value, pct, 'answered',
|
|
1777
|
+
n ? m.ok + ' answered, ' + m.failed + ' failed in the last 24 hours' : 'not called in the last 24 hours');
|
|
1725
1778
|
}
|
|
1726
1779
|
function qResting(m) { return m.until && new Date(m.until) > Date.now(); }
|
|
1727
1780
|
function qTile(t) {
|
|
1728
1781
|
var node = el('article', {class: 'q-tile' + (t.out ? ' out' : ''), draggable: 'true', tabindex: '0',
|
|
1729
1782
|
'data-id': t.id, 'aria-label': t.name + ': drag, or use the arrow keys, to move it'});
|
|
1730
1783
|
var head = el('div', {class: 'q-head'});
|
|
1731
|
-
head.appendChild(el('b', {class: 'nm'}, t.name));
|
|
1732
|
-
var f = t.fresh, how =
|
|
1733
|
-
head.appendChild(el('span', {class: 'chip ' + (f.how === 'live' ? 'ok' : 'via')
|
|
1784
|
+
head.appendChild(el('b', {class: 'nm', title: t.name}, t.name));
|
|
1785
|
+
var f = t.fresh, how = {live: 'read ', seen: 'seen ', answered: 'answered '}[f.how] || '';
|
|
1786
|
+
head.appendChild(el('span', {class: 'chip ' + (f.how === 'live' ? 'ok' : 'via'),
|
|
1787
|
+
title: {live: 'Last asked of the provider', seen: 'Last stated in a reply\'s headers',
|
|
1788
|
+
answered: 'Last answer from one of its models'}[f.how] || 'Nothing known yet'},
|
|
1789
|
+
f.at ? how + qAgo(f.at) : 'nothing yet'));
|
|
1734
1790
|
node.appendChild(head);
|
|
1735
|
-
|
|
1791
|
+
// Every word about the figures in one place, under the name: where they
|
|
1792
|
+
// come from, its plan, your per-minute caps, what a live read said. The
|
|
1793
|
+
// connection's own name and address are in Models.
|
|
1794
|
+
var about = el('div', {class: 'q-about'});
|
|
1795
|
+
var src = t.source || [];
|
|
1796
|
+
about.appendChild(el('div', {class: 'q-from'}, (src.length && src[0] !== 'nothing stated'
|
|
1797
|
+
? 'From ' + src.join('; ') : 'No limit stated') + (t.plan ? ' · ' + t.plan + ' plan' : '')));
|
|
1798
|
+
if (t.conn_limit) about.appendChild(el('div', {}, 'Your cap: ' + t.conn_limit + '.'));
|
|
1799
|
+
(t.notes || []).forEach(function (n) { about.appendChild(el('div', {}, n)); });
|
|
1800
|
+
node.appendChild(about);
|
|
1801
|
+
var body = el('div', {class: 'q-body'});
|
|
1802
|
+
// Day and month caps, counted by the bench: yours, else what the provider
|
|
1803
|
+
// documents. The From line names the source; the title says the rest.
|
|
1804
|
+
(t.caps || []).forEach(function (g) {
|
|
1805
|
+
var box = el('section', {class: 'q-group bare', title: g.title || ''});
|
|
1806
|
+
g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
|
|
1807
|
+
body.appendChild(box);
|
|
1808
|
+
});
|
|
1736
1809
|
if (t.kind === 'groups') {
|
|
1737
1810
|
t.groups.forEach(function (g) {
|
|
1738
1811
|
var box = el('section', {class: g.label ? 'q-group' : 'q-group bare'});
|
|
1739
1812
|
if (g.label) box.appendChild(el('h4', {}, g.label));
|
|
1740
1813
|
g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
|
|
1741
|
-
|
|
1814
|
+
body.appendChild(box);
|
|
1742
1815
|
});
|
|
1743
1816
|
} else if (t.kind === 'pool') {
|
|
1744
1817
|
t.pool.forEach(function (p) {
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
} else {
|
|
1751
|
-
box.appendChild(qLine(p.label, '<b>' + qNum(p.used) + '</b> sent · limit not known yet'));
|
|
1752
|
-
}
|
|
1753
|
-
node.appendChild(box);
|
|
1754
|
-
});
|
|
1755
|
-
} else {
|
|
1756
|
-
var shown = t.models.slice(0, 6);
|
|
1757
|
-
shown.forEach(function (m) {
|
|
1758
|
-
var right = qResting(m) ? '<span class="warn-t">resting · ' + qWhen(m.until) + '</span>'
|
|
1759
|
-
: m.ok + m.failed ? m.ok + ' ok · ' + m.failed + ' failed' : 'no calls today';
|
|
1760
|
-
var row = qLine('', right); row.firstChild.className = 'mono'; row.firstChild.textContent = m.alias;
|
|
1761
|
-
node.appendChild(row);
|
|
1818
|
+
body.appendChild(p.of
|
|
1819
|
+
? qMeter('<b>' + p.left + '%</b> left · <b>' + qShort(Math.max(0, p.of - p.used)) + '</b> of '
|
|
1820
|
+
+ qShort(p.of) + ' ' + p.label,
|
|
1821
|
+
qReset(p.reset_at), p.left, 'left')
|
|
1822
|
+
: qMeter('<b>' + qShort(p.used) + '</b> ' + p.label + ' · limit not known', qReset(p.reset_at), null));
|
|
1762
1823
|
});
|
|
1763
|
-
|
|
1764
|
-
|
|
1824
|
+
} else if (!(t.caps || []).length) {
|
|
1825
|
+
// Nothing stated to count against: how the provider's calls went, all
|
|
1826
|
+
// its models together (each one's are in Models).
|
|
1827
|
+
var all = t.models.reduce(function (s, m) { return {ok: s.ok + m.ok, failed: s.failed + m.failed}; }, {ok: 0, failed: 0});
|
|
1828
|
+
body.appendChild(qCalls({alias: '', ok: all.ok, failed: all.failed, until: null}, 'Calls, last 24 hours'));
|
|
1765
1829
|
}
|
|
1766
1830
|
if (t.kind !== 'models') {
|
|
1767
1831
|
t.models.filter(qResting).forEach(function (m) {
|
|
1768
|
-
|
|
1832
|
+
var r = el('div', {class: 'q-rest'});
|
|
1833
|
+
r.appendChild(qAlias(m.alias)); r.appendChild(document.createTextNode(' resting · back ' + qWhen(m.until)));
|
|
1834
|
+
body.appendChild(r);
|
|
1769
1835
|
});
|
|
1770
1836
|
}
|
|
1771
|
-
|
|
1772
|
-
(t.notes || []).forEach(function (n) { node.appendChild(el('div', {class: 'muted q-small'}, n)); });
|
|
1837
|
+
node.appendChild(body);
|
|
1773
1838
|
var msg = el('div', {class: 'q-msg', 'aria-live': 'polite'});
|
|
1774
1839
|
node.appendChild(msg);
|
|
1840
|
+
// One Refresh: the server picks a free read, one short call or the records.
|
|
1775
1841
|
var foot = el('div', {class: 'q-foot'});
|
|
1776
1842
|
var rb = el('button', {class: 'btn small', type: 'button', 'aria-label': 'Refresh ' + t.name,
|
|
1777
|
-
title:
|
|
1843
|
+
title: {live: 'Asks the provider what is left (free)',
|
|
1844
|
+
check: 'Sends one 16-token request; the reply\'s headers say what is left'}[t.refresh]
|
|
1845
|
+
|| 'Reads the bench\'s own records again (this provider states no limits to ask about)'});
|
|
1778
1846
|
rb.innerHTML = '↻ Refresh';
|
|
1779
|
-
rb.addEventListener('click', function () { qAct(
|
|
1847
|
+
rb.addEventListener('click', function () { qAct(t.id, node); });
|
|
1780
1848
|
foot.appendChild(rb);
|
|
1781
|
-
if (t.check) {
|
|
1782
|
-
var cb = el('button', {class: 'btn small', type: 'button', title: 'One short call to one of its models; its reply says what is left'}, 'Check now · 1 request');
|
|
1783
|
-
cb.addEventListener('click', function () { qAct('check', t.id, node); });
|
|
1784
|
-
foot.appendChild(cb);
|
|
1785
|
-
}
|
|
1786
1849
|
var mb = el('button', {class: 'btn small', type: 'button'}, 'Models ');
|
|
1787
1850
|
mb.appendChild(el('span', {class: 'chip ' + (t.live === t.bench ? 'ok' : t.live ? 'warn' : 'err')}, t.live + '/' + t.bench));
|
|
1788
1851
|
mb.title = t.live + ' of the ' + t.bench + ' bench models on it are free to call';
|
|
@@ -1791,10 +1854,10 @@
|
|
|
1791
1854
|
node.appendChild(foot);
|
|
1792
1855
|
return node;
|
|
1793
1856
|
}
|
|
1794
|
-
function qAct(
|
|
1857
|
+
function qAct(id, node) {
|
|
1795
1858
|
var msg = node.querySelector('.q-msg');
|
|
1796
|
-
msg.className = 'q-msg muted'; msg.textContent =
|
|
1797
|
-
return post('/api/quota/'
|
|
1859
|
+
msg.className = 'q-msg muted'; msg.textContent = 'Refreshing…';
|
|
1860
|
+
return post('/api/quota/refresh', {tag: id}).then(function (r) {
|
|
1798
1861
|
if (!r.ok) { msg.className = 'q-msg errors'; msg.textContent = r.errors.join('; '); return; }
|
|
1799
1862
|
var i = QD.tiles.findIndex(function (t) { return t.id === id; });
|
|
1800
1863
|
if (r.tile && i >= 0) {
|
|
@@ -1809,17 +1872,37 @@
|
|
|
1809
1872
|
function qModels(t) {
|
|
1810
1873
|
$('q-models-h').textContent = t.name + ': ' + t.bench + ' on the bench';
|
|
1811
1874
|
$('q-models-sub').textContent = t.live + ' free to call now · the last 24 hours of calls';
|
|
1875
|
+
// How it is reached, asked when the popup opens (it reads Open WebUI's connections).
|
|
1876
|
+
var reach = $('q-models-about');
|
|
1877
|
+
function reachLine(after) {
|
|
1878
|
+
reach.textContent = '';
|
|
1879
|
+
var line = el('div'); line.appendChild(el('span', {class: 'mono'}, t.tag));
|
|
1880
|
+
if (after) line.appendChild(document.createTextNode(' · ' + after));
|
|
1881
|
+
reach.appendChild(line);
|
|
1882
|
+
}
|
|
1883
|
+
reachLine('finding how it is reached…');
|
|
1884
|
+
post('/api/quota/about', {tag: t.id}).then(function (r) {
|
|
1885
|
+
if (!r.ok) { reachLine(r.errors.join('; ')); return; }
|
|
1886
|
+
reachLine(r.routes.length ? '' : 'no endpoint or Open WebUI connection by this name');
|
|
1887
|
+
r.routes.forEach(function (x) {
|
|
1888
|
+
var d = el('div', {}, 'Through ' + x.via + ': ');
|
|
1889
|
+
d.appendChild(el('code', {}, x.url)); reach.appendChild(d);
|
|
1890
|
+
});
|
|
1891
|
+
}).catch(function (e) { reachLine(String(e)); });
|
|
1812
1892
|
var list = $('q-models-list'); list.textContent = '';
|
|
1813
1893
|
var tb = el('table'), hd = el('tr');
|
|
1814
1894
|
['Model', 'Now', 'Last 24 h', 'Last answered', 'Known limit'].forEach(function (h) { hd.appendChild(el('th', {}, h)); });
|
|
1815
1895
|
tb.appendChild(el('thead')).appendChild(hd);
|
|
1816
1896
|
var body = tb.appendChild(el('tbody'));
|
|
1817
1897
|
t.models.forEach(function (m) {
|
|
1818
|
-
var tr = el('tr');
|
|
1819
|
-
|
|
1898
|
+
var tr = el('tr'), name = el('td');
|
|
1899
|
+
name.appendChild(qAlias(m.alias)); tr.appendChild(name);
|
|
1820
1900
|
var now = el('td'); now.appendChild(el('span', {class: 'chip ' + (qResting(m) ? 'warn' : 'ok')}, qResting(m) ? 'resting · ' + qWhen(m.until) : 'ready'));
|
|
1821
1901
|
tr.appendChild(now);
|
|
1822
|
-
|
|
1902
|
+
var n = m.ok + m.failed, calls = el('td', {class: 'q-calls', title: m.failed + ' failed'});
|
|
1903
|
+
calls.appendChild(qSegs(n ? Math.round(100 * m.ok / n) : null, 'answered'));
|
|
1904
|
+
calls.appendChild(el('span', {}, n ? m.ok + ' of ' + n + ' answered' : 'no calls'));
|
|
1905
|
+
tr.appendChild(calls);
|
|
1823
1906
|
tr.appendChild(el('td', {}, m.last_ok ? qAgo(m.last_ok) : '—'));
|
|
1824
1907
|
tr.appendChild(el('td', {}, m.limit || '—'));
|
|
1825
1908
|
body.appendChild(tr);
|
|
@@ -1888,7 +1971,7 @@
|
|
|
1888
1971
|
live.reduce(function (p, t) {
|
|
1889
1972
|
return p.then(function () {
|
|
1890
1973
|
var node = qBox.querySelector('.q-tile[data-id="' + CSS.escape(t.id) + '"]');
|
|
1891
|
-
return node ? qAct(
|
|
1974
|
+
return node ? qAct(t.id, node) : null;
|
|
1892
1975
|
});
|
|
1893
1976
|
}, Promise.resolve()).then(function () { $('q-done').textContent = 'Refreshed ' + live.length + '.'; });
|
|
1894
1977
|
});
|
|
@@ -1931,6 +2014,7 @@
|
|
|
1931
2014
|
QD.tiles.forEach(function (t) {
|
|
1932
2015
|
var wins = [];
|
|
1933
2016
|
t.groups.forEach(function (g) { g.windows.forEach(function (w) { wins.push({label: (g.label ? g.label + ' · ' : '') + w.label, at: w.reset_at}); }); });
|
|
2017
|
+
(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)}); }); });
|
|
1934
2018
|
t.pool.forEach(function (p) { if (p.reset_at) wins.push({label: p.label, at: p.reset_at, daily: true}); });
|
|
1935
2019
|
wins.forEach(function (w) {
|
|
1936
2020
|
var at = +new Date(w.at), daily = w.daily || / day$/.test(w.label);
|