librelad 82989069e2 refactor(backup): decompose backup-page god-file into 13 responsibility files
Faithful prototype-augment split of backup-page.js (2353->753 line base) into
fetch-client, dashboard, snapshots, locations, location-fields, ssh-key,
retention-presets, configuration, engine-details, location-modal,
snapshot-actions, migrate (+ the earlier cron-schedule). Methods relocated
verbatim (mechanical sed/awk extraction, no logic change); all augment
BackupPage.prototype and load after the base via the ordered kernel loader.
Verified: all 99 original methods present exactly once across base+clusters,
no duplicates, all 14 files node --check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 14:02:45 +01:00

145 lines
8.5 KiB
JavaScript

// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
Object.assign(BackupPage.prototype, {
renderSnapshots() {
const list = document.getElementById('backup-snapshot-list');
if (!list) return;
const filter = (document.getElementById('backup-snapshot-filter')?.value || '').toLowerCase();
const locFilter = document.getElementById('backup-snapshot-repo')?.value || '';
const locNameByIdx = {};
(this.locations?.locations || []).forEach(l => { locNameByIdx[l.idx] = l.name; });
const rows = [];
Object.entries(this.snapshotsByLoc).forEach(([locIdx, data]) => {
if (locFilter && String(locFilter) !== String(locIdx)) return;
const snaps = Array.isArray(data?.snapshots) ? data.snapshots : [];
snaps.forEach(s => {
const app = (s.tags || []).map(t => /^app=/.test(t) ? t.slice(4) : null).find(Boolean) || '';
rows.push({
app,
host: s.hostname || '—',
locIdx,
locName: locNameByIdx[locIdx] || `Loc ${locIdx}`,
time: s.time,
id: s.short_id || (s.id || '').slice(0, 8),
tags: Array.isArray(s.tags) ? s.tags : [],
paths: Array.isArray(s.paths) ? s.paths : [],
});
});
});
rows.sort((a, b) => String(b.time).localeCompare(String(a.time)));
const filtered = filter ? rows.filter(r =>
r.app.toLowerCase().includes(filter) ||
r.host.toLowerCase().includes(filter) ||
r.id.toLowerCase().includes(filter) ||
r.locName.toLowerCase().includes(filter)
) : rows;
if (!filtered.length) {
list.innerHTML = `<div class="backup-empty-state">No backups yet.</div>`;
return;
}
list.innerHTML = filtered.map(r => this._renderSnapshotRow(r)).join('');
},
// Render one global-Snapshots-tab backup as the same .task-item card
// the per-app Backups tab uses, so the two surfaces look identical.
// Extras vs the per-app card:
// - An app-name chip (because the global list isn't scoped to one app)
// that doubles as a deep-link to /app/<name>/backups?snapshot=<id>
// - A Delete action alongside Restore (per-app card only offers
// Restore — delete lives in the global view)
_renderSnapshotRow(r) {
const hasApp = r.app && r.app !== '—';
const deepLink = hasApp
? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}`
: null;
const iconUrl = hasApp ? `/core/icons/apps/${encodeURIComponent(r.app)}.svg` : '/core/icons/apps/libreportal.svg';
const displayName = hasApp ? this.appMeta(r.app).displayName : 'Configs';
const appChip = hasApp
? `<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>`
: `<span class="backup-snapshot-app-chip">${this.escape(displayName)}</span>`;
const sid = String(r.id);
// Restic stamps app=/host=/engine= into the snapshot tags; surface
// those as their own fields and keep any remaining tags as chips.
const tagMap = {};
(r.tags || []).forEach(t => { const i = t.indexOf('='); if (i > 0) tagMap[t.slice(0, i)] = t.slice(i + 1); });
const engineName = tagMap.engine ? this.engineDisplayName(tagMap.engine) : null;
const otherTags = (r.tags || []).filter(t => !/^(app|host|engine|paths?)=/.test(t));
const field = (label, valueHtml) =>
`<div class="bsm-field"><span class="bsm-label">${label}</span><span class="bsm-value">${valueHtml}</span></div>`;
return `
<div class="task-item backup-snapshot-item" data-snapshot="${this.escape(sid)}" data-loc="${this.escape(String(r.locIdx))}">
<div class="task-header" data-action="toggle-snapshot-row">
<div class="task-info">
<img src="${this.escape(iconUrl)}" alt="" class="task-app-icon" onerror="this.style.display='none'">
<span class="task-title">${this.escape(this.formatRelative(r.time))}</span>
${appChip}
<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="backup-snapshot-meta">
<div class="bsm-grid">
${field('App', `${this.escape(displayName)}${hasApp ? ` <code>${this.escape(r.app)}</code>` : ''}`)}
${field('Host', this.escape(r.host))}
${field('Location', `<span class="backup-snapshot-loc-pill">${this.escape(r.locName)}</span>`)}
${field('Backup ID', `<code>${this.escape(sid)}</code>`)}
${field('When', `<span title="${this.escape(this._fmtFullTime(r.time))}">${this.escape(this._fmtNiceTime(r.time))}</span>`)}
${engineName ? field('Engine', this.escape(engineName)) : ''}
</div>
${otherTags.length ? `
<div class="bsm-block">
<span class="bsm-label">Tags</span>
<div class="bsm-tags">${otherTags.map(t => `<span class="backup-snapshot-tag">${this.escape(t)}</span>`).join('')}</div>
</div>` : ''}
${r.paths && r.paths.length ? `
<div class="bsm-block">
<span class="bsm-label">Paths</span>
<ul class="bsm-paths">${r.paths.map(p => `<li><code>${this.escape(p)}</code></li>`).join('')}</ul>
</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();
},
_fmtNiceTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return String(iso);
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
},
});