Merge claude/2

This commit is contained in:
librelad 2026-05-28 02:00:05 +01:00
commit 5432f46fd0
2 changed files with 93 additions and 47 deletions

View File

@ -111,21 +111,10 @@
<option value="">All locations</option> <option value="">All locations</option>
</select> </select>
</div> </div>
<div class="backup-snapshot-table-wrap"> <!-- Each backup is rendered by renderSnapshots() as a .task-item
<table class="backup-snapshot-table"> card, matching the per-app Backups tab so the two surfaces
<thead> share one visual language. -->
<tr> <div class="backup-snapshot-rows" id="backup-snapshot-list"></div>
<th>App</th>
<th>Host</th>
<th>Location</th>
<th>When</th>
<th>ID</th>
<th class="backup-col-actions">Actions</th>
</tr>
</thead>
<tbody id="backup-snapshot-tbody"></tbody>
</table>
</div>
</section> </section>
<section class="backup-tabpanel" id="backup-panel-locations"> <section class="backup-tabpanel" id="backup-panel-locations">

View File

@ -234,16 +234,28 @@ class BackupPage {
const restoreBtn = e.target.closest('[data-action="restore-snapshot"]'); const restoreBtn = e.target.closest('[data-action="restore-snapshot"]');
if (restoreBtn) { if (restoreBtn) {
e.stopPropagation(); // don't also toggle the row's details panel
this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot); this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot);
return; return;
} }
const deleteBtn = e.target.closest('[data-action="delete-snapshot"]'); const deleteBtn = e.target.closest('[data-action="delete-snapshot"]');
if (deleteBtn) { if (deleteBtn) {
e.stopPropagation();
this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot); this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot);
return; return;
} }
// Row header / Details button → toggle the .task-details panel.
// Matches the per-app Backups tab interaction.
const snapToggle = e.target.closest('[data-action="toggle-snapshot-row"]');
if (snapToggle) {
const item = snapToggle.closest('.backup-snapshot-item');
const details = item && item.querySelector('.task-details');
if (details) details.classList.toggle('task-details-open');
return;
}
const locEnable = e.target.closest('[data-action="toggle-location-enabled"]'); const locEnable = e.target.closest('[data-action="toggle-location-enabled"]');
if (locEnable) { if (locEnable) {
const cb = locEnable.querySelector('input[type="checkbox"]'); const cb = locEnable.querySelector('input[type="checkbox"]');
@ -1045,8 +1057,8 @@ class BackupPage {
} }
renderSnapshots() { renderSnapshots() {
const tbody = document.getElementById('backup-snapshot-tbody'); const list = document.getElementById('backup-snapshot-list');
if (!tbody) return; if (!list) return;
const filter = (document.getElementById('backup-snapshot-filter')?.value || '').toLowerCase(); const filter = (document.getElementById('backup-snapshot-filter')?.value || '').toLowerCase();
const locFilter = document.getElementById('backup-snapshot-repo')?.value || ''; const locFilter = document.getElementById('backup-snapshot-repo')?.value || '';
@ -1067,6 +1079,8 @@ class BackupPage {
locName: locNameByIdx[locIdx] || `Loc ${locIdx}`, locName: locNameByIdx[locIdx] || `Loc ${locIdx}`,
time: s.time, time: s.time,
id: s.short_id || (s.id || '').slice(0, 8), id: s.short_id || (s.id || '').slice(0, 8),
tags: Array.isArray(s.tags) ? s.tags : [],
paths: Array.isArray(s.paths) ? s.paths : [],
}); });
}); });
}); });
@ -1081,39 +1095,82 @@ class BackupPage {
) : rows; ) : rows;
if (!filtered.length) { if (!filtered.length) {
tbody.innerHTML = `<tr><td colspan="6" class="backup-empty-state">No backups yet.</td></tr>`; list.innerHTML = `<div class="backup-empty-state">No backups yet.</div>`;
return; return;
} }
tbody.innerHTML = filtered.map(r => { list.innerHTML = filtered.map(r => this._renderSnapshotRow(r)).join('');
// Link the App and ID cells to the per-app Backups tab with the }
// snapshot pre-expanded. "—" rows (snapshots without an
// app=<slug> tag, e.g. system config backups) stay plain text // Render one global-Snapshots-tab backup as the same .task-item card
// since there's no app page to open. // the per-app Backups tab uses, so the two surfaces look identical.
const hasApp = r.app && r.app !== '—'; // Extras vs the per-app card:
const deepLink = hasApp // - An app-name chip (because the global list isn't scoped to one app)
? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}` // that doubles as a deep-link to /app/<name>/backups?snapshot=<id>
: null; // - A Delete action alongside Restore (per-app card only offers
const appCell = hasApp // Restore — delete lives in the global view)
? `<a class="backup-snapshot-link" href="${this.escape(deepLink)}" data-deep-link="${this.escape(deepLink)}">${this.escape(r.app)}</a>` _renderSnapshotRow(r) {
: this.escape(r.app); const hasApp = r.app && r.app !== '—';
const idCell = hasApp const deepLink = hasApp
? `<a class="backup-snapshot-link backup-snapshot-id" href="${this.escape(deepLink)}" data-deep-link="${this.escape(deepLink)}">${this.escape(r.id)}</a>` ? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}`
: `<span class="backup-snapshot-id">${this.escape(r.id)}</span>`; : null;
return ` const iconUrl = hasApp ? `/icons/apps/${encodeURIComponent(r.app)}.svg` : '/icons/apps/libreportal.svg';
<tr> const displayName = hasApp ? this.appMeta(r.app).displayName : 'System config';
<td>${appCell}</td> const appChip = hasApp
<td>${this.escape(r.host)}</td> ? `<a class="backup-snapshot-link backup-snapshot-app-chip" href="${this.escape(deepLink)}" data-deep-link="${this.escape(deepLink)}" title="Open ${this.escape(displayName)} backups">${this.escape(displayName)}</a>`
<td>${this.escape(r.locName)}</td> : `<span class="backup-snapshot-app-chip">${this.escape(displayName)}</span>`;
<td>${this.formatRelative(r.time)}</td> const sid = String(r.id);
<td>${idCell}</td> return `
<td class="backup-col-actions"> <div class="task-item backup-snapshot-item" data-snapshot="${this.escape(sid)}" data-loc="${this.escape(String(r.locIdx))}">
<button class="backup-row-action-btn" data-action="restore-snapshot" data-app="${this.escape(r.app)}" data-loc="${r.locIdx}" data-snapshot="${this.escape(r.id)}">Restore</button> <div class="task-header" data-action="toggle-snapshot-row">
<button class="backup-row-action-btn danger" data-action="delete-snapshot" data-app="${this.escape(r.app)}" data-loc="${r.locIdx}" data-snapshot="${this.escape(r.id)}">Delete</button> <div class="task-info">
</td> <img src="${this.escape(iconUrl)}" alt="" class="task-app-icon" onerror="this.style.display='none'">
</tr> <span class="task-title">${this.escape(this.formatRelative(r.time))}</span>
`; ${appChip}
}).join(''); <span class="task-status backup-snapshot-loc-pill">${this.escape(r.locName)}</span>
<span class="task-time" title="${this.escape(this._fmtFullTime(r.time))}">${this.escape(this._fmtShortTime(r.time))}</span>
<span class="backup-snapshot-id-chip" title="Backup ID">${this.escape(sid)}</span>
</div>
<div class="task-actions">
<button class="task-btn" data-action="restore-snapshot" data-app="${this.escape(r.app)}" data-loc="${this.escape(String(r.locIdx))}" data-snapshot="${this.escape(sid)}" title="Restore from this backup">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v6h6"/></svg>
<span class="task-btn-label">Restore</span>
</button>
<button class="task-btn delete" data-action="delete-snapshot" data-app="${this.escape(r.app)}" data-loc="${this.escape(String(r.locIdx))}" data-snapshot="${this.escape(sid)}" title="Delete this backup">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6M14 11v6"></path></svg>
<span class="task-btn-label">Delete</span>
</button>
<button class="task-btn toggle-details" data-action="toggle-snapshot-row" title="Toggle details">
<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 class="task-btn-label">Details</span>
</button>
</div>
</div>
<div class="task-details">
<div class="task-meta">
<div class="meta-item"><strong>App:</strong> ${this.escape(displayName)}${hasApp ? ` (<code>${this.escape(r.app)}</code>)` : ''}</div>
<div class="meta-item"><strong>Backup ID:</strong> <code>${this.escape(sid)}</code></div>
<div class="meta-item"><strong>Location:</strong> ${this.escape(r.locName)}</div>
<div class="meta-item"><strong>When:</strong> ${this.escape(this._fmtFullTime(r.time))}</div>
<div class="meta-item"><strong>Host:</strong> ${this.escape(r.host)}</div>
${r.tags && r.tags.length ? `<div class="meta-item"><strong>Tags:</strong> ${r.tags.map(t => `<span class="backup-snapshot-tag">${this.escape(t)}</span>`).join(' ')}</div>` : ''}
${r.paths && r.paths.length ? `<div class="meta-item"><strong>Paths:</strong><br><code>${r.paths.map(p => this.escape(p)).join('<br>')}</code></div>` : ''}
</div>
</div>
</div>`;
}
_fmtShortTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return String(iso);
return d.toLocaleString();
}
_fmtFullTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return String(iso);
return d.toString();
} }
renderConfiguration() { renderConfiguration() {