Merge claude/2
This commit is contained in:
commit
2de82f4b2e
@ -1211,3 +1211,117 @@
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, rgba(var(--text-rgb), 0.65));
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Per-app Backups tab — Services-style snapshot rows.
|
||||
Each row is a .task-item + .task-header + .task-details so it
|
||||
inherits the global task-list visual (shared with services). The
|
||||
only backup-specific styling is the location pill colour, the
|
||||
ID chip, the deep-link highlight flash, and the inline tags.
|
||||
============================================================ */
|
||||
.backup-snapshot-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.backup-snapshot-loc-pill {
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
color: var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 2px 9px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.backup-snapshot-id-chip {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.72rem;
|
||||
color: rgba(var(--text-rgb), 0.55);
|
||||
padding: 1px 6px;
|
||||
background: rgba(var(--text-rgb), 0.05);
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.backup-snapshot-tag {
|
||||
display: inline-block;
|
||||
margin: 0 4px 2px 0;
|
||||
padding: 1px 6px;
|
||||
background: rgba(var(--text-rgb), 0.06);
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.72rem;
|
||||
color: rgba(var(--text-rgb), 0.7);
|
||||
}
|
||||
.backup-snapshot-overflow {
|
||||
margin-top: 10px;
|
||||
font-size: 0.78rem;
|
||||
color: rgba(var(--text-rgb), 0.5);
|
||||
text-align: center;
|
||||
}
|
||||
.backup-snapshot-overflow a { color: var(--accent); }
|
||||
|
||||
/* Deep-link arrival: ?snapshot=<id> flashes the row briefly so the
|
||||
user's eye lands on the right thing after the SPA jump. */
|
||||
.backup-snapshot-flash {
|
||||
animation: backup-snapshot-flash 2.2s ease-out;
|
||||
}
|
||||
@keyframes backup-snapshot-flash {
|
||||
0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55); }
|
||||
20% { box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.55); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.0); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Global Backup dashboard — app tile + "Back up" action pill.
|
||||
The whole tile navigates to the per-app Backups tab; the pill
|
||||
is the explicit affordance for the old "open the pick modal"
|
||||
behaviour. Hidden by default; visible on hover/focus so the
|
||||
tile stays calm at rest.
|
||||
============================================================ */
|
||||
.backup-app-tile {
|
||||
position: relative;
|
||||
}
|
||||
.backup-app-tile-action {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.14);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.4);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity .15s ease, transform .15s ease, background .15s ease;
|
||||
}
|
||||
.backup-app-tile:hover .backup-app-tile-action,
|
||||
.backup-app-tile:focus-within .backup-app-tile-action {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.backup-app-tile-action:hover {
|
||||
background: rgba(var(--accent-rgb), 0.28);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Global Snapshots table — App + ID cells link to the per-app
|
||||
page deep-linked to that snapshot.
|
||||
============================================================ */
|
||||
.backup-snapshot-link {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed rgba(var(--accent-rgb), 0.4);
|
||||
transition: color .15s ease, border-color .15s ease;
|
||||
}
|
||||
.backup-snapshot-link:hover {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
a.backup-snapshot-link.backup-snapshot-id {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
// Per-app backup card — used inside the app detail "Backups" tab.
|
||||
// Lightweight view that lists the app's snapshots across all enabled repos and
|
||||
// offers Backup Now + per-snapshot restore. For full management (delete,
|
||||
// migrate, schedule overrides) the user follows the link to /backup.
|
||||
// Per-app backup view — Services-tab-style list inside the app detail
|
||||
// "Backups" tab. Each snapshot is a collapsible row (status dot, location
|
||||
// pill, "when" chip, ID chip, action buttons + an expandable detail block
|
||||
// matching the same .task-item visual pattern the Services tab uses).
|
||||
//
|
||||
// Deep-link contract: /app/<name>/backups?snapshot=<id> auto-expands and
|
||||
// scrolls to that row, so the global Snapshots table on /backup can jump
|
||||
// straight to a specific backup on its app's page.
|
||||
|
||||
class BackupAppCard {
|
||||
constructor(appName) {
|
||||
@ -28,7 +32,20 @@ class BackupAppCard {
|
||||
|
||||
const restoreBtn = e.target.closest('[data-action="restore-app-snapshot"]');
|
||||
if (restoreBtn) {
|
||||
e.stopPropagation(); // don't also collapse/expand the row
|
||||
card.restoreSnapshot(restoreBtn.dataset.loc, restoreBtn.dataset.snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
// Header click → toggle the detail panel. Mirrors the
|
||||
// .task-header click target Services uses.
|
||||
const header = e.target.closest('.backup-snapshot-item .task-header');
|
||||
if (header) {
|
||||
const item = header.closest('.backup-snapshot-item');
|
||||
if (item) {
|
||||
const details = item.querySelector('.task-details');
|
||||
if (details) details.classList.toggle('task-details-open');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -58,30 +75,81 @@ class BackupAppCard {
|
||||
<span class="backup-card-hint">${allSnaps.length} total across ${locCount} location${locCount === 1 ? '' : 's'}</span>
|
||||
`;
|
||||
|
||||
const iconUrl = `/icons/apps/${encodeURIComponent(this.appName)}.svg`;
|
||||
snapsEl.innerHTML = `
|
||||
<table class="backup-snapshot-table" style="margin-top:14px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Location</th>
|
||||
<th>When</th>
|
||||
<th>ID</th>
|
||||
<th class="backup-col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${allSnaps.slice(0, 15).map(s => `
|
||||
<tr>
|
||||
<td>${this.escape(s.locName)}</td>
|
||||
<td>${this.formatRelative(s.time)}</td>
|
||||
<td class="backup-snapshot-id">${this.escape(s.id)}</td>
|
||||
<td class="backup-col-actions">
|
||||
<button class="backup-row-action-btn" data-action="restore-app-snapshot" data-loc="${s.locIdx}" data-snapshot="${this.escape(s.id)}">Restore</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="backup-snapshot-rows">
|
||||
${allSnaps.slice(0, 50).map(s => this._renderRow(s, iconUrl)).join('')}
|
||||
</div>
|
||||
${allSnaps.length > 50 ? `<div class="backup-snapshot-overflow">Showing the most recent 50 of ${allSnaps.length} snapshots. Use the <a href="/backup">backup center</a> for the full list.</div>` : ''}
|
||||
`;
|
||||
|
||||
// Deep-link: /app/<name>/backups?snapshot=<id> auto-expands that row
|
||||
// and scrolls it into view, briefly flashing the highlight class so
|
||||
// the user's eye lands on the right thing.
|
||||
this._honorSnapshotDeepLink();
|
||||
}
|
||||
|
||||
_renderRow(s, iconUrl) {
|
||||
const sid = String(s.id || '');
|
||||
return `
|
||||
<div class="task-item backup-snapshot-item" data-snapshot="${this.escape(sid)}" data-loc="${this.escape(String(s.locIdx))}">
|
||||
<div class="task-header">
|
||||
<div class="task-info">
|
||||
<img src="${iconUrl}" alt="${this.escape(this.appName)}" class="task-app-icon" onerror="this.style.display='none'">
|
||||
<span class="task-title">${this.escape(this.formatRelative(s.time))}</span>
|
||||
<span class="task-status backup-snapshot-loc-pill">${this.escape(s.locName)}</span>
|
||||
<span class="task-time" title="${this.escape(this._fmtFull(s.time))}">${this.escape(this._fmtShort(s.time))}</span>
|
||||
<span class="backup-snapshot-id-chip" title="Snapshot ID">${this.escape(sid)}</span>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="task-btn" data-action="restore-app-snapshot" data-loc="${this.escape(String(s.locIdx))}" data-snapshot="${this.escape(sid)}" title="Restore from this snapshot">
|
||||
<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 toggle-details" data-action="toggle-snapshot" 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>Snapshot ID:</strong> <code>${this.escape(sid)}</code></div>
|
||||
<div class="meta-item"><strong>Location:</strong> ${this.escape(s.locName)}</div>
|
||||
<div class="meta-item"><strong>When:</strong> ${this.escape(this._fmtFull(s.time))}</div>
|
||||
${s.hostname ? `<div class="meta-item"><strong>Host:</strong> ${this.escape(s.hostname)}</div>` : ''}
|
||||
${s.tags && s.tags.length ? `<div class="meta-item"><strong>Tags:</strong> ${s.tags.map(t => `<span class="backup-snapshot-tag">${this.escape(t)}</span>`).join(' ')}</div>` : ''}
|
||||
${s.paths && s.paths.length ? `<div class="meta-item"><strong>Paths:</strong><br><code>${s.paths.map(p => this.escape(p)).join('<br>')}</code></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_honorSnapshotDeepLink() {
|
||||
const want = new URLSearchParams(window.location.search).get('snapshot');
|
||||
if (!want) return;
|
||||
const row = document.querySelector(`.backup-snapshot-item[data-snapshot="${CSS.escape(want)}"]`);
|
||||
if (!row) return;
|
||||
const details = row.querySelector('.task-details');
|
||||
if (details && !details.classList.contains('task-details-open')) details.classList.add('task-details-open');
|
||||
setTimeout(() => {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
row.classList.add('backup-snapshot-flash');
|
||||
setTimeout(() => row.classList.remove('backup-snapshot-flash'), 2200);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
_fmtShort(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
_fmtFull(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toString();
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
@ -118,7 +186,10 @@ class BackupAppCard {
|
||||
locIdx,
|
||||
locName: this.locationsByIdx[locIdx]?.name || `Location ${locIdx}`,
|
||||
time: s.time,
|
||||
id: s.short_id || (s.id || '').slice(0, 8)
|
||||
id: s.short_id || (s.id || '').slice(0, 8),
|
||||
hostname: s.hostname || '',
|
||||
tags,
|
||||
paths: Array.isArray(s.paths) ? s.paths : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -186,15 +186,43 @@ class BackupPage {
|
||||
return;
|
||||
}
|
||||
|
||||
// Click any tile on the dashboard → open the Back-up checklist
|
||||
// modal with that tile pre-ticked. System tile is data-system="1";
|
||||
// app tiles carry data-app="<slug>". Both share .backup-app-tile.
|
||||
// Deep-link from the Snapshots table → /app/<name>/backups?snapshot=<id>.
|
||||
// Routed via the SPA so the app page mounts in-place rather than a
|
||||
// full reload.
|
||||
const deepLink = e.target.closest('[data-deep-link]');
|
||||
if (deepLink) {
|
||||
e.preventDefault();
|
||||
if (window.navigateToRoute) window.navigateToRoute(deepLink.dataset.deepLink);
|
||||
else window.location.href = deepLink.dataset.deepLink;
|
||||
return;
|
||||
}
|
||||
|
||||
// Tile actions, in priority order:
|
||||
// 1. "Backup now" pill on the tile → opens the pick modal
|
||||
// preticked with that tile (explicit affordance, replaces
|
||||
// the old implicit whole-tile click).
|
||||
// 2. Whole-tile click → navigates to the per-app Backups tab
|
||||
// (or the system page for the System tile). This is the
|
||||
// cohesion fix: each tile leads to the page that owns
|
||||
// that subject's full detail, not a modal asking "do
|
||||
// you want to back up?".
|
||||
const backupNowBtn = e.target.closest('[data-action="backup-now"]');
|
||||
if (backupNowBtn) {
|
||||
e.stopPropagation();
|
||||
if (backupNowBtn.dataset.system) {
|
||||
this.openBackupPickModal({ preTickSystem: true });
|
||||
} else if (backupNowBtn.dataset.app) {
|
||||
this.openBackupPickModal({ preTickApps: [backupNowBtn.dataset.app] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tile = e.target.closest('.backup-app-tile');
|
||||
if (tile) {
|
||||
if (tile.dataset.system) {
|
||||
// System has no dedicated page yet — keep the pick modal.
|
||||
this.openBackupPickModal({ preTickSystem: true });
|
||||
} else if (tile.dataset.app) {
|
||||
this.openBackupPickModal({ preTickApps: [tile.dataset.app] });
|
||||
} else if (tile.dataset.app && window.navigateToRoute) {
|
||||
window.navigateToRoute(`/app/${encodeURIComponent(tile.dataset.app)}/backups`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -643,7 +671,7 @@ class BackupPage {
|
||||
const dot = has ? 'ok' : 'none';
|
||||
const when = has ? 'Last backed up ' + this.formatRelative(sys.latest_time) : 'No backup yet';
|
||||
return `
|
||||
<div class="backup-app-tile backup-app-tile--system" data-system="1">
|
||||
<div class="backup-app-tile backup-app-tile--system" data-system="1" title="Back up system config">
|
||||
<img class="backup-app-tile-icon" src="/icons/apps/libreportal.svg" alt="" onerror="this.style.display='none'">
|
||||
<div class="backup-app-tile-text">
|
||||
<div class="backup-app-tile-name">System config</div>
|
||||
@ -652,6 +680,9 @@ class BackupPage {
|
||||
<span>${this.escape(when)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="backup-app-tile-action" data-action="backup-now" data-system="1" title="Back up now">
|
||||
Back up
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -689,7 +720,7 @@ class BackupPage {
|
||||
const when = has ? this.formatRelative(app.latest_time) : 'No backup yet';
|
||||
const { icon, displayName } = this.appMeta(app.app);
|
||||
return `
|
||||
<div class="backup-app-tile" data-app="${this.escape(app.app)}">
|
||||
<div class="backup-app-tile" data-app="${this.escape(app.app)}" title="Open ${this.escape(displayName)} backup history">
|
||||
<img class="backup-app-tile-icon" src="${this.escape(icon)}" alt="" onerror="this.src='/icons/apps/default.svg'">
|
||||
<div class="backup-app-tile-text">
|
||||
<div class="backup-app-tile-name">${this.escape(displayName)}</div>
|
||||
@ -698,6 +729,9 @@ class BackupPage {
|
||||
<span>${when}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="backup-app-tile-action" data-action="backup-now" data-app="${this.escape(app.app)}" title="Back up now">
|
||||
Back up
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -1031,19 +1065,35 @@ class BackupPage {
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filtered.map(r => `
|
||||
<tr>
|
||||
<td>${this.escape(r.app)}</td>
|
||||
<td>${this.escape(r.host)}</td>
|
||||
<td>${this.escape(r.locName)}</td>
|
||||
<td>${this.formatRelative(r.time)}</td>
|
||||
<td class="backup-snapshot-id">${this.escape(r.id)}</td>
|
||||
<td class="backup-col-actions">
|
||||
<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>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
tbody.innerHTML = filtered.map(r => {
|
||||
// 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
|
||||
// since there's no app page to open.
|
||||
const hasApp = r.app && r.app !== '—';
|
||||
const deepLink = hasApp
|
||||
? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}`
|
||||
: null;
|
||||
const appCell = hasApp
|
||||
? `<a class="backup-snapshot-link" href="${this.escape(deepLink)}" data-deep-link="${this.escape(deepLink)}">${this.escape(r.app)}</a>`
|
||||
: this.escape(r.app);
|
||||
const idCell = hasApp
|
||||
? `<a class="backup-snapshot-link backup-snapshot-id" href="${this.escape(deepLink)}" data-deep-link="${this.escape(deepLink)}">${this.escape(r.id)}</a>`
|
||||
: `<span class="backup-snapshot-id">${this.escape(r.id)}</span>`;
|
||||
return `
|
||||
<tr>
|
||||
<td>${appCell}</td>
|
||||
<td>${this.escape(r.host)}</td>
|
||||
<td>${this.escape(r.locName)}</td>
|
||||
<td>${this.formatRelative(r.time)}</td>
|
||||
<td>${idCell}</td>
|
||||
<td class="backup-col-actions">
|
||||
<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>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
renderConfiguration() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user