Colour-coded config edits, match sorting, expanded by default

- Unsaved edits: yellow = changed, green = added, red = removed on save;
  removed rows stay visible with an undo button, with a legend by Save
- Current matches: sort by departure or newest first (remembered);
  expanded by default, collapsed state remembered per watch
- Status "since" is when a flight last became bookable; tooltips spell
  out seats left vs taken

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Måns SivertssonandClaude Opus 5.5 committed 2026-10-13 08:44:16 +02:00
1 parent cb3528d8b0
commit bb7a0e6a1b
2 files changed
+75 -35

No files matched your search

+7 -6
View File
@@ -60,15 +60,16 @@ Under the tables, **JSON – export / import / reset** holds the same config as
*Download .json* for a backup, *Load file…* or paste + *Apply JSON to tables* to restore, *Download .json* for a backup, *Load file…* or paste + *Apply JSON to tables* to restore,
*Reset to example* to start over. Nothing takes effect until you press **Save & restart**. *Reset to example* to start over. Nothing takes effect until you press **Save & restart**.
Fields that differ from the saved config are highlighted in yellow until you save or discard; new rows are Unsaved edits are colour-coded until you save or discard: **yellow** = changed field, **green** = new row,
highlighted in full. **red** = row that will be removed on save (click ↺ to keep it).
## Dashboard ## Dashboard
- **Current matches** per watch, collapsed to a summary row by default (click the header or the summary to expand): - **Current matches** per watch, sorted *By departure* or *Newest first* (most recently appeared seats on top).
Click a watch's header or summary row to collapse it; this is remembered across browser restarts:
- summary: number of flights, bookable seats per cabin with how many were lost in red (`Economy ×30 −6`), and - summary: number of flights, bookable seats per cabin with how many were lost in red (`Economy ×30 −6`), and
when the most recent flight appeared when the most recent flight appeared
- `Economy ×8 −2` – bookable now; the red number is how many seats were taken since the highest count seen - `Economy ×8 −2` – 8 seats left now; the red number is how many were taken since the highest count seen (10)
- red, struck through, *gone 12 okt 13:42* – sold out (or below your seat count); kept for two weeks or until departure - red, struck through, *gone 12 okt 13:42* – sold out (or below your seat count); kept for two weeks or until departure
- grey, *no matching return* – still has seats, but its return disappeared (with Both legs) - grey, *no matching return* – still has seats, but its return disappeared (with Both legs)
- **Recent changes**: `+` new / back, `▲` more seats, `▼` fewer seats, `−` gone, `○` lost its return - **Recent changes**: `+` new / back, `▲` more seats, `▼` fewer seats, `−` gone, `○` lost its return
@@ -103,7 +104,7 @@ Only one tab polls at a time, so opening the dashboard twice does not double the
The script header points Tampermonkey at this repo: The script header points Tampermonkey at this repo:
``` ```
// @version 1.2.0 // @version 1.3.0
// @updateURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js // @updateURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js
// @downloadURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js // @downloadURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js
``` ```
@@ -126,7 +127,7 @@ Requirements:
1. Edit `sas-bonus-watch.user.js`. 1. Edit `sas-bonus-watch.user.js`.
2. Run the tests: `node --test` (Node 20+). 2. Run the tests: `node --test` (Node 20+).
3. **Bump `@version`** (e.g. 1.2.0 → 1.2.1). Without this, nobody gets the update. 3. **Bump `@version`** (e.g. 1.3.0 → 1.3.1). Without this, nobody gets the update.
4. Commit and push to `main`. 4. Commit and push to `main`.
## Development ## Development
+68 -29
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name SAS bonus watch // @name SAS bonus watch
// @namespace sas-bonus-finder // @namespace sas-bonus-finder
// @version 1.2.0 // @version 1.3.0
// @description Watches SAS EuroBonus award seats from your own browser and alerts you when matching seats appear. // @description Watches SAS EuroBonus award seats from your own browser and alerts you when matching seats appear.
// @author Måns Sivertsson // @author Måns Sivertsson
// @homepageURL https://gitea.mscloud.site/mans/sas-bonus-watch // @homepageURL https://gitea.mscloud.site/mans/sas-bonus-watch
@@ -647,10 +647,23 @@
input.code { width: 8.5em; text-transform: uppercase; } input.name { width: 11em; } input.num { width: 4.2em; } input.url { width: 100%; min-width: 22em; } input.code { width: 8.5em; text-transform: uppercase; } input.name { width: 11em; } input.num { width: 4.2em; } input.url { width: 100%; min-width: 22em; }
label.cab { white-space: nowrap; margin-right: 6px; font-size: 13px; } label.cab { white-space: nowrap; margin-right: 6px; font-size: 13px; }
tr.off td { opacity: .45; } tr.off td:first-child { opacity: 1; } tr.off td { opacity: .45; } tr.off td:first-child { opacity: 1; }
.changed, tr.added td { background: color-mix(in srgb, var(--warn) 13%, transparent); } /* unsaved edits: yellow = changed, green = new row, red = row will be removed on save */
.changed { background: color-mix(in srgb, var(--warn) 13%, transparent); }
td.changed, label.changed { border-radius: 4px; } label.changed { padding: 2px 4px; } td.changed, label.changed { border-radius: 4px; } label.changed { padding: 2px 4px; }
.changed > input:not([type=checkbox]), .changed > select, tr.added input:not([type=checkbox]), tr.added select { .changed > input:not([type=checkbox]), .changed > select {
background: color-mix(in srgb, var(--warn) 15%, var(--input)); border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); } background: color-mix(in srgb, var(--warn) 15%, var(--input)); border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
tr.added td, tr.added td.changed { background: color-mix(in srgb, var(--ok) 13%, transparent); }
tr.added input:not([type=checkbox]), tr.added select, tr.added .changed > input:not([type=checkbox]), tr.added .changed > select {
background: color-mix(in srgb, var(--ok) 13%, var(--input)); border-color: color-mix(in srgb, var(--ok) 45%, var(--line)); }
tr.removed td, tr.removed td.changed { background: color-mix(in srgb, var(--bad) 13%, transparent); }
tr.removed input:not([type=checkbox]), tr.removed select { background: color-mix(in srgb, var(--bad) 10%, var(--input));
border-color: color-mix(in srgb, var(--bad) 40%, var(--line)); text-decoration: line-through; }
tr.removed label, tr.removed input { opacity: .75; }
tr.removed button.icon { color: var(--bad); }
.legend span { margin-right: 12px; } .legend i { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:4px; vertical-align:-1px; }
.seg { display:inline-flex; border:1px solid var(--line); border-radius:6px; overflow:hidden; vertical-align: 1px; margin-left: 10px; }
.seg button { border:0; border-radius:0; padding:2px 10px; font-size:12px; font-weight:400; }
.seg button.on { background: var(--acc); color:#fff; }
.toggle { cursor: pointer; user-select: none; } .toggle { cursor: pointer; user-select: none; }
.chev { display: inline-block; width: 1.1em; color: var(--muted); } .chev { display: inline-block; width: 1.1em; color: var(--muted); }
tr.sum td { font-weight: 600; } tr.sum td.muted { font-weight: 400; } tr.sum td { font-weight: 600; } tr.sum td.muted { font-weight: 400; }
@@ -677,24 +690,31 @@
h('div', { class: 'muted' }, h('div', { class: 'muted' },
`Last sweep ${t(status.lastCycle)} · next ${t(status.nextCycle)} · ${client.requests} request(s) since load · keep this tab open`)); `Last sweep ${t(status.lastCycle)} · next ${t(status.nextCycle)} · ${client.requests} request(s) since load · keep this tab open`));
live.pause.textContent = status.paused ? 'Resume' : 'Pause'; live.pause.textContent = status.paused ? 'Resume' : 'Pause';
live.sort.replaceChildren(...[['departure', 'By departure'], ['newest', 'Newest first']].map(([mode, label]) =>
h('button', { class: gmGet('matchSort', 'departure') === mode ? 'on' : null,
onclick: () => { gmSet('matchSort', mode); render(); } }, label)));
const when = (iso) => new Date(iso).toLocaleString('sv-SE', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); const when = (iso) => new Date(iso).toLocaleString('sv-SE', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
const order = { active: 0, unpaired: 1, gone: 2 }; const order = { active: 0, unpaired: 1, gone: 2 };
const collapsed = gmGet('collapsed', {}); // per watch; collapsed unless explicitly opened const collapsed = gmGet('collapsed', {}); // per watch, survives browser restarts; expanded unless collapsed
const sortMode = gmGet('matchSort', 'departure');
const sinceOf = (r) => r.since || r.first;
const byDeparture = (a, b) => (a.leg > b.leg ? -1 : a.leg < b.leg ? 1 : 0) || (a.date + a.time).localeCompare(b.date + b.time);
const byNewest = (a, b) => sinceOf(b).localeCompare(sinceOf(a)) || byDeparture(a, b);
live.matches.replaceChildren(...(cfg?.watches || []).map((w) => { live.matches.replaceChildren(...(cfg?.watches || []).map((w) => {
const rows = Object.values(state.matches?.[w.name] || {}).map((r) => ({ status: 'active', desc: r.line, peak: r.seats, ...r })) const rows = Object.values(state.matches?.[w.name] || {}).map((r) => ({ status: 'active', desc: r.line, peak: r.seats, ...r }))
.sort((a, b) => order[a.status] - order[b.status] || (a.leg > b.leg ? -1 : a.leg < b.leg ? 1 : 0) || .sort((a, b) => order[a.status] - order[b.status] || (sortMode === 'newest' ? byNewest(a, b) : byDeparture(a, b)));
(a.date + a.time).localeCompare(b.date + b.time)); const open = collapsed[w.name] !== true;
const open = collapsed[w.name] === false;
const toggle = () => { collapsed[w.name] = open; gmSet('collapsed', collapsed); render(); }; const toggle = () => { collapsed[w.name] = open; gmSet('collapsed', collapsed); render(); };
const seatsCell = (r) => h('td', { class: 'seats' }, const seatsCell = (r) => h('td', { class: 'seats' },
r.cabin ? `${CABINS[r.cabin]} ×${r.seats}` : '', r.cabin ? `${CABINS[r.cabin]} ×${r.seats}` : '',
r.status !== 'gone' && r.peak > r.seats ? h('span', { class: 'bad', title: `was ×${r.peak}` }, ` −${r.peak - r.seats}`) : null); r.status !== 'gone' && r.peak > r.seats
? h('span', { class: 'bad', title: `${r.seats} left · ${r.peak - r.seats} taken since ×${r.peak}` }, ` −${r.peak - r.seats}`) : null);
const statusCell = (r) => r.status === 'gone' const statusCell = (r) => r.status === 'gone'
? h('td', { class: 'bad' }, `gone ${when(r.since)}`) ? h('td', { class: 'bad' }, `gone ${when(r.since)}`)
: r.status === 'unpaired' : r.status === 'unpaired'
? h('td', { class: 'muted', title: 'Still has seats, but no return in your window fits with it' }, 'no matching return') ? h('td', { class: 'muted', title: 'Still has seats, but no return in your window fits with it' }, 'no matching return')
: h('td', { class: 'muted' }, `since ${when(r.first)}`); : h('td', { class: 'muted' }, `since ${when(sinceOf(r))}`);
// summary: bookable seats per cabin, and how many were lost (drops + sold-out flights) // summary: bookable seats per cabin, and how many were lost (drops + sold-out flights)
const byCabin = {}; const byCabin = {};
@@ -705,14 +725,15 @@
c.missed += r.status === 'gone' ? r.peak : r.peak - r.seats; c.missed += r.status === 'gone' ? r.peak : r.peak - r.seats;
} }
const count = (st) => rows.filter((r) => r.status === st).length; const count = (st) => rows.filter((r) => r.status === st).length;
const latest = rows.filter((r) => r.status === 'active').map((r) => r.since || r.first).sort().pop(); const latest = rows.filter((r) => r.status === 'active').map(sinceOf).sort().pop();
const summary = h('tr', { class: 'sum toggle', onclick: toggle, title: open ? 'Collapse' : 'Expand' }, const summary = h('tr', { class: 'sum toggle', onclick: toggle, title: open ? 'Collapse' : 'Expand' },
h('td', {}, h('span', { class: 'chev' }, open ? '▾' : '▸'), h('td', {}, h('span', { class: 'chev' }, open ? '▾' : '▸'),
`${rows.length} flight${rows.length === 1 ? '' : 's'} · ${count('active')} bookable` + `${rows.length} flight${rows.length === 1 ? '' : 's'} · ${count('active')} bookable` +
(count('gone') ? ` · ${count('gone')} gone` : '') + (count('unpaired') ? ` · ${count('unpaired')} without return` : '')), (count('gone') ? ` · ${count('gone')} gone` : '') + (count('unpaired') ? ` · ${count('unpaired')} without return` : '')),
h('td', { class: 'seats' }, Object.keys(CABINS).filter((c) => byCabin[c]).flatMap((c, i) => [ h('td', { class: 'seats' }, Object.keys(CABINS).filter((c) => byCabin[c]).flatMap((c, i) => [
i ? ' · ' : '', `${CABINS[c]} ×${byCabin[c].seats}`, i ? ' · ' : '', `${CABINS[c]} ×${byCabin[c].seats}`,
byCabin[c].missed > 0 ? h('span', { class: 'bad', title: 'seats taken or sold out since first seen' }, ` −${byCabin[c].missed}`) : null])), byCabin[c].missed > 0 ? h('span', { class: 'bad', title: `${byCabin[c].seats} bookable now · ${byCabin[c].missed} taken or sold out` },
` −${byCabin[c].missed}`) : null])),
h('td', { class: 'muted' }, latest ? `since ${when(latest)}` : '–')); h('td', { class: 'muted' }, latest ? `since ${when(latest)}` : '–'));
return h('div', { class: 'card', style: 'margin-bottom:10px' }, return h('div', { class: 'card', style: 'margin-bottom:10px' },
@@ -754,11 +775,15 @@
const saved = normalizeDraft(rawConfig); const saved = normalizeDraft(rawConfig);
const bound = new Map(); // input/cell -> [object, key], for highlighting unsaved fields const bound = new Map(); // input/cell -> [object, key], for highlighting unsaved fields
const ed = {}; const ed = {};
let draft, origOf, dirty = false; let draft, origOf, removed, dirty = false;
/** The draft without rows marked for removal: what Save stores and JSON shows. */
const effective = () => ({ ...draft, watches: draft.watches.filter((w) => !removed.has(w)),
notify: draft.notify.filter((n) => !removed.has(n)) });
/** Start editing `fresh`; remember which saved row each draft row came from (by position, or by name after an import). */ /** Start editing `fresh`; remember which saved row each draft row came from (by position, or by name after an import). */
function loadDraft(fresh, byName = false) { function loadDraft(fresh, byName = false) {
draft = fresh; draft = fresh;
removed = new Set();
origOf = new WeakMap([[draft.settings, saved.settings]]); origOf = new WeakMap([[draft.settings, saved.settings]]);
draft.watches.forEach((w, i) => { draft.watches.forEach((w, i) => {
const o = byName ? saved.watches.find((x) => x.name === w.name) : saved.watches[i]; const o = byName ? saved.watches.find((x) => x.name === w.name) : saved.watches[i];
@@ -774,11 +799,10 @@
const differs = (obj, key) => { const o = origOf.get(obj); return !o || canon(obj[key]) !== canon(o[key]); }; const differs = (obj, key) => { const o = origOf.get(obj); return !o || canon(obj[key]) !== canon(o[key]); };
function changed() { function changed() {
dirty = canon(draft) !== canon(saved); dirty = canon(effective()) !== canon(saved);
ed.save.disabled = ed.discard.disabled = !dirty; ed.save.disabled = ed.discard.disabled = !dirty;
const kept = [...draft.watches, ...draft.notify].filter((x) => origOf.has(x)).length; ed.dirtyNote.textContent = dirty ? 'Unsaved changes' : '';
const removed = saved.watches.length + saved.notify.length - kept; ed.legend.hidden = !dirty;
ed.dirtyNote.textContent = dirty ? `Unsaved changes${removed > 0 ? ` · ${removed} row${removed > 1 ? 's' : ''} removed` : ''}` : '';
if (dirty) ed.errors.textContent = ''; if (dirty) ed.errors.textContent = '';
for (const [el, [obj, key]] of bound) { for (const [el, [obj, key]] of bound) {
if (!el.isConnected) { bound.delete(el); continue; } if (!el.isConnected) { bound.delete(el); continue; }
@@ -792,9 +816,19 @@
}); });
const optInt = (v) => (v === '' ? null : Number(v)); const optInt = (v) => (v === '' ? null : Number(v));
const codes = (v) => String(v).split(/[\s,;/]+/).map((c) => c.trim().toUpperCase()).filter(Boolean); const codes = (v) => String(v).split(/[\s,;/]+/).map((c) => c.trim().toUpperCase()).filter(Boolean);
const removeBtn = (list, i, title) => h('button', { class: 'icon', title, onclick: () => { list.splice(i, 1); renderEditor(); } }, '✕'); /** New rows are dropped at once; saved rows are marked red and only removed on save (click again to undo). */
const removeBtn = (list, obj, what) => h('button', {
class: 'icon', title: removed.has(obj) ? `Keep this ${what}` : `Remove ${what}`,
onclick: () => {
if (!origOf.has(obj)) list.splice(list.indexOf(obj), 1);
else if (removed.has(obj)) removed.delete(obj);
else removed.add(obj);
renderEditor();
} }, removed.has(obj) ? '↺' : '✕');
const rowClass = (obj, extra = '') =>
[extra, removed.has(obj) ? 'removed' : origOf.has(obj) ? '' : 'added'].join(' ').trim() || null;
function watchRow(w, i) { function watchRow(w) {
const cabinBoxes = Object.entries(CABIN_KEYS).map(([code, key]) => h('label', { class: 'cab' }, const cabinBoxes = Object.entries(CABIN_KEYS).map(([code, key]) => h('label', { class: 'cab' },
h('input', { type: 'checkbox', checked: (w.cabins || []).includes(key), onchange: (e) => { h('input', { type: 'checkbox', checked: (w.cabins || []).includes(key), onchange: (e) => {
const set = new Set(w.cabins || []); const set = new Set(w.cabins || []);
@@ -802,7 +836,7 @@
w.cabins = Object.values(CABIN_KEYS).filter((c) => set.has(c)); w.cabins = Object.values(CABIN_KEYS).filter((c) => set.has(c));
changed(); changed();
} }), ` ${CABINS[code]}`)); } }), ` ${CABINS[code]}`));
const tr = h('tr', { class: [w.enabled === false ? 'off' : '', origOf.has(w) ? '' : 'added'].join(' ').trim() || null }, const tr = h('tr', { class: rowClass(w, w.enabled === false ? 'off' : '') },
h('td', {}, h('input', { type: 'checkbox', title: 'Enabled', checked: w.enabled !== false, $bind: [w, 'enabled'], h('td', {}, h('input', { type: 'checkbox', title: 'Enabled', checked: w.enabled !== false, $bind: [w, 'enabled'],
onchange: (e) => { w.enabled = e.target.checked; tr.classList.toggle('off', !w.enabled); changed(); } })), onchange: (e) => { w.enabled = e.target.checked; tr.classList.toggle('off', !w.enabled); changed(); } })),
h('td', {}, h('input', { class: 'name', value: w.name || '', placeholder: 'Name', ...bind(w, 'name') })), h('td', {}, h('input', { class: 'name', value: w.name || '', placeholder: 'Name', ...bind(w, 'name') })),
@@ -821,18 +855,18 @@
h('input', { class: 'num', type: 'number', min: 0, placeholder: 'min', value: w.min_nights ?? '', ...bind(w, 'min_nights', optInt) }), h('input', { class: 'num', type: 'number', min: 0, placeholder: 'min', value: w.min_nights ?? '', ...bind(w, 'min_nights', optInt) }),
' – ', ' – ',
h('input', { class: 'num', type: 'number', min: 0, placeholder: 'max', value: w.max_nights ?? '', ...bind(w, 'max_nights', optInt) })), h('input', { class: 'num', type: 'number', min: 0, placeholder: 'max', value: w.max_nights ?? '', ...bind(w, 'max_nights', optInt) })),
h('td', {}, removeBtn(draft.watches, i, 'Remove watch'))); h('td', {}, removeBtn(draft.watches, w, 'watch')));
return tr; return tr;
} }
function notifyRow(n, i) { function notifyRow(n) {
return h('tr', { class: origOf.has(n) ? null : 'added' }, return h('tr', { class: rowClass(n) },
h('td', {}, h('select', { ...bind(n, 'type') }, ['ntfy', 'discord', 'slack', 'webhook'].map((t) => h('td', {}, h('select', { ...bind(n, 'type') }, ['ntfy', 'discord', 'slack', 'webhook'].map((t) =>
h('option', { value: t, selected: n.type === t }, t)))), h('option', { value: t, selected: n.type === t }, t)))),
h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'https://ntfy.sh/your-topic', ...bind(n, 'url') })), h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'https://ntfy.sh/your-topic', ...bind(n, 'url') })),
h('td', {}, h('select', { title: 'Priority (ntfy only)', ...bind(n, 'priority') }, ['', 'low', 'default', 'high', 'urgent'].map((p) => h('td', {}, h('select', { title: 'Priority (ntfy only)', ...bind(n, 'priority') }, ['', 'low', 'default', 'high', 'urgent'].map((p) =>
h('option', { value: p, selected: (n.priority || '') === p }, p || '–')))), h('option', { value: p, selected: (n.priority || '') === p }, p || '–')))),
h('td', {}, removeBtn(draft.notify, i, 'Remove notification'))); h('td', {}, removeBtn(draft.notify, n, 'notification')));
} }
function renderEditor() { function renderEditor() {
@@ -891,7 +925,7 @@
if (f) { json.value = await f.text(); apply(); } if (f) { json.value = await f.text(); apply(); }
e.target.value = ''; e.target.value = '';
} }); } });
return h('details', { ontoggle: (e) => { if (e.target.open) json.value = JSON.stringify(draft, null, 2); } }, return h('details', { ontoggle: (e) => { if (e.target.open) json.value = JSON.stringify(effective(), null, 2); } },
h('summary', {}, 'JSON – export / import / reset'), h('summary', {}, 'JSON – export / import / reset'),
h('div', { class: 'small muted', style: 'margin-bottom:6px' }, h('div', { class: 'small muted', style: 'margin-bottom:6px' },
'The config as currently edited above. Edit or paste JSON and apply it to the tables, or save it to a file as a backup.'), 'The config as currently edited above. Edit or paste JSON and apply it to the tables, or save it to a file as a backup.'),
@@ -899,7 +933,7 @@
h('div', { class: 'btns' }, h('div', { class: 'btns' },
h('button', { onclick: apply }, 'Apply JSON to tables'), h('button', { onclick: apply }, 'Apply JSON to tables'),
h('button', { onclick: () => { h('button', { onclick: () => {
const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(draft, null, 2)], { type: 'application/json' })), const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(effective(), null, 2)], { type: 'application/json' })),
download: `sas-bonus-watch-${todayISO()}.json` }); download: `sas-bonus-watch-${todayISO()}.json` });
a.click(); a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
@@ -918,6 +952,7 @@
live.matches = h('div'); live.matches = h('div');
live.events = h('pre'); live.events = h('pre');
live.log = h('pre'); live.log = h('pre');
live.sort = h('span', { class: 'seg', title: 'Sort flights' });
live.pause = h('button', { onclick: () => { live.pause = h('button', { onclick: () => {
status.paused = !status.paused; status.paused = !status.paused;
gmSet('paused', status.paused); gmSet('paused', status.paused);
@@ -928,10 +963,14 @@
ed.tables = h('div'); ed.tables = h('div');
ed.errors = h('div', { class: 'err' }, cfgError ? `The saved config has problems:\n${cfgError}` : ''); ed.errors = h('div', { class: 'err' }, cfgError ? `The saved config has problems:\n${cfgError}` : '');
ed.dirtyNote = h('span', { class: 'warn small' }); ed.dirtyNote = h('span', { class: 'warn small' });
ed.legend = h('span', { class: 'legend small muted', hidden: true },
h('span', {}, h('i', { style: 'background:color-mix(in srgb, var(--warn) 45%, transparent)' }), 'changed'),
h('span', {}, h('i', { style: 'background:color-mix(in srgb, var(--ok) 45%, transparent)' }), 'added'),
h('span', {}, h('i', { style: 'background:color-mix(in srgb, var(--bad) 45%, transparent)' }), 'removed on save'));
ed.save = h('button', { class: 'primary', disabled: true, onclick: () => { ed.save = h('button', { class: 'primary', disabled: true, onclick: () => {
try { try {
validateConfig(draft); validateConfig(effective());
gmSet('config', draft); gmSet('config', effective());
dirty = false; dirty = false;
location.reload(); location.reload();
} catch (e) { ed.errors.textContent = e.message; } } catch (e) { ed.errors.textContent = e.message; }
@@ -953,10 +992,10 @@
'Baseline (no alerts)'), 'Baseline (no alerts)'),
h('button', { onclick: () => { if (confirm('Forget seen matches and cached data?')) { gmSet('state', {}); location.reload(); } } }, h('button', { onclick: () => { if (confirm('Forget seen matches and cached data?')) { gmSet('state', {}); location.reload(); } } },
'Reset state')), 'Reset state')),
h('h2', {}, 'Current matches'), live.matches, h('h2', {}, 'Current matches', live.sort), live.matches,
h('h2', {}, 'Config'), h('h2', {}, 'Config'),
h('div', { class: 'card' }, ed.tables, h('div', { class: 'card' }, ed.tables,
h('div', { class: 'btns', style: 'margin-top:16px' }, ed.save, ed.discard, ed.dirtyNote), h('div', { class: 'btns', style: 'margin-top:16px' }, ed.save, ed.discard, ed.dirtyNote, ed.legend),
ed.errors, ed.errors,
jsonSection()), jsonSection()),
h('h2', {}, 'Recent changes'), h('div', { class: 'card' }, live.events), h('h2', {}, 'Recent changes'), h('div', { class: 'card' }, live.events),