Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons; the reasoning of the ten longest is on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0). The status page: the month-peak and peak-guard rows, the stale-reading row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the Stopped banner and the line under the buttons, which now shows the plain hint of each event instead of the raw action log. The Supervisor question at start logs one line with a real reason. Refusals are counted as shown (labels expanded, long cases driven), seven were shortened, the card and parse errors lost their internals, a blank value reads "empty", and the panel no longer files inverters.max_w under the flat max_w. Option names lose "(JSON)", the NL texts are checked too. DOCS gets a glossary (control board, isolator, Developer Tools) and card ids move from headings to a "For installers" line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
562 lines
23 KiB
Python
562 lines
23 KiB
Python
"""UX-02: the configuration page itself - markup, style and the form's script.
|
|
|
|
⚠️ WHY THIS IS ITS OWN FILE. `web.py` is the ROUTES: three status handlers and,
|
|
since this card, five configuration ones. The page is ~500 lines of HTML, CSS
|
|
and JavaScript, and inlining it there would bury the routing - which is the
|
|
half a reviewer has to check for "does anything here command an inverter".
|
|
|
|
⚠️ AND WHAT IS NOT IN THIS FILE, WHICH IS THE POINT OF THE CARD. There is no
|
|
option key, no label, no unit, no range, no default and NO RULE in the script
|
|
below. It renders a model the server built out of `GET /addons/self/info`
|
|
(app/configui.py) and it renders refusals the server got out of
|
|
`validate_startup_config()` (app/selfconfig.py). Its longest function is the one
|
|
that decides which HTML input a `type` deserves.
|
|
|
|
That is deliberate and it is checkable: test_selfconfig.py part 5 greps this
|
|
file for the option keys and for phrases out of validate.py's refusals and
|
|
asserts it contains none of them, and part 6 adds a NEW rule to a copy of
|
|
validate.py and watches it appear on the page with this file untouched.
|
|
|
|
⚠️ EVERY URL IS RELATIVE. Ingress serves the add-on under a generated path
|
|
prefix (`/api/hassio_ingress/<token>/`), so an absolute `/config/model` 404s in
|
|
the field while working perfectly on a laptop. web.py's own header has said so
|
|
since the status page shipped; this file has five more chances to get it wrong.
|
|
|
|
⚠️ AND IT IS BUILT FOR A PHONE FIRST. This is the screen someone stands in
|
|
front of the inverter with: one column, big touch targets, sections that start
|
|
collapsed so 72 options do not arrive as one wall, and the refusal block pinned
|
|
where it cannot be scrolled past.
|
|
"""
|
|
|
|
CONFIG_PAGE = """<!doctype html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>GoodWe - Configuration</title>
|
|
<style>
|
|
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
|
|
--ok:#137333;--warn:#b25e02;--bad:#c5221f;--accent:#1a73e8;
|
|
--badtint:rgba(197,34,31,.10);--warntint:rgba(178,94,2,.10);
|
|
--oktint:rgba(19,115,51,.10)}
|
|
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
|
|
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82;
|
|
--accent:#8ab4f8;--badtint:rgba(242,139,130,.14);
|
|
--warntint:rgba(253,214,99,.14);--oktint:rgba(129,201,149,.14)}}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;padding:0 0 96px;background:var(--bg);color:var(--ink);
|
|
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
|
.wrap{max-width:760px;margin:0 auto;padding:14px}
|
|
h1{font-size:18px;margin:0 0 2px}
|
|
a{color:var(--accent)}
|
|
.sub{color:var(--muted);font-size:13px;margin:0 0 12px}
|
|
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
|
padding:14px;margin-bottom:12px}
|
|
.note{border-left:4px solid var(--warn);background:var(--warntint);
|
|
border-radius:8px;padding:11px 13px;margin-bottom:12px;font-size:14px}
|
|
.note.bad{border-left-color:var(--bad);background:var(--badtint)}
|
|
.note.ok{border-left-color:var(--ok);background:var(--oktint)}
|
|
.note b{display:block;margin-bottom:3px}
|
|
details.grp{background:var(--card);border:1px solid var(--line);
|
|
border-radius:10px;margin-bottom:10px;overflow:hidden}
|
|
details.grp>summary{cursor:pointer;padding:14px;font-weight:600;
|
|
list-style:none;display:flex;justify-content:space-between;gap:10px;
|
|
align-items:center}
|
|
details.grp>summary::-webkit-details-marker{display:none}
|
|
details.grp>summary::after{content:"\\25be";color:var(--muted);font-weight:400}
|
|
details.grp[open]>summary::after{content:"\\25b4"}
|
|
details.grp[open]>summary{border-bottom:1px solid var(--line)}
|
|
.gbody{padding:4px 14px 14px}
|
|
.pill{font-size:12px;font-weight:600;border-radius:999px;padding:2px 9px;
|
|
background:var(--badtint);color:var(--bad)}
|
|
.pill.q{background:var(--warntint);color:var(--warn)}
|
|
.f{padding:13px 0;border-bottom:1px solid var(--line)}
|
|
.f:last-child{border-bottom:0}
|
|
.f>label{display:block;font-weight:600;margin-bottom:2px}
|
|
.k{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}
|
|
.row{display:flex;gap:8px;align-items:center;margin:6px 0 0}
|
|
.row input[type=text],.row input[type=number],.row select{flex:1 1 auto;
|
|
min-width:0;font:inherit;padding:10px;border-radius:8px;
|
|
border:1px solid var(--line);background:var(--bg);color:var(--ink)}
|
|
.row .unit{color:var(--muted);font-size:13px;white-space:nowrap}
|
|
.rng{color:var(--muted);font-size:12px;margin-top:4px}
|
|
.help{color:var(--muted);font-size:13px;margin-top:6px;cursor:pointer;
|
|
max-height:3.1em;overflow:hidden;position:relative}
|
|
.help.open{max-height:none}
|
|
.help::after{content:" more";color:var(--accent)}
|
|
.help.open::after{content:" less";color:var(--accent)}
|
|
.prob{margin-top:8px;border-left:4px solid var(--bad);background:var(--badtint);
|
|
border-radius:6px;padding:9px 11px;font-size:13.5px}
|
|
.prob+.prob{margin-top:6px}
|
|
.sw{display:flex;align-items:center;gap:10px;margin-top:6px}
|
|
.sw input{width:20px;height:20px}
|
|
.entry{border:1px solid var(--line);border-radius:9px;padding:10px 12px;
|
|
margin:10px 0;background:var(--bg)}
|
|
.entry h4{margin:0 0 2px;font-size:14px}
|
|
button{font:inherit;padding:10px 14px;border-radius:8px;
|
|
border:1px solid var(--line);background:var(--card);color:var(--ink);
|
|
cursor:pointer}
|
|
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
|
|
button[disabled]{opacity:.5;cursor:not-allowed}
|
|
button.small{padding:6px 10px;font-size:13px}
|
|
.bar{position:fixed;left:0;right:0;bottom:0;background:var(--card);
|
|
border-top:1px solid var(--line);padding:10px 14px;z-index:5}
|
|
.bar .in{max-width:760px;margin:0 auto;display:flex;gap:10px;
|
|
align-items:center;flex-wrap:wrap}
|
|
.bar .msg{flex:1 1 200px;min-width:0;font-size:13px;color:var(--muted)}
|
|
dialog{border:1px solid var(--line);border-radius:12px;background:var(--card);
|
|
color:var(--ink);max-width:520px;width:92%;padding:18px}
|
|
dialog::backdrop{background:rgba(0,0,0,.5)}
|
|
.diff{font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
white-space:pre-wrap;word-break:break-word;margin-top:6px}
|
|
</style></head><body>
|
|
<div class="wrap">
|
|
<h1>GoodWe - Configuration</h1>
|
|
<p class="sub"><a href="./">← Status and controls</a></p>
|
|
|
|
<div class="note" id="authority">
|
|
<b>This page and the Supervisor's Configuration tab edit the same settings.</b>
|
|
Both write one options blob. This page is the one that can tell you a
|
|
setting is wrong <i>before</i> you save it; the Configuration tab cannot,
|
|
because it only knows types and ranges. If the add-on is not running, the
|
|
Configuration tab is the only one of the two that still exists - that is
|
|
what it is for.
|
|
</div>
|
|
|
|
<div class="note" id="restartcost">
|
|
<b>Saving does not restart anything, and does not take effect on its own.</b>
|
|
Stored settings are applied when the add-on next starts. Restarting to
|
|
apply them costs a short loss of control: the add-on commands
|
|
<b>0 W on every inverter</b> as it shuts down, and the battery does nothing
|
|
until it is back up. That is a separate button, below, with its own
|
|
confirmation.
|
|
</div>
|
|
|
|
<div id="banner"></div>
|
|
<div id="staleness"></div>
|
|
<div id="form"></div>
|
|
</div>
|
|
|
|
<div class="bar"><div class="in">
|
|
<button class="primary" id="save" disabled>Save settings</button>
|
|
<button id="restart" disabled>Restart to apply</button>
|
|
<button id="reload">Discard edits</button>
|
|
<span class="msg" id="msg">loading…</span>
|
|
</div></div>
|
|
|
|
<dialog id="confirm">
|
|
<h3 style="margin-top:0">Restart the add-on?</h3>
|
|
<p>The add-on will command <b>0 W on every inverter</b>, stop, and start
|
|
again with the stored settings. Control of the battery is lost for as long as
|
|
that takes. Nothing else about the inverter is changed, and no latched
|
|
failsafe is cleared.</p>
|
|
<div style="display:flex;gap:10px;justify-content:flex-end">
|
|
<button id="cancel">Cancel</button>
|
|
<button class="danger" id="go">Yes, restart now</button>
|
|
</div>
|
|
</dialog>
|
|
|
|
<script>
|
|
"use strict";
|
|
var MODEL = null; // groups/fields, from the server
|
|
var STORED = null; // the options blob the server last read
|
|
var FP = ""; // its fingerprint - the compare-and-swap token
|
|
var VALS = {}; // what is on screen right now
|
|
var timer = null;
|
|
|
|
function el(tag, cls, text){
|
|
var e = document.createElement(tag);
|
|
if(cls) e.className = cls;
|
|
if(text !== undefined && text !== null) e.textContent = text;
|
|
return e;
|
|
}
|
|
function msg(text, kind){
|
|
var m = document.getElementById('msg');
|
|
m.textContent = text;
|
|
m.style.color = kind === 'bad' ? 'var(--bad)'
|
|
: kind === 'ok' ? 'var(--ok)' : 'var(--muted)';
|
|
}
|
|
function note(host, kind, title, body){
|
|
var d = el('div', 'note ' + (kind || ''));
|
|
if(title) d.appendChild(el('b', null, title));
|
|
if(body) d.appendChild(document.createTextNode(body));
|
|
host.appendChild(d);
|
|
return d;
|
|
}
|
|
|
|
// --- the value of one input, by the type the SERVER gave the field ---------
|
|
// The only "knowledge" in this script: which HTML control a Supervisor type
|
|
// deserves, and how to read it back. No key names, no bounds, no rules.
|
|
function readInput(input, type){
|
|
if(type === 'boolean') return input.checked;
|
|
if(type === 'integer' || type === 'float'){
|
|
if(input.value === '') return '';
|
|
var n = Number(input.value);
|
|
return isNaN(n) ? input.value : n;
|
|
}
|
|
return input.value;
|
|
}
|
|
|
|
function makeInput(field, value, onchange){
|
|
var row = el('div', 'row');
|
|
var input;
|
|
if(field.type === 'boolean'){
|
|
row.className = 'sw';
|
|
input = el('input');
|
|
input.type = 'checkbox';
|
|
input.checked = !!value;
|
|
var lab = el('span', 'k', field.key);
|
|
row.appendChild(input); row.appendChild(lab);
|
|
} else if(field.type === 'select'){
|
|
input = el('select');
|
|
(field.options || []).forEach(function(o){
|
|
var opt = el('option', null, o); opt.value = o;
|
|
if(String(value) === o) opt.selected = true;
|
|
input.appendChild(opt);
|
|
});
|
|
row.appendChild(input);
|
|
} else {
|
|
input = el('input');
|
|
if(field.type === 'integer' || field.type === 'float'){
|
|
input.type = 'number';
|
|
input.inputMode = field.type === 'integer' ? 'numeric' : 'decimal';
|
|
input.step = field.type === 'integer' ? '1' : 'any';
|
|
if(field.min !== undefined) input.min = field.min;
|
|
if(field.max !== undefined) input.max = field.max;
|
|
} else {
|
|
input.type = 'text';
|
|
input.autocapitalize = 'none';
|
|
input.spellcheck = false;
|
|
}
|
|
input.value = (value === undefined || value === null) ? '' : value;
|
|
row.appendChild(input);
|
|
if(field.unit) row.appendChild(el('span', 'unit', field.unit));
|
|
}
|
|
input.addEventListener('input', onchange);
|
|
input.addEventListener('change', onchange);
|
|
return {row: row, input: input};
|
|
}
|
|
|
|
function helpBlock(field){
|
|
if(!field.help) return null;
|
|
var h = el('div', 'help', field.help);
|
|
h.title = field.help;
|
|
h.addEventListener('click', function(){ h.classList.toggle('open'); });
|
|
return h;
|
|
}
|
|
|
|
function rangeHint(field){
|
|
var bits = [];
|
|
if(field.min !== undefined || field.max !== undefined){
|
|
bits.push('accepted: ' + (field.min !== undefined ? field.min : '-')
|
|
+ ' to ' + (field.max !== undefined ? field.max : '-')
|
|
+ (field.unit ? ' ' + field.unit : ''));
|
|
}
|
|
bits.push(field.key);
|
|
return el('div', 'rng', bits.join(' \\u00b7 '));
|
|
}
|
|
|
|
// --- a list of scalars (`- str` in the schema) -----------------------------
|
|
function scalarList(field, value){
|
|
var box = el('div');
|
|
var items = Array.isArray(value) ? value.slice() : [];
|
|
function draw(){
|
|
box.textContent = '';
|
|
items.forEach(function(v, i){
|
|
var row = el('div', 'row');
|
|
var input = el('input'); input.type = 'text'; input.value = v;
|
|
input.autocapitalize = 'none'; input.spellcheck = false;
|
|
input.addEventListener('input', function(){
|
|
items[i] = input.value; VALS[field.key] = items.slice(); queue();
|
|
});
|
|
var rm = el('button', 'small', 'Remove');
|
|
rm.addEventListener('click', function(){
|
|
items.splice(i, 1); VALS[field.key] = items.slice(); draw(); queue();
|
|
});
|
|
row.appendChild(input); row.appendChild(rm);
|
|
box.appendChild(row);
|
|
});
|
|
var add = el('button', 'small', 'Add');
|
|
add.addEventListener('click', function(){
|
|
items.push(''); VALS[field.key] = items.slice(); draw(); queue();
|
|
});
|
|
box.appendChild(add);
|
|
}
|
|
draw();
|
|
return box;
|
|
}
|
|
|
|
// --- a list of objects: the roster editor ----------------------------------
|
|
function objectList(field, value){
|
|
var box = el('div');
|
|
var entries = Array.isArray(value) ? JSON.parse(JSON.stringify(value)) : [];
|
|
function push(){ VALS[field.key] = JSON.parse(JSON.stringify(entries)); }
|
|
function draw(){
|
|
box.textContent = '';
|
|
if(!entries.length){
|
|
var e = el('div', 'rng',
|
|
'No entries. With none, the settings above describe the one inverter.');
|
|
box.appendChild(e);
|
|
}
|
|
entries.forEach(function(entry, i){
|
|
var card = el('div', 'entry');
|
|
card.appendChild(el('h4', null, 'Entry ' + (i + 1)
|
|
+ (entry && entry.name ? ' \\u2014 ' + entry.name : '')));
|
|
(field.fields || []).forEach(function(sub){
|
|
var f = el('div', 'f');
|
|
// The label is the translation's own, verbatim - UX-01 already says
|
|
// "(optional)" in the ones that are, and adding a second one here
|
|
// would be this panel putting words next to a field.
|
|
f.appendChild(el('label', null, sub.label));
|
|
var made = makeInput(sub, entry ? entry[sub.key] : undefined,
|
|
function(){
|
|
entries[i][sub.key] = readInput(made.input, sub.type);
|
|
push(); queue();
|
|
});
|
|
f.appendChild(made.row);
|
|
f.appendChild(rangeHint(sub));
|
|
var h = helpBlock(sub); if(h) f.appendChild(h);
|
|
card.appendChild(f);
|
|
});
|
|
var rm = el('button', 'small danger', 'Remove this inverter');
|
|
rm.addEventListener('click', function(){
|
|
entries.splice(i, 1); push(); draw(); queue();
|
|
});
|
|
card.appendChild(rm);
|
|
box.appendChild(card);
|
|
});
|
|
var add = el('button', 'small', 'Add an inverter');
|
|
add.addEventListener('click', function(){
|
|
var fresh = {};
|
|
(field.fields || []).forEach(function(sub){
|
|
fresh[sub.key] = sub.type === 'boolean' ? false
|
|
: (sub.type === 'integer' || sub.type === 'float') ? ''
|
|
: '';
|
|
});
|
|
entries.push(fresh); push(); draw(); queue();
|
|
});
|
|
box.appendChild(add);
|
|
}
|
|
draw();
|
|
return box;
|
|
}
|
|
|
|
// --- rendering the whole form ---------------------------------------------
|
|
var FIELD_NODES = {};
|
|
|
|
function render(){
|
|
var host = document.getElementById('form');
|
|
host.textContent = '';
|
|
FIELD_NODES = {};
|
|
MODEL.groups.forEach(function(group){
|
|
var d = el('details', 'grp');
|
|
var s = el('summary');
|
|
s.appendChild(el('span', null, group.name || 'Not grouped'));
|
|
var pill = el('span', 'pill'); pill.style.display = 'none';
|
|
s.appendChild(pill);
|
|
d.appendChild(s);
|
|
var body = el('div', 'gbody');
|
|
group.fields.forEach(function(field){
|
|
var f = el('div', 'f');
|
|
f.appendChild(el('label', null, field.label));
|
|
if(field.type === 'schema'){
|
|
f.appendChild(objectList(field, VALS[field.key]));
|
|
} else if(field.multiple){
|
|
f.appendChild(scalarList(field, VALS[field.key]));
|
|
} else {
|
|
var made = makeInput(field, VALS[field.key], function(){
|
|
VALS[field.key] = readInput(made.input, field.type); queue();
|
|
});
|
|
f.appendChild(made.row);
|
|
}
|
|
if(field.type !== 'schema') f.appendChild(rangeHint(field));
|
|
var h = helpBlock(field); if(h) f.appendChild(h);
|
|
var probs = el('div'); f.appendChild(probs);
|
|
FIELD_NODES[field.key] = {probs: probs, group: d, pill: pill};
|
|
body.appendChild(f);
|
|
});
|
|
d.appendChild(body);
|
|
host.appendChild(d);
|
|
});
|
|
}
|
|
|
|
// --- refusals: printed exactly as the validator wrote them -----------------
|
|
function showProblems(payload){
|
|
Object.keys(FIELD_NODES).forEach(function(k){
|
|
FIELD_NODES[k].probs.textContent = '';
|
|
});
|
|
document.querySelectorAll('.pill').forEach(function(p){
|
|
p.style.display = 'none'; p.textContent = '';
|
|
});
|
|
var banner = document.getElementById('banner');
|
|
banner.textContent = '';
|
|
var counts = new Map();
|
|
Object.keys(payload.by_key || {}).forEach(function(key){
|
|
var node = FIELD_NODES[key];
|
|
payload.by_key[key].forEach(function(text){
|
|
var box = el('div', 'prob', text);
|
|
if(node) node.probs.appendChild(box); else banner.appendChild(box);
|
|
});
|
|
if(node){
|
|
counts.set(node.pill, (counts.get(node.pill) || 0)
|
|
+ payload.by_key[key].length);
|
|
node.group.open = true;
|
|
}
|
|
});
|
|
counts.forEach(function(n, pill){
|
|
pill.textContent = n + (n === 1 ? ' problem' : ' problems');
|
|
pill.style.display = '';
|
|
});
|
|
(payload.unanchored || []).forEach(function(text){
|
|
banner.appendChild(el('div', 'prob', text));
|
|
});
|
|
var total = (payload.problems || []).length;
|
|
var save = document.getElementById('save');
|
|
save.disabled = total > 0;
|
|
if(total){
|
|
note(banner, 'bad', 'This configuration would not start.',
|
|
'The add-on would refuse to start on it. Saving is blocked until these '
|
|
+ 'are fixed, and nothing has been written. Each message below comes '
|
|
+ 'from the same check that runs at start.');
|
|
banner.insertBefore(banner.lastChild, banner.firstChild);
|
|
msg(total + (total === 1 ? ' problem' : ' problems')
|
|
+ ' - saving is blocked', 'bad');
|
|
} else {
|
|
msg('this configuration would start', 'ok');
|
|
}
|
|
if(payload.device_note){
|
|
note(banner, '', 'Two range checks could not run.', payload.device_note);
|
|
}
|
|
}
|
|
|
|
// ⚠️ THE DEBOUNCE IS WHAT BOUNDS THE READS THIS PAGE MAKES OF HOME ASSISTANT.
|
|
// Each validation asks the setpoint device(s) for their own range, and those
|
|
// are real Core API reads on the same session the control loop uses. Because
|
|
// every keystroke RESETS the timer, sustained typing produces no requests at
|
|
// all and one lands 350 ms after the operator stops - which is the property
|
|
// that matters, not the number.
|
|
function queue(){
|
|
msg('checking\\u2026');
|
|
document.getElementById('save').disabled = true;
|
|
if(timer) clearTimeout(timer);
|
|
timer = setTimeout(validate, 350);
|
|
}
|
|
|
|
async function validate(){
|
|
try{
|
|
const r = await fetch('config/validate', {method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({options: VALS})});
|
|
showProblems(await r.json());
|
|
}catch(e){
|
|
msg('cannot reach the add-on: ' + e, 'bad');
|
|
}
|
|
}
|
|
|
|
function showStaleness(keys, stored){
|
|
var host = document.getElementById('staleness');
|
|
host.textContent = '';
|
|
if(!keys || !keys.length) return;
|
|
var d = note(host, '', 'Saved, but not running yet.',
|
|
'These settings are stored and differ from what this add-on is running '
|
|
+ 'right now. They take effect when it next starts:');
|
|
var pre = el('div', 'diff', keys.map(function(k){
|
|
return k + ': running ' + JSON.stringify(stored.running[k])
|
|
+ ' -> stored ' + JSON.stringify(stored.stored[k]);
|
|
}).join('\\n'));
|
|
d.appendChild(pre);
|
|
}
|
|
|
|
async function load(){
|
|
msg('loading\\u2026');
|
|
try{
|
|
const r = await fetch('config/model?lang='
|
|
+ encodeURIComponent((navigator.language || 'en')
|
|
.slice(0, 2)),
|
|
{cache: 'no-store'});
|
|
const m = await r.json();
|
|
MODEL = m; STORED = m.options; FP = m.fingerprint;
|
|
VALS = JSON.parse(JSON.stringify(m.options));
|
|
render();
|
|
showStaleness(m.running_differs, {running: m.running, stored: m.options});
|
|
document.getElementById('restart').disabled = false;
|
|
await validate();
|
|
}catch(e){
|
|
msg('cannot load the configuration: ' + e, 'bad');
|
|
}
|
|
}
|
|
|
|
async function save(){
|
|
msg('saving\\u2026');
|
|
document.getElementById('save').disabled = true;
|
|
var r, body;
|
|
try{
|
|
r = await fetch('config/save', {method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({options: VALS, fingerprint: FP})});
|
|
body = await r.json();
|
|
}catch(e){
|
|
msg('cannot reach the add-on: ' + e, 'bad');
|
|
document.getElementById('save').disabled = false;
|
|
return;
|
|
}
|
|
if(r.ok){
|
|
FP = body.fingerprint;
|
|
msg('saved - restart to apply', 'ok');
|
|
var host = document.getElementById('staleness');
|
|
host.textContent = '';
|
|
note(host, 'ok', 'Saved. Not applied yet.',
|
|
'The add-on is still running the settings it started with. Press '
|
|
+ '"Restart to apply" when you are ready to lose control of the '
|
|
+ 'battery for a few seconds.');
|
|
return;
|
|
}
|
|
if(body.kind === 'stale'){
|
|
var host2 = document.getElementById('staleness');
|
|
host2.textContent = '';
|
|
var d = note(host2, 'bad', 'Nothing was saved: these settings changed '
|
|
+ 'somewhere else while you were editing.',
|
|
'Somebody - most likely the Supervisor\\u2019s Configuration tab - '
|
|
+ 'wrote these settings after this page loaded. Your edits are still on '
|
|
+ 'screen and nothing has been overwritten. Read what changed, then '
|
|
+ 'press Save again if you still want your version.');
|
|
d.appendChild(el('div', 'diff', (body.changed || []).map(function(k){
|
|
return k + ': theirs ' + JSON.stringify(body.stored[k])
|
|
+ ' -> mine ' + JSON.stringify(VALS[k]);
|
|
}).join('\\n') || 'no key that this page also edits'));
|
|
FP = body.fingerprint;
|
|
msg('not saved - the stored settings had changed', 'bad');
|
|
document.getElementById('save').disabled = false;
|
|
return;
|
|
}
|
|
if(body.kind === 'invalid'){
|
|
showProblems(body);
|
|
msg('not saved - the configuration would not start', 'bad');
|
|
return;
|
|
}
|
|
msg('not saved: ' + (body.error || r.status), 'bad');
|
|
document.getElementById('save').disabled = false;
|
|
}
|
|
|
|
document.getElementById('save').addEventListener('click', save);
|
|
document.getElementById('reload').addEventListener('click', load);
|
|
document.getElementById('restart').addEventListener('click', function(){
|
|
document.getElementById('confirm').showModal();
|
|
});
|
|
document.getElementById('cancel').addEventListener('click', function(){
|
|
document.getElementById('confirm').close();
|
|
});
|
|
document.getElementById('go').addEventListener('click', async function(){
|
|
document.getElementById('confirm').close();
|
|
msg('restarting\\u2026');
|
|
try{
|
|
await fetch('config/restart', {method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({confirm: 'restart'})});
|
|
msg('restart requested - this page will stop answering for a moment');
|
|
}catch(e){
|
|
msg('restart requested - this page will stop answering for a moment');
|
|
}
|
|
});
|
|
load();
|
|
</script></body></html>
|
|
"""
|