@wendellresearch-ai/benchwarmers 0.6.1 → 0.6.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/VERSION +1 -1
- package/metrics/route.py +6 -4
- package/package.json +1 -1
- package/scripts/test_no_owui.py +5 -0
- package/serve.py +28 -19
- package/ui/bench.js +69 -44
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.6.
|
|
1
|
+
0.6.2
|
package/metrics/route.py
CHANGED
|
@@ -148,12 +148,14 @@ def _full_tag_of(alias: str) -> str:
|
|
|
148
148
|
import endpoints
|
|
149
149
|
# Tags are keyed by the served name. A decision model is not in the llm
|
|
150
150
|
# yaml, so its served name comes from config/decision-models.json.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
or alias)
|
|
151
|
+
decision = _json(DATA / "config" / "decision-models.json").get(alias) or {}
|
|
152
|
+
served = _registered_names().get(alias) or decision.get("served") or alias
|
|
154
153
|
tag = _json(DATA / "config" / "provider-tags.json").get(served)
|
|
155
154
|
if not tag:
|
|
156
|
-
|
|
155
|
+
# A decision model is in no registration either: its own endpoint is its `via`
|
|
156
|
+
# (Cloudflare's clef, onboarded from a direct cloudflare endpoint).
|
|
157
|
+
name = (endpoints.endpoint_name(endpoints.registrations().get(alias) or {})
|
|
158
|
+
or decision.get("via") or decision.get("endpoint"))
|
|
157
159
|
tag = name if name and not endpoints.hub(name) else None
|
|
158
160
|
return tag or "?"
|
|
159
161
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
|
|
5
5
|
"mcpName": "io.github.WendellResearch-AI/benchwarmers",
|
|
6
6
|
"license": "MIT",
|
package/scripts/test_no_owui.py
CHANGED
|
@@ -236,6 +236,11 @@ def owner(fake: str) -> None:
|
|
|
236
236
|
assert tags["fast-1"] == "groq" and tags["llm7.codestral"] == "llm7-api" and tags["vendor/x:free"] == "openrouter", tags
|
|
237
237
|
assert "never-read" not in json.dumps(tags)
|
|
238
238
|
assert route.connection_of("fast-1") == "groq" and route.connection_of("x-free") == "openrouter"
|
|
239
|
+
# A decision model onboarded from the direct endpoint: in no registration, untagged, its `via` is its connection.
|
|
240
|
+
write("decision-models.json", {"dec-1": {"served": "vendor/decide:free", "endpoint": "openrouter", "via": "openrouter"},
|
|
241
|
+
"dec-hub": {"served": "hub-decide", "endpoint": "openwebui", "via": "openwebui"}})
|
|
242
|
+
assert route.connection_of("dec-1") == "openrouter" and route.connection_of("dec-hub") == "?"
|
|
243
|
+
(TMP / "config" / "decision-models.json").unlink()
|
|
239
244
|
|
|
240
245
|
# The default hub is recorded whole; the direct endpoint, its registered models only.
|
|
241
246
|
assert check_deprecations.served() == {"fast-1", "llm7.codestral", "fresh-2", "mercury-decide",
|
package/serve.py
CHANGED
|
@@ -4956,11 +4956,12 @@ def move_card() -> str:
|
|
|
4956
4956
|
"""Settings' Export and import (export_zip, import_zip; bench.js)."""
|
|
4957
4957
|
return ("<div class='panel form-panel'><h2>Export and import</h2><p class='muted'>Take this install to another"
|
|
4958
4958
|
" machine, or keep a copy of it: one .zip file, imported on the other install's Settings page.</p>"
|
|
4959
|
-
|
|
4959
|
+
# No wrapper: `.pick` is the onboarding picker's 3-column grid row, and squeezed these into it.
|
|
4960
|
+
"<label class='check'><input type='radio' name='x-scope' value='settings' checked"
|
|
4960
4961
|
" data-kept> <span><b>Settings only</b>: endpoints, collections, roles, model options, the schedule,"
|
|
4961
4962
|
" gateway keys, the theme (<code>config/</code>)</span></label>"
|
|
4962
4963
|
"<label class='check'><input type='radio' name='x-scope' value='all' data-kept> <span><b>Everything</b>:"
|
|
4963
|
-
" the settings, and every result, history, log and saved answer the bench judges by</span></label
|
|
4964
|
+
" the settings, and every result, history, log and saved answer the bench judges by</span></label>"
|
|
4964
4965
|
"<label class='check'><input type='checkbox' id='x-keys' data-kept> <span>With the endpoints' API keys."
|
|
4965
4966
|
" The file then holds them: keep it like a password, and delete it once imported.</span></label>"
|
|
4966
4967
|
"<div class='actions'><button class='btn' type='button' id='x-export'>Download export</button></div>"
|
|
@@ -5005,23 +5006,28 @@ def access_card() -> str:
|
|
|
5005
5006
|
body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td>{esc(v)}</td>"
|
|
5006
5007
|
f"<td class='mono set-by'>{'<br>'.join(esc(s) for s in src.split(', '))}</td></tr>"
|
|
5007
5008
|
for k, v, src in rows)
|
|
5008
|
-
running, disk = on_disk()
|
|
5009
|
-
now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
|
|
5010
|
-
f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
|
|
5011
5009
|
return ("<div class='panel'><h2>Server and access</h2><p class='muted'>How this install is reached and who"
|
|
5012
5010
|
" may call it. Set in the environment, read at start: change one and restart. Shown here, never"
|
|
5013
5011
|
" set here, since a page that could change its own login could open itself to anyone. No secret"
|
|
5014
5012
|
" is shown, only whether one is set.</p>"
|
|
5015
5013
|
f"<table class='facts'><thead><tr>{th('Setting')}{th('Now')}{th('Set by')}</tr></thead>"
|
|
5016
|
-
f"<tbody>{body}</tbody></table>"
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5014
|
+
f"<tbody>{body}</tbody></table>{restart_block('restart', 'a setting above')}</div>")
|
|
5015
|
+
|
|
5016
|
+
|
|
5017
|
+
def restart_block(id_: str, what: str) -> str:
|
|
5018
|
+
"""The Restart heading, what runs now, and its button (bench.js restart()).
|
|
5019
|
+
On Settings (Server and access) and Info, each with its own ids, since both
|
|
5020
|
+
tabs can be in the page at once."""
|
|
5021
|
+
running, disk = on_disk()
|
|
5022
|
+
now = (f"Running {esc(RELEASE)} ({esc(running)}), the code on disk." if running == disk else
|
|
5023
|
+
f"<strong>Newer code is on disk</strong> ({esc(disk)}; running {esc(RELEASE)}, {esc(running)}).")
|
|
5024
|
+
# restart(): stopped and started again by serve.py's own parent process.
|
|
5025
|
+
return (f"<h3>Restart</h3><p class='muted'>{now} A restart starts the dashboard again on the code on disk,"
|
|
5026
|
+
f" for an update or {what}, whatever runs it (a service, the launcher, a container or a terminal)."
|
|
5027
|
+
" It waits for onboarding, a scheduled job and gateway calls in progress, or cuts them off if you"
|
|
5028
|
+
" insist; the page comes back in a few seconds.</p>"
|
|
5029
|
+
f"<p><button class='btn' type='button' id='{id_}'>Restart Benchwarmers</button>"
|
|
5030
|
+
f" <span id='{id_}-out' role='status'></span></p>")
|
|
5025
5031
|
|
|
5026
5032
|
|
|
5027
5033
|
RESTART = 75 # the exit code serve.py's parent reads as "start me again"
|
|
@@ -5337,7 +5343,8 @@ def models_page() -> str:
|
|
|
5337
5343
|
# Every model, decision models too (connections_view is chat models only,
|
|
5338
5344
|
# for what a collection may hold): they are on the bench like any other.
|
|
5339
5345
|
by: dict[str, list[str]] = {}
|
|
5340
|
-
|
|
5346
|
+
directory = model_directory()
|
|
5347
|
+
for a in directory:
|
|
5341
5348
|
by.setdefault(catalog.connection_of(a), []).append(a)
|
|
5342
5349
|
for c in ({"name": n, "models": sorted(m)} for n, m in sorted(by.items())):
|
|
5343
5350
|
rows = []
|
|
@@ -5348,6 +5355,9 @@ def models_page() -> str:
|
|
|
5348
5355
|
ep = through.get(a, "?")
|
|
5349
5356
|
kind = (" " + chip("decision", "", "a decision model: answers typed questions, not chat")
|
|
5350
5357
|
if a in decisions else "")
|
|
5358
|
+
if directory[a].get("ad_hoc"):
|
|
5359
|
+
kind += " " + chip("ad hoc", "", "ad hoc only: never chosen by the bench on its own, only when you"
|
|
5360
|
+
" name it (a collection member, with, --model)")
|
|
5351
5361
|
rows.append(f"<tr><td><a href='#' class='model' data-alias='{esc(a)}'>"
|
|
5352
5362
|
f"<span class='alias'>{esc(a)}</span></a>{kind}</td>"
|
|
5353
5363
|
f"<td data-sort='{esc(ep)}'>{chip(ep, 'via', f'reached through the {ep} endpoint')}</td>"
|
|
@@ -5604,6 +5614,7 @@ def info_page() -> str:
|
|
|
5604
5614
|
("Theme", saved_theme())]]
|
|
5605
5615
|
body = "".join(f"<tr><th scope='row'>{esc(k)}</th><td class='mono'>{v}</td></tr>" for k, v in rows)
|
|
5606
5616
|
return (f"<div class='panel'><h2>Info</h2><table class='facts'><tbody>{body}</tbody></table>"
|
|
5617
|
+
f"{restart_block('restart-info', 'a setting')}"
|
|
5607
5618
|
"<p class='muted'>Benchwarmers · by Wendell Research · MIT licence"
|
|
5608
5619
|
" (<code>LICENSE</code>, credits in <code>NOTICE</code>). Every response carries"
|
|
5609
5620
|
" <code>X-Benchwarmers-Version</code>.</p></div>")
|
|
@@ -5668,8 +5679,7 @@ def editor() -> str:
|
|
|
5668
5679
|
+ "".join(f"<label>{k} <input type='number' min='1' data-k='{k}' placeholder='—'></label>"
|
|
5669
5680
|
for k in ("rpm", "rpd", "tpm", "tpd"))
|
|
5670
5681
|
+ "</div><div class='src' id='s-limits'></div></div>"
|
|
5671
|
-
"</div><div class='errors' id='f-errors' role='alert'></div
|
|
5672
|
-
"<div class='actions'><button class='btn' id='f-save' type='button'>Save facts</button></div>"
|
|
5682
|
+
"</div><div class='errors' id='f-errors' role='alert'></div>"
|
|
5673
5683
|
"</details>"
|
|
5674
5684
|
# How the bench calls it (serve.save_model_options): every caller, gateway, suite and probes.
|
|
5675
5685
|
"<details id='m-opts' class='facts-box'><summary>Call options</summary>"
|
|
@@ -5679,8 +5689,7 @@ def editor() -> str:
|
|
|
5679
5689
|
" makes to it. A reasoning model thinks out of the same budget and answers with an empty string"
|
|
5680
5690
|
" when it runs out, which looks like a dead model: raise it then. Blank leaves it to the"
|
|
5681
5691
|
" endpoint. <span id='co-ceiling'></span></p>"
|
|
5682
|
-
"<div class='errors' id='co-errors' role='alert'></div
|
|
5683
|
-
"<div class='actions'><button class='btn' id='co-save' type='button'>Save options</button></div>"
|
|
5692
|
+
"<div class='errors' id='co-errors' role='alert'></div>"
|
|
5684
5693
|
"</details>"
|
|
5685
5694
|
"<label class='check'><input type='checkbox' id='m-adhoc'> Ad hoc only</label>"
|
|
5686
5695
|
"<p class='rules'>Never chosen by the bench on its own: no explorer seat, no router"
|
package/ui/bench.js
CHANGED
|
@@ -55,30 +55,33 @@
|
|
|
55
55
|
askD.addEventListener('close', function () { answer(false); }); // Escape
|
|
56
56
|
|
|
57
57
|
// ---- restart (serve.restart): the page reloads once the new one answers ----
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// One button on Settings (restart) and one on Info (restart-info), each with its own status line.
|
|
59
|
+
function restart(id, force) {
|
|
60
|
+
var rs = $(id), out = $(id + '-out');
|
|
60
61
|
rs.disabled = true; out.textContent = 'Restarting…';
|
|
61
62
|
post('/api/restart', {force: force}).then(function (res) {
|
|
62
|
-
if (res.ok) return back(Date.now());
|
|
63
|
+
if (res.ok) return back(out, Date.now());
|
|
63
64
|
rs.disabled = false; out.textContent = '';
|
|
64
65
|
if (!res.busy) { listErrors(out, res.errors); return; }
|
|
65
66
|
ask('Still in progress: ' + res.errors.join('; ') + '. Restart anyway and cut them off?', 'Restart anyway', true)
|
|
66
|
-
.then(function (yes) { if (yes) restart(true); });
|
|
67
|
+
.then(function (yes) { if (yes) restart(id, true); });
|
|
67
68
|
}).catch(function (err) { rs.disabled = false; out.textContent = String(err); });
|
|
68
69
|
}
|
|
69
70
|
// The old one stops half a second after answering; /health answers again once the new one is up.
|
|
70
|
-
function back(since) {
|
|
71
|
+
function back(out, since) {
|
|
71
72
|
setTimeout(function () {
|
|
72
73
|
fetch('/health', {cache: 'no-store'}).then(function (r) { if (!r.ok) throw r.status; location.reload(); })
|
|
73
74
|
.catch(function () {
|
|
74
|
-
if (Date.now() - since < 120000) back(since);
|
|
75
|
-
else
|
|
75
|
+
if (Date.now() - since < 120000) back(out, since);
|
|
76
|
+
else out.textContent = 'Not back after two minutes: look at whatever runs it.';
|
|
76
77
|
});
|
|
77
78
|
}, 1500);
|
|
78
79
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
['restart', 'restart-info'].forEach(function (id) {
|
|
81
|
+
on('click', id, function () {
|
|
82
|
+
ask('Restart Benchwarmers? The dashboard and the gateway on this port stop for a few seconds.', 'Restart')
|
|
83
|
+
.then(function (yes) { if (yes) restart(id, false); });
|
|
84
|
+
});
|
|
82
85
|
});
|
|
83
86
|
|
|
84
87
|
// ---- Export and import (serve.export_zip, import_zip) ----------------
|
|
@@ -490,7 +493,7 @@
|
|
|
490
493
|
reason: $('m-reason'), remove: $('m-remove'), onboard: $('m-onboard'),
|
|
491
494
|
qtypes: $('m-qtypes'), aliasWhy: $('m-alias-why'), roleHint: $('m-role-hint')};
|
|
492
495
|
m.aliasWhy.dataset.chat = m.aliasWhy.textContent;
|
|
493
|
-
var editing = null;
|
|
496
|
+
var editing = null, shown = {};
|
|
494
497
|
|
|
495
498
|
function openModel(alias) {
|
|
496
499
|
var model = D.models[alias], gone = (D.retired || {})[alias];
|
|
@@ -539,6 +542,8 @@
|
|
|
539
542
|
+ (hint.at || '').slice(0, 10) + '). Only a suggestion: the role is the one you pick above.' : '';
|
|
540
543
|
m.save.disabled = false; m.save.textContent = 'Save';
|
|
541
544
|
m.reason.value = ''; m.removeBox.open = false; m.remove.disabled = false; refresh();
|
|
545
|
+
// What the popup opened with: Save sends only the parts that differ from it.
|
|
546
|
+
shown = {model: JSON.stringify(modelBody()), facts: factsBody(), opts: optsBody()};
|
|
542
547
|
}
|
|
543
548
|
if (!md.open) md.showModal(); // open already: shown again as it now is (shownAgain)
|
|
544
549
|
}
|
|
@@ -577,7 +582,7 @@
|
|
|
577
582
|
var box = $('m-facts'), f = (D.facts || {})[alias], profile = $('m-profile');
|
|
578
583
|
box.hidden = profile.hidden = decision || !f;
|
|
579
584
|
profile.textContent = '';
|
|
580
|
-
$('f-errors').textContent = '';
|
|
585
|
+
$('f-errors').textContent = '';
|
|
581
586
|
if (box.hidden) return;
|
|
582
587
|
var src = f.sources || {}, mine = function (k) { return src[k] === 'you'; };
|
|
583
588
|
// At a glance, as models.dev shows a model: what goes in, what comes out.
|
|
@@ -621,40 +626,25 @@
|
|
|
621
626
|
var lsrc = Object.keys(src).filter(function (k) { return k.indexOf('limits') === 0; }).map(function (k) { return src[k]; });
|
|
622
627
|
$('s-limits').textContent = lsrc.length ? lsrc.join(' · ') : 'none known';
|
|
623
628
|
}
|
|
624
|
-
|
|
625
|
-
|
|
629
|
+
// The facts as typed, as /api/model-facts takes them; null when the box is not shown.
|
|
630
|
+
function factsBody() {
|
|
631
|
+
if ($('m-facts').hidden) return null;
|
|
626
632
|
var facts = {limits: {}};
|
|
627
633
|
FACT_NUM.forEach(function (k) { if ($('f-' + k).value) facts[k] = parseInt($('f-' + k).value, 10); });
|
|
628
634
|
FACT_CAP.forEach(function (k) { var v = $('f-' + k).value; if (v) facts[k] = v === 'yes'; });
|
|
629
635
|
if ($('f-knowledge_cutoff').value.trim()) facts.knowledge_cutoff = $('f-knowledge_cutoff').value.trim();
|
|
630
636
|
document.querySelectorAll('#f-limits input').forEach(function (b) { if (b.value) facts.limits[b.dataset.k] = parseInt(b.value, 10); });
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
post('/api/model-facts', {alias: alias, facts: facts}).then(function (r) {
|
|
634
|
-
if (!r.ok) { listErrors($('f-errors'), r.errors); return; }
|
|
635
|
-
// The tables that show facts (At a glance, Models, Quotas) follow once the popup closes (live).
|
|
636
|
-
shownAgain(alias, 'f-done', 'Saved. The tables show it once this closes.');
|
|
637
|
-
});
|
|
638
|
-
});
|
|
637
|
+
return JSON.stringify(facts);
|
|
638
|
+
}
|
|
639
639
|
// ---- call options: how the bench asks this model (serve.save_model_options)
|
|
640
640
|
function fillOptions(alias, decision) {
|
|
641
641
|
var o = (D.options || {})[alias] || {}, ceiling = ((D.facts || {})[alias] || {}).max_output;
|
|
642
642
|
$('m-opts').hidden = decision;
|
|
643
|
-
$('co-errors').textContent = '';
|
|
643
|
+
$('co-errors').textContent = '';
|
|
644
644
|
$('co-max').value = o.max_tokens || ''; $('co-max').placeholder = "the endpoint's default";
|
|
645
645
|
$('co-ceiling').textContent = ceiling ? 'At most its max output, ' + Number(ceiling).toLocaleString() + '.' : '';
|
|
646
646
|
}
|
|
647
|
-
$('
|
|
648
|
-
if (!editing) return;
|
|
649
|
-
var v = $('co-max').value.trim();
|
|
650
|
-
$('co-errors').textContent = ''; $('co-done').textContent = 'Saving…'; // the llm CLI is told too: seconds
|
|
651
|
-
var alias = editing;
|
|
652
|
-
post('/api/model-options', {alias: alias, max_tokens: v ? Number(v) : null}).then(function (r) {
|
|
653
|
-
if (!r.ok) { $('co-done').textContent = ''; listErrors($('co-errors'), r.errors); return; }
|
|
654
|
-
ownChange(); // only this popup shows them
|
|
655
|
-
shownAgain(alias, 'co-done', 'Saved' + (r.note ? '; ' + r.note : '') + '.');
|
|
656
|
-
});
|
|
657
|
-
});
|
|
647
|
+
function optsBody() { return $('m-opts').hidden ? null : $('co-max').value.trim(); }
|
|
658
648
|
|
|
659
649
|
function refresh() {
|
|
660
650
|
m.danger.hidden = !(editing && m.alias.value.trim() !== editing);
|
|
@@ -690,22 +680,57 @@
|
|
|
690
680
|
then(res);
|
|
691
681
|
};
|
|
692
682
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
var body = {alias: editing, new_alias: m.alias.value.trim(), new_role: m.role.value, new_ad_hoc: m.adhoc.checked};
|
|
683
|
+
function modelBody() {
|
|
684
|
+
var body = {new_alias: m.alias.value.trim(), new_role: m.role.value, new_ad_hoc: m.adhoc.checked};
|
|
696
685
|
if (!m.qtypes.hidden) {
|
|
697
686
|
body.new_question_types = Array.prototype.filter.call(m.qtypes.querySelectorAll('input'),
|
|
698
687
|
function (c) { return c.checked; }).map(function (c) { return c.value; });
|
|
699
688
|
}
|
|
700
|
-
|
|
701
|
-
|
|
689
|
+
return body;
|
|
690
|
+
}
|
|
691
|
+
// The one Save: facts, then call options, then the model itself (alias, role, ad hoc,
|
|
692
|
+
// question types), each only if changed. The model goes last: a rename moves the
|
|
693
|
+
// facts and options saved under the old alias with it. The first refusal stops it.
|
|
694
|
+
m.save.addEventListener('click', function () {
|
|
695
|
+
if (!editing) return;
|
|
696
|
+
var alias = editing, body = modelBody(), facts = factsBody(), opts = optsBody(), said = [];
|
|
697
|
+
var steps = [];
|
|
698
|
+
if (facts !== shown.facts) steps.push(function () {
|
|
699
|
+
return post('/api/model-facts', {alias: alias, facts: JSON.parse(facts)}).then(function (r) {
|
|
700
|
+
if (!r.ok) { listErrors($('f-errors'), r.errors); $('m-facts').open = true; throw 'refused'; }
|
|
701
|
+
said.push('model facts');
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
if (opts !== shown.opts) steps.push(function () { // the llm CLI is told too: seconds
|
|
705
|
+
return post('/api/model-options', {alias: alias, max_tokens: opts ? Number(opts) : null}).then(function (r) {
|
|
706
|
+
if (!r.ok) { listErrors($('co-errors'), r.errors); $('m-opts').open = true; throw 'refused'; }
|
|
707
|
+
ownChange(); // only this popup shows them
|
|
708
|
+
said.push('call options' + (r.note ? ' (' + r.note + ')' : ''));
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
if (JSON.stringify(body) !== shown.model) steps.push(function () {
|
|
712
|
+
body.alias = alias;
|
|
713
|
+
return post('/api/model', body).then(function (r) {
|
|
714
|
+
if (!r.ok) { listErrors(m.errors, r.errors); throw 'refused'; }
|
|
715
|
+
said.push('the model (' + done(r).replace(/^Done: |\.$/g, '') + ')');
|
|
716
|
+
if (body.new_alias !== alias) { m.title.textContent = body.new_alias; refreshPanes(); }
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
m.errors.textContent = ''; m.done.textContent = ''; $('f-errors').textContent = ''; $('co-errors').textContent = '';
|
|
720
|
+
if (!steps.length) { m.done.textContent = 'Nothing changed.'; return; }
|
|
721
|
+
(body.new_alias === alias ? Promise.resolve(true)
|
|
722
|
+
: ask('Rename ' + alias + ' to ' + body.new_alias + '? Every record keyed by the old alias follows it.', 'Rename')
|
|
702
723
|
).then(function (yes) {
|
|
703
724
|
if (!yes) return;
|
|
704
|
-
m.save.disabled = true; m.save.textContent = 'Saving…';
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
shownAgain(body.new_alias, 'm-done',
|
|
708
|
-
})
|
|
725
|
+
m.save.disabled = true; m.save.textContent = 'Saving…';
|
|
726
|
+
steps.reduce(function (p, step) { return p.then(step); }, Promise.resolve()).then(function () {
|
|
727
|
+
// The tables that show facts follow once the popup closes (live).
|
|
728
|
+
shownAgain(body.new_alias, 'm-done', 'Saved: ' + said.join(' · ') + '.');
|
|
729
|
+
}).catch(function (err) {
|
|
730
|
+
if (err !== 'refused') m.errors.textContent = String(err);
|
|
731
|
+
if (said.length) m.done.textContent = 'Saved before that: ' + said.join(' · ') + '.';
|
|
732
|
+
m.save.disabled = false; m.save.textContent = 'Save';
|
|
733
|
+
});
|
|
709
734
|
});
|
|
710
735
|
});
|
|
711
736
|
// The confirm is the last word: the popup closes at once and the page
|