Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
67970a84e9 fix(webui): give backup rows room, and part the id chip from Restore
The per-app backup list borrows the Tasks list's .task-* classes, whose
.task-header ships 4px of vertical padding. That is right for a dense log and
too tight for these rows, which carry a 28px app icon — the content sat hard
against the card edges. And the backup id is the last thing in .task-info with
.task-actions starting immediately after, so the hash and the Restore button
touched.

Both scoped to .backup-snapshot-item so the Tasks page keeps its density —
confirmed after the change: Tasks rows still compute 4px, backup rows 10px.
2026-08-20 22:47:56 +01:00
librelad
2510b1a52f feat(webui): green Upgrade, trailing picker, select-all, icon in confirms
Four fixes to the Updates tab, all reported from the same screenshot.

Upgrade is green and just says "Upgrade". It was accent-styled and carried the
target version, which duplicated the confirmation it opens — the dialog already
names the version, so the button only had to name the action. Green also stops
it reading as the same control as the cyan Update beside it.

The picker moved from the head of the row to the tail, matching the Tasks list.
It used to lead with an `ov-pick-empty` spacer holding the column open, so with
nothing updatable every row carried a left gutter for a checkbox that was never
coming — the gap in the report. Unselectable rows now render no picker at all.

Select all joins the toolbar, right-aligned above that column, again mirroring
Tasks. It acts on the selectable rows in view, so a narrowing filter chip cannot
leave it quietly picking rows nobody can see.

The confirmation dialog takes an optional icon and the upgrade flow passes the
app's, so it leads with the same icon-in-holder the instance modals use. Styled
in base.css rather than borrowing .app-card-icon: this dialog is global and
appears on pages that never load the apps stylesheet. Its title now uses the
display name too — "Upgrade matrix to…" beside the Matrix logo read as a
different thing.

Verified against the live WebUI: green Upgrade with the gutter gone; Select all
and the trailing checkboxes with two apps temporarily flagged (data restored
byte-identical after); the master toggling 0 -> 2 -> 0; and the dialog showing
matrix.svg under the title "Upgrade Matrix to v1.159.0?".
2026-08-20 22:47:56 +01:00
7 changed files with 129 additions and 11 deletions

View File

@ -194,6 +194,38 @@
padding: 0 6px;
}
.ov-toolbar-actions { display: flex; gap: 8px; }
/* Select-all, mirroring the Tasks toolbar: pushed to the right of the chips so
the picker column and its master control line up on the same edge. */
.ov-select-all {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.ov-select-all input {
width: 15px;
height: 15px;
accent-color: var(--accent);
cursor: pointer;
}
/* Trailing picker. No fixed width reserved: an unselectable row renders no
checkbox at all, so holding a column open would reintroduce the gutter this
replaced. */
.ov-pick {
display: inline-flex;
align-items: center;
margin-left: 4px;
}
.ov-pick-box {
width: 15px;
height: 15px;
accent-color: var(--accent);
cursor: pointer;
}
/* ---- Updates: expandable rows ------------------------------------------- */
/* Block (not the inherited .updater-row 3-col grid) so the head sits on top and

View File

@ -331,6 +331,21 @@ class OverviewManager {
this.selected.clear();
this._applyTab('updates');
return;
case 'select-all': {
// Acts on what is actually selectable and in view — the filter chips
// can narrow the list, and "select all" that quietly picked rows the
// user cannot see would be a worse answer than none.
const boxes = Array.from(document.querySelectorAll('#overview-view .ov-pick-box'));
const on = !!(oa.checked);
for (const b of boxes) {
b.checked = on;
const slug = b.dataset.app;
if (!slug) continue;
if (on) this.selected.add(slug); else this.selected.delete(slug);
}
this._syncSelectionBar();
break;
}
case 'update-selected':
if (this.updater) this.updater.applySelected([...this.selected]);
this.selected.clear();
@ -606,6 +621,15 @@ class OverviewManager {
return [...this.selected];
}
// True when every selectable row currently in view is picked — drives the
// master checkbox's checked state on render.
_allPicked() {
const up = this.updater;
if (!up) return false;
const avail = up.apps.filter((a) => a.update_available).map((a) => a.name);
return avail.length > 0 && avail.every((n) => this.selected.has(n));
}
// Update the bar in place. Re-rendering the whole tab on every tick would
// rebuild the checkboxes underneath the pointer and lose focus mid-selection.
_syncSelectionBar() {
@ -655,6 +679,10 @@ class OverviewManager {
${up.renderAutoCheckLine()}
<div class="updater-toolbar ov-toolbar">
<div class="ov-chips">${chip('all', 'All', up.apps.length)}${chip('updates', 'Updates', nUpd)}${chip('newer', 'Newer', nNew)}${chip('security', 'Security', nSec)}</div>
${nUpd ? `<label class="ov-select-all" title="Select every updatable app in view">
<input type="checkbox" data-overview-action="select-all"${this._allPicked() ? ' checked' : ''}>
<span>Select all</span>
</label>` : ''}
</div>
${nUpd ? this.renderSelectionBar() : ''}
<div class="updater-list ov-updates-list">${rows}</div>`;
@ -765,20 +793,25 @@ class OverviewManager {
const updBtn = a.update_available
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${slug}">Update</button>`
: (a.newer_version
? `<button class="updater-btn ov-upgrade-btn" data-updater-action="upgrade" data-app="${slug}" data-version="${esc(a.newer_version)}" title="Move to the ${esc(a.newer_version)} release line — you will be asked to confirm first">Upgrade to ${esc(a.newer_version)}</button>`
? `<button class="updater-btn updater-btn-success" data-updater-action="upgrade" data-app="${slug}" data-version="${esc(a.newer_version)}" title="Move to the ${esc(a.newer_version)} release line — you will be asked to confirm first">Upgrade</button>`
: '');
// Only rows with something to apply are selectable. A checkbox on a row
// that is already current would offer a choice with no outcome, and
// "selected 6, updated 2" is a worse answer than not offering the 4.
// Trailing, and absent rather than blank when the row is not selectable.
// It used to lead the row with an `ov-pick-empty` spacer holding the column
// open — so with nothing updatable, every row carried a left gutter for a
// checkbox that was never coming. Matches the Tasks list, where the picker
// sits last and unselectable rows simply have none.
const pick = a.update_available
? `<span class="ov-pick"><input type="checkbox" class="ov-pick-box" data-overview-action="select" data-app="${slug}"${this.selected.has(a.name) ? ' checked' : ''} aria-label="Select ${name} for update"></span>`
: '<span class="ov-pick ov-pick-empty" aria-hidden="true"></span>';
: '';
return `<div class="updater-row ov-row" data-app="${slug}">
<div class="updater-row-head ov-row-head" data-overview-action="toggle" data-app="${slug}" role="button" tabindex="0" aria-expanded="false" aria-controls="ov-detail-${slug}">
${pick}<img class="ov-row-icon" src="/core/icons/apps/${slug}.svg" alt="" onerror="this.style.display='none'">
<img class="ov-row-icon" src="/core/icons/apps/${slug}.svg" alt="" onerror="this.style.display='none'">
<span class="updater-row-name">${name}</span> ${status} ${sev} ${pol} ${newer} ${stale}
<span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span>
<span class="ov-row-actions">${updBtn}<button class="ov-details-btn" data-overview-action="toggle" data-app="${slug}" tabindex="-1" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"></polyline></svg><span>Details</span></button></span>
<span class="ov-row-actions">${updBtn}<button class="ov-details-btn" data-overview-action="toggle" data-app="${slug}" tabindex="-1" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"></polyline></svg><span>Details</span></button>${pick}</span>
</div>
<div class="updater-row-details ov-row-details" id="ov-detail-${slug}" role="region" aria-label="${name} details" hidden></div>
</div>`;

View File

@ -1355,6 +1355,18 @@
background: rgba(var(--text-rgb), 0.05);
border-radius: 4px;
letter-spacing: 0.02em;
/* The chip is the last thing in .task-info and .task-actions starts straight
after it, so without this the backup id and the Restore button touch. */
margin-right: 12px;
}
/* Roomier rows than the Tasks list they borrow .task-* from. .task-header ships
4px of vertical padding, which is right for a dense log of tasks and too tight
here: these rows carry a 28px app icon, so the content sat hard against the
card edges. Scoped to the snapshot item so the Tasks page keeps its density. */
.backup-snapshot-item .task-header {
padding-top: 10px;
padding-bottom: 10px;
}
.backup-snapshot-tag {
display: inline-block;

View File

@ -184,6 +184,17 @@
color: rgb(var(--page-rgb, var(--accent-rgb)));
}
.updater-btn-primary:hover { background: rgba(var(--page-rgb, var(--accent-rgb)), 0.3); }
/* Green, for moving to a newer release line. Distinct from the primary (accent)
Update so the two actions on a row never read as the same thing.
Pastel #86efac text on a saturated green rather than --status-success itself,
matching .app-tag.installed-tag: the raw #28a745 is too dark to read at button
size on dark themes. */
.updater-btn-success {
background: rgba(var(--status-success-rgb), 0.30);
border-color: rgba(var(--status-success-rgb), 0.65);
color: #86efac;
}
.updater-btn-success:hover { background: rgba(var(--status-success-rgb), 0.45); }
/* ---- Hints / empty states ---- */
.updater-hint { padding: 12px 15px; border-radius: 11px; margin-bottom: 14px; font-size: 0.84rem;

View File

@ -339,12 +339,15 @@ class UpdaterPage {
const a = this.apps.find((x) => x.name === app) || {};
const from = a.channel || a.current_version || 'the current version';
const to = version || a.newer_version || 'the newest release';
// Display name, not the slug: the dialog now leads with the app's icon, and
// "Upgrade matrix to…" beside the Matrix logo reads as a different thing.
const label = (window.getAppDisplayName ? window.getAppDisplayName(app) : null) || a.displayName || app;
const body = `
<div class="updater-detail-section">
<p><strong>${this.escape(app)}</strong> will move from <strong>${this.escape(from)}</strong>
<p><strong>${this.escape(label)}</strong> will move from <strong>${this.escape(from)}</strong>
to <strong>${this.escape(to)}</strong>, one release at a time.</p>
<p class="updater-detail-meta">Every step takes its own recovery snapshot first, then waits for
${this.escape(app)} to confirm it is serving that version with no migration outstanding.
${this.escape(label)} to confirm it is serving that version with no migration outstanding.
If any step fails it is rolled back and the upgrade stops there, leaving the app on the last
version that verified.</p>
<p class="updater-detail-meta">This can take a long time each release runs its own database
@ -353,8 +356,9 @@ class UpdaterPage {
const go = () => this.dispatch('updater_upgrade', { app, version: version || '' },
`Upgrading ${app} to ${to}, one release at a time…`);
if (window.showConfirmation) {
window.showConfirmation(`Upgrade ${app} to ${to}?`, '', go, 'Start upgrade', 'Cancel', 'warning', false, '', body);
} else if (window.confirm(`Upgrade ${app} from ${from} to ${to}, one release at a time?`)) {
window.showConfirmation(`Upgrade ${label} to ${to}?`, '', go, 'Start upgrade', 'Cancel', 'warning', false, '', body,
`/core/icons/apps/${app}.svg`);
} else if (window.confirm(`Upgrade ${label} from ${from} to ${to}, one release at a time?`)) {
go();
}
}

View File

@ -31,13 +31,14 @@ class ConfirmationDialog {
}
show(title, message, onConfirm, confirmText = 'Confirm', cancelText = 'Cancel', confirmClass = 'primary', showDataLossCheckbox = false, checkboxText = 'I understand I will lose all my data and it cannot be undone', messageHtml = '') {
show(title, message, onConfirm, confirmText = 'Confirm', cancelText = 'Cancel', confirmClass = 'primary', showDataLossCheckbox = false, checkboxText = 'I understand I will lose all my data and it cannot be undone', messageHtml = '', iconUrl = '') {
this.callback = onConfirm;
// Build dialog content
this.dialog.innerHTML = `
<div class="confirmation-header">
${iconUrl ? `<div class="confirmation-app-icon"><img src="${this.escapeHtml(iconUrl)}" alt="" onerror="this.onerror=null; this.src='/core/icons/apps/default.svg'"></div>` : ''}
<h3>${this.escapeHtml(title)}</h3>
<button class="confirmation-close" onclick="window.confirmationDialog.hide()">×</button>
</div>
@ -142,12 +143,12 @@ function initConfirmationDialog() {
// initConfirmationDialog() will be called centrally
// Global function
window.showConfirmation = (title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox, checkboxText, messageHtml) => {
window.showConfirmation = (title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox, checkboxText, messageHtml, iconUrl) => {
// Ensure dialog is initialized
initConfirmationDialog();
if (confirmationDialog) {
confirmationDialog.show(title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox, checkboxText, messageHtml);
confirmationDialog.show(title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox, checkboxText, messageHtml, iconUrl);
} else {
// Fallback to native confirm
if (confirm(message)) {

View File

@ -901,8 +901,33 @@ html[data-theme="nebula"]::after {
border-bottom: 1px solid var(--border-color, #444);
}
/* Icon holder, when a caller names an app. Styled here rather than reusing
.app-card-icon from apps.css: this dialog is global and appears on pages that
never load the apps stylesheet, where borrowing that class would render an
unstyled image. */
.confirmation-app-icon {
width: 40px;
height: 40px;
min-width: 40px;
padding: 7px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--text-rgb), 0.1);
border: 1px solid rgba(var(--text-rgb), 0.2);
}
.confirmation-app-icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.confirmation-header h3 {
margin: 0;
/* Takes the slack so the close button stays pinned right once an icon leads
the header the row is space-between, which would otherwise centre it. */
flex: 1;
color: var(--text-primary);
color: var(--text-primary, #fff);
font-size: 16px;