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>
145 lines
8.5 KiB
JavaScript
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' });
|
|
},
|
|
});
|