F1: every password option is shown as ******** (set) or empty in /config/model (options, running, ui) and in a stale refusal; the classic page draws it as a password field; a save or validate that sends ******** or nothing keeps the stored value. F2: a static_profile beside price_service is said once at start (notification and log), not refused. F3 (owner, 2026-10-14): the no-price row, Home line and notification only while maintenance_enabled is on. F4: tests for the reviewer's R01 and R10. F5: price_service_token must be 16+ characters. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
567 lines
23 KiB
Python
567 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 if(field.type === 'password'){
|
|
input.type = 'password';
|
|
input.autocomplete = 'new-password';
|
|
input.autocapitalize = 'none';
|
|
input.spellcheck = false;
|
|
} 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>
|
|
"""
|