Add backups
Release rolling / release (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-16 16:24:38 -04:00
parent 301902c470
commit 4460f61f85
7 changed files with 1672 additions and 3 deletions
+902
View File
@@ -0,0 +1,902 @@
/**
* Client-side backup & restore for PearDock preferences and local state.
*
* Packages settings, peers, templates, table columns, notifications, and
* related keys into a versioned JSON archive. Maintains a local backup
* history with retention (max count / max age) and auto-rotation.
*
* Storage: localStorage key peardock.backups.v1
* Export format: peardock-backup (JSON, version 1)
*/
import {
loadPeers,
savePeers,
getLastActivePeerId,
setLastActivePeerId,
LOCALSTORAGE_KEY,
ACTIVE_PEER_KEY,
} from './peerCache.js'
export const BACKUP_FORMAT = 'peardock-backup'
export const BACKUP_FORMAT_VERSION = 1
export const BACKUP_STORE_KEY = 'peardock.backups.v1'
export const SETTINGS_KEY = 'peardock.settings.v1'
export const TEMPLATES_KEY = 'peardock_templates'
export const NOTIFICATIONS_KEY = 'peardock_notifications'
export const PEER_ENVS_KEY = 'peardock.peerEnvs.v1'
export const TABLE_COLS_PREFIX = 'peardock.tableCols.'
export const FIRST_CONNECT_KEY = 'peardock.firstConnect.dismissed'
export const SIDEBAR_KEY = 'peardock.sidebar.collapsed'
/** @typedef {'settings'|'peers'|'templates'|'notifications'|'tableColumns'|'peerEnvs'|'misc'} BackupSection */
/** @type {Record<BackupSection, boolean>} */
export const DEFAULT_INCLUDE = Object.freeze({
settings: true,
peers: true,
templates: true,
notifications: false,
tableColumns: true,
peerEnvs: true,
misc: true,
})
/** @type {BackupPolicy} */
export const DEFAULT_POLICY = Object.freeze({
maxBackups: 10,
maxAgeDays: 90,
autoEnabled: false,
autoIntervalHours: 24,
include: { ...DEFAULT_INCLUDE },
lastAutoAt: null,
})
/**
* @typedef {object} BackupPolicy
* @property {number} maxBackups — 0 = unlimited (still soft-capped at 50)
* @property {number} maxAgeDays — 0 = no age pruning
* @property {boolean} autoEnabled
* @property {number} autoIntervalHours
* @property {Record<string, boolean>} include
* @property {string|null} lastAutoAt
*/
/**
* @typedef {object} BackupRecord
* @property {string} id
* @property {string} createdAt
* @property {string} [label]
* @property {'manual'|'auto'|'import'} source
* @property {number} sizeBytes
* @property {string[]} sections
* @property {boolean} [pinned]
* @property {object} package — full peardock-backup payload
*/
/**
* @typedef {object} BackupStore
* @property {number} version
* @property {BackupRecord[]} backups
* @property {BackupPolicy} policy
*/
const SOFT_MAX_BACKUPS = 50
const MAX_LABEL_LEN = 120
/** @type {ReturnType<typeof setInterval>|null} */
let autoTimer = null
/**
* @returns {string}
*/
function nowIso() {
return new Date().toISOString()
}
/**
* @returns {string}
*/
function newId() {
try {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
} catch {
// fall through
}
return `b_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
}
/**
* @param {unknown} raw
* @returns {BackupPolicy}
*/
export function normalizePolicy(raw) {
const p = raw && typeof raw === 'object' ? raw : {}
const includeIn = p.include && typeof p.include === 'object' ? p.include : {}
/** @type {Record<string, boolean>} */
const include = { ...DEFAULT_INCLUDE }
for (const key of Object.keys(DEFAULT_INCLUDE)) {
if (typeof includeIn[key] === 'boolean') include[key] = includeIn[key]
}
let maxBackups = Number(p.maxBackups)
if (!Number.isFinite(maxBackups) || maxBackups < 0) maxBackups = DEFAULT_POLICY.maxBackups
maxBackups = Math.min(SOFT_MAX_BACKUPS, Math.floor(maxBackups))
let maxAgeDays = Number(p.maxAgeDays)
if (!Number.isFinite(maxAgeDays) || maxAgeDays < 0) maxAgeDays = DEFAULT_POLICY.maxAgeDays
maxAgeDays = Math.min(3650, Math.floor(maxAgeDays))
let autoIntervalHours = Number(p.autoIntervalHours)
if (!Number.isFinite(autoIntervalHours) || autoIntervalHours < 1) {
autoIntervalHours = DEFAULT_POLICY.autoIntervalHours
}
autoIntervalHours = Math.min(24 * 30, Math.floor(autoIntervalHours))
return {
maxBackups,
maxAgeDays,
autoEnabled: Boolean(p.autoEnabled),
autoIntervalHours,
include,
lastAutoAt:
typeof p.lastAutoAt === 'string' && p.lastAutoAt ? p.lastAutoAt : null,
}
}
/**
* @returns {BackupStore}
*/
export function loadBackupStore() {
try {
if (typeof localStorage === 'undefined') {
return { version: 1, backups: [], policy: { ...DEFAULT_POLICY, include: { ...DEFAULT_INCLUDE } } }
}
const raw = JSON.parse(localStorage.getItem(BACKUP_STORE_KEY) || '{}')
const backups = Array.isArray(raw.backups)
? raw.backups
.filter((b) => b && typeof b === 'object' && b.package && b.id)
.map((b) => ({
id: String(b.id),
createdAt: String(b.createdAt || ''),
label: b.label != null ? String(b.label).slice(0, MAX_LABEL_LEN) : '',
source: b.source === 'auto' || b.source === 'import' ? b.source : 'manual',
sizeBytes: Number(b.sizeBytes) || 0,
sections: Array.isArray(b.sections) ? b.sections.map(String) : [],
pinned: Boolean(b.pinned),
package: b.package,
}))
: []
return {
version: 1,
backups,
policy: normalizePolicy(raw.policy),
}
} catch {
return {
version: 1,
backups: [],
policy: { ...DEFAULT_POLICY, include: { ...DEFAULT_INCLUDE } },
}
}
}
/**
* @param {BackupStore} store
*/
export function saveBackupStore(store) {
if (typeof localStorage === 'undefined') return
const payload = {
version: 1,
updatedAt: nowIso(),
policy: normalizePolicy(store.policy),
backups: Array.isArray(store.backups) ? store.backups : [],
}
localStorage.setItem(BACKUP_STORE_KEY, JSON.stringify(payload))
}
/**
* @param {Partial<BackupPolicy>} partial
* @returns {BackupPolicy}
*/
export function saveBackupPolicy(partial) {
const store = loadBackupStore()
store.policy = normalizePolicy({ ...store.policy, ...partial })
if (partial?.include) {
store.policy.include = normalizePolicy({
...store.policy,
include: { ...store.policy.include, ...partial.include },
}).include
}
store.backups = applyRetention(store.backups, store.policy)
saveBackupStore(store)
return store.policy
}
/**
* Collect peardock.tableCols.* keys from localStorage.
* @returns {Record<string, unknown>}
*/
export function collectTableColumns() {
/** @type {Record<string, unknown>} */
const out = {}
try {
if (typeof localStorage === 'undefined') return out
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (!key || !key.startsWith(TABLE_COLS_PREFIX)) continue
try {
out[key.slice(TABLE_COLS_PREFIX.length)] = JSON.parse(localStorage.getItem(key) || 'null')
} catch {
out[key.slice(TABLE_COLS_PREFIX.length)] = localStorage.getItem(key)
}
}
} catch {
// ignore
}
return out
}
/**
* @param {Record<string, unknown>} map
*/
function restoreTableColumns(map) {
if (!map || typeof map !== 'object' || typeof localStorage === 'undefined') return
for (const [suffix, value] of Object.entries(map)) {
if (!suffix || suffix.includes('..') || suffix.includes('/')) continue
try {
localStorage.setItem(
TABLE_COLS_PREFIX + suffix,
typeof value === 'string' ? value : JSON.stringify(value)
)
} catch {
// ignore per-key failures
}
}
}
/**
* Build a portable backup package from current client state.
* @param {{ include?: Partial<Record<BackupSection, boolean>>, label?: string, appVersion?: string }} [opts]
* @returns {object}
*/
export function buildBackupPackage(opts = {}) {
const include = normalizePolicy({ include: { ...DEFAULT_INCLUDE, ...opts.include } }).include
/** @type {Record<string, unknown>} */
const sections = {}
/** @type {string[]} */
const included = []
if (include.settings) {
try {
sections.settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}')
} catch {
sections.settings = {}
}
included.push('settings')
}
if (include.peers) {
try {
sections.peers = loadPeers() || {}
} catch {
try {
sections.peers = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY) || '{}')
} catch {
sections.peers = {}
}
}
try {
sections.activePeerId = getLastActivePeerId() || localStorage.getItem(ACTIVE_PEER_KEY) || null
} catch {
sections.activePeerId = null
}
included.push('peers')
}
if (include.templates) {
try {
sections.templates = JSON.parse(localStorage.getItem(TEMPLATES_KEY) || '{}')
} catch {
sections.templates = {}
}
included.push('templates')
}
if (include.notifications) {
try {
sections.notifications = JSON.parse(localStorage.getItem(NOTIFICATIONS_KEY) || '[]')
} catch {
sections.notifications = []
}
included.push('notifications')
}
if (include.tableColumns) {
sections.tableColumns = collectTableColumns()
included.push('tableColumns')
}
if (include.peerEnvs) {
try {
sections.peerEnvs = JSON.parse(localStorage.getItem(PEER_ENVS_KEY) || '{}')
} catch {
sections.peerEnvs = {}
}
included.push('peerEnvs')
}
if (include.misc) {
sections.misc = {
firstConnectDismissed:
typeof localStorage !== 'undefined'
? localStorage.getItem(FIRST_CONNECT_KEY) === '1'
: false,
sidebarCollapsed:
typeof localStorage !== 'undefined' ? localStorage.getItem(SIDEBAR_KEY) : null,
}
included.push('misc')
}
const createdAt = nowIso()
const pkg = {
format: BACKUP_FORMAT,
version: BACKUP_FORMAT_VERSION,
createdAt,
appVersion: opts.appVersion || guessAppVersion(),
label: opts.label ? String(opts.label).slice(0, MAX_LABEL_LEN) : '',
sections,
meta: {
include: { ...include },
sectionKeys: included,
note: 'May include peer public keys and admin seeds. Store securely.',
},
}
return pkg
}
/**
* @returns {string}
*/
function guessAppVersion() {
try {
const el = typeof document !== 'undefined' ? document.getElementById('settings-app-version') : null
if (el?.textContent?.trim()) return el.textContent.trim()
} catch {
// ignore
}
return '2.0.1'
}
/**
* Validate a parsed backup package.
* @param {unknown} raw
* @returns {{ ok: true, package: object } | { ok: false, error: string }}
*/
export function validateBackupPackage(raw) {
if (!raw || typeof raw !== 'object') {
return { ok: false, error: 'Backup is not a JSON object' }
}
const pkg = /** @type {Record<string, unknown>} */ (raw)
if (pkg.format !== BACKUP_FORMAT) {
return { ok: false, error: `Unknown backup format (expected ${BACKUP_FORMAT})` }
}
const ver = Number(pkg.version)
if (!Number.isFinite(ver) || ver < 1 || ver > BACKUP_FORMAT_VERSION) {
return { ok: false, error: `Unsupported backup version: ${pkg.version}` }
}
if (!pkg.sections || typeof pkg.sections !== 'object') {
return { ok: false, error: 'Backup missing sections payload' }
}
return { ok: true, package: pkg }
}
/**
* Serialize package for download / size accounting.
* @param {object} pkg
* @returns {string}
*/
export function serializePackage(pkg) {
return JSON.stringify(pkg, null, 2)
}
/**
* Suggested download filename for a package.
* @param {object} [pkg]
* @returns {string}
*/
export function backupFilename(pkg) {
const ts = (pkg?.createdAt || nowIso()).replace(/[:.]/g, '-').replace(/Z$/, 'Z')
const label = pkg?.label
? `-${String(pkg.label)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40)}`
: ''
return `peardock-backup${label}-${ts}.json`
}
/**
* Trigger a browser download of the package JSON.
* @param {object} pkg
* @param {string} [filename]
* @returns {{ filename: string, sizeBytes: number }}
*/
export function downloadBackupPackage(pkg, filename) {
const text = serializePackage(pkg)
const name = filename || backupFilename(pkg)
if (typeof document !== 'undefined' && typeof Blob !== 'undefined') {
const blob = new Blob([text], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 2_000)
}
return { filename: name, sizeBytes: text.length }
}
/**
* Apply retention: drop expired unpinned, then cap count (pinned always kept).
* @param {BackupRecord[]} backups
* @param {BackupPolicy} policy
* @returns {BackupRecord[]}
*/
export function applyRetention(backups, policy) {
const p = normalizePolicy(policy)
let list = Array.isArray(backups) ? [...backups] : []
// Age prune (unpinned only)
if (p.maxAgeDays > 0) {
const cutoff = Date.now() - p.maxAgeDays * 86_400_000
list = list.filter((b) => {
if (b.pinned) return true
const t = Date.parse(b.createdAt)
if (!Number.isFinite(t)) return true
return t >= cutoff
})
}
// Newest first
list.sort((a, b) => Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0))
// Count prune: keep all pinned + newest unpinned up to maxBackups total soft rules
const max = p.maxBackups > 0 ? p.maxBackups : SOFT_MAX_BACKUPS
const pinned = list.filter((b) => b.pinned)
const unpinned = list.filter((b) => !b.pinned)
// Prefer: pinned always; fill remaining slots with newest unpinned
// If pinned alone exceeds max, still keep all pinned (operator chose them)
const room = Math.max(0, max - pinned.length)
list = [...pinned, ...unpinned.slice(0, room)]
list.sort((a, b) => Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0))
return list
}
/**
* Create a backup, store in history, apply rotation, optionally download.
* @param {{
* include?: Partial<Record<BackupSection, boolean>>,
* label?: string,
* source?: 'manual'|'auto'|'import',
* download?: boolean,
* appVersion?: string,
* pin?: boolean,
* }} [opts]
* @returns {{ record: BackupRecord, store: BackupStore, downloaded?: { filename: string, sizeBytes: number } }}
*/
export function createBackup(opts = {}) {
const store = loadBackupStore()
const include = opts.include || store.policy.include
const pkg = buildBackupPackage({
include,
label: opts.label,
appVersion: opts.appVersion,
})
const text = serializePackage(pkg)
/** @type {BackupRecord} */
const record = {
id: newId(),
createdAt: pkg.createdAt,
label: pkg.label || '',
source: opts.source === 'auto' || opts.source === 'import' ? opts.source : 'manual',
sizeBytes: text.length,
sections: pkg.meta?.sectionKeys || Object.keys(pkg.sections || {}),
pinned: Boolean(opts.pin),
package: pkg,
}
store.backups = applyRetention([record, ...store.backups], store.policy)
if (opts.source === 'auto') {
store.policy = { ...store.policy, lastAutoAt: record.createdAt }
}
saveBackupStore(store)
/** @type {{ filename: string, sizeBytes: number }|undefined} */
let downloaded
if (opts.download) {
downloaded = downloadBackupPackage(pkg)
}
return { record, store, downloaded }
}
/**
* Import an external package into local history (does not apply restore).
* @param {unknown} raw
* @param {{ label?: string, pin?: boolean }} [opts]
* @returns {{ ok: true, record: BackupRecord, store: BackupStore } | { ok: false, error: string }}
*/
export function importBackupToHistory(raw, opts = {}) {
const v = validateBackupPackage(raw)
if (!v.ok) return v
const pkg = { ...v.package }
if (opts.label) pkg.label = String(opts.label).slice(0, MAX_LABEL_LEN)
const text = serializePackage(pkg)
const store = loadBackupStore()
const record = {
id: newId(),
createdAt: String(pkg.createdAt || nowIso()),
label: pkg.label || opts.label || 'Imported',
source: /** @type {const} */ ('import'),
sizeBytes: text.length,
sections: Array.isArray(pkg.meta?.sectionKeys)
? pkg.meta.sectionKeys
: Object.keys(pkg.sections || {}),
pinned: Boolean(opts.pin),
package: pkg,
}
store.backups = applyRetention([record, ...store.backups], store.policy)
saveBackupStore(store)
return { ok: true, record, store }
}
/**
* @param {string} id
* @returns {BackupRecord|null}
*/
export function getBackupById(id) {
if (!id) return null
return loadBackupStore().backups.find((b) => b.id === id) || null
}
/**
* @param {string} id
* @returns {boolean}
*/
export function deleteBackup(id) {
const store = loadBackupStore()
const next = store.backups.filter((b) => b.id !== id)
if (next.length === store.backups.length) return false
store.backups = next
saveBackupStore(store)
return true
}
/**
* @param {string} id
* @param {boolean} pinned
* @returns {BackupRecord|null}
*/
export function setBackupPinned(id, pinned) {
const store = loadBackupStore()
const rec = store.backups.find((b) => b.id === id)
if (!rec) return null
rec.pinned = Boolean(pinned)
store.backups = applyRetention(store.backups, store.policy)
saveBackupStore(store)
return rec
}
/**
* Restore client state from a package. Merge or replace per opts.
* @param {unknown} raw
* @param {{
* sections?: Partial<Record<BackupSection, boolean>>,
* mode?: 'merge'|'replace',
* }} [opts]
* @returns {{ ok: true, restored: string[], warnings: string[] } | { ok: false, error: string }}
*/
export function restoreFromPackage(raw, opts = {}) {
const v = validateBackupPackage(raw)
if (!v.ok) return v
const pkg = v.package
const sections = pkg.sections || {}
const mode = opts.mode === 'merge' ? 'merge' : 'replace'
/** @type {Record<string, boolean>} */
const want = { ...DEFAULT_INCLUDE }
if (opts.sections) {
for (const k of Object.keys(DEFAULT_INCLUDE)) {
if (typeof opts.sections[k] === 'boolean') want[k] = opts.sections[k]
}
} else if (pkg.meta?.include && typeof pkg.meta.include === 'object') {
for (const k of Object.keys(DEFAULT_INCLUDE)) {
if (typeof pkg.meta.include[k] === 'boolean') want[k] = pkg.meta.include[k]
}
}
/** @type {string[]} */
const restored = []
/** @type {string[]} */
const warnings = []
try {
if (want.settings && sections.settings && typeof sections.settings === 'object') {
if (mode === 'merge') {
let cur = {}
try {
cur = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}')
} catch {
cur = {}
}
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...cur, ...sections.settings }))
} else {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(sections.settings))
}
restored.push('settings')
}
if (want.peers && sections.peers && typeof sections.peers === 'object') {
let peersMap = sections.peers
// Accept array form
if (Array.isArray(peersMap)) {
/** @type {Record<string, object>} */
const map = {}
for (const p of peersMap) {
if (!p || typeof p !== 'object') continue
const id = p.id || String(p.publicKeyHex || '').slice(0, 12)
if (id) map[id] = p
}
peersMap = map
}
if (mode === 'merge') {
try {
const existing = loadPeers() || {}
peersMap = { ...existing, ...peersMap }
} catch {
// use package only
}
}
const activePeerId =
sections.activePeerId != null ? String(sections.activePeerId) : undefined
const result = savePeers(peersMap, {
...(activePeerId !== undefined ? { activePeerId } : {}),
})
if (!result.ok) {
warnings.push('Peers written to localStorage mirror only (file write failed)')
}
if (activePeerId) {
try {
setLastActivePeerId(activePeerId)
} catch {
try {
localStorage.setItem(ACTIVE_PEER_KEY, activePeerId)
} catch {
// ignore
}
}
}
restored.push('peers')
}
if (want.templates && sections.templates != null) {
localStorage.setItem(
TEMPLATES_KEY,
JSON.stringify(
mode === 'merge'
? {
...(() => {
try {
return JSON.parse(localStorage.getItem(TEMPLATES_KEY) || '{}')
} catch {
return {}
}
})(),
...(typeof sections.templates === 'object' ? sections.templates : {}),
}
: sections.templates
)
)
restored.push('templates')
}
if (want.notifications && sections.notifications != null) {
localStorage.setItem(
NOTIFICATIONS_KEY,
JSON.stringify(
Array.isArray(sections.notifications) ? sections.notifications : []
)
)
restored.push('notifications')
}
if (want.tableColumns && sections.tableColumns && typeof sections.tableColumns === 'object') {
restoreTableColumns(sections.tableColumns)
restored.push('tableColumns')
}
if (want.peerEnvs && sections.peerEnvs && typeof sections.peerEnvs === 'object') {
if (mode === 'merge') {
let cur = {}
try {
cur = JSON.parse(localStorage.getItem(PEER_ENVS_KEY) || '{}')
} catch {
cur = {}
}
localStorage.setItem(PEER_ENVS_KEY, JSON.stringify({ ...cur, ...sections.peerEnvs }))
} else {
localStorage.setItem(PEER_ENVS_KEY, JSON.stringify(sections.peerEnvs))
}
restored.push('peerEnvs')
}
if (want.misc && sections.misc && typeof sections.misc === 'object') {
const misc = sections.misc
if (misc.firstConnectDismissed) {
localStorage.setItem(FIRST_CONNECT_KEY, '1')
} else if (mode === 'replace') {
localStorage.removeItem(FIRST_CONNECT_KEY)
}
if (misc.sidebarCollapsed === '1' || misc.sidebarCollapsed === 1 || misc.sidebarCollapsed === true) {
localStorage.setItem(SIDEBAR_KEY, '1')
} else if (mode === 'replace' && (misc.sidebarCollapsed === '0' || misc.sidebarCollapsed === false || misc.sidebarCollapsed === null)) {
localStorage.setItem(SIDEBAR_KEY, '0')
}
restored.push('misc')
}
} catch (err) {
return { ok: false, error: err?.message || String(err) }
}
if (restored.length === 0) {
return { ok: false, error: 'No matching sections to restore from this backup' }
}
return { ok: true, restored, warnings }
}
/**
* Parse file text into a package.
* @param {string} text
* @returns {{ ok: true, package: object } | { ok: false, error: string }}
*/
export function parseBackupText(text) {
if (text == null || !String(text).trim()) {
return { ok: false, error: 'Empty file' }
}
try {
const raw = JSON.parse(String(text))
return validateBackupPackage(raw)
} catch (err) {
return { ok: false, error: `Invalid JSON: ${err?.message || err}` }
}
}
/**
* Human-readable size.
* @param {number} n
* @returns {string}
*/
export function formatBytes(n) {
const v = Number(n) || 0
if (v < 1024) return `${v} B`
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`
return `${(v / (1024 * 1024)).toFixed(2)} MB`
}
/**
* Summarize store for UI.
* @returns {{ count: number, totalBytes: number, policy: BackupPolicy, backups: BackupRecord[], nextAutoDue: string|null }}
*/
export function getBackupSummary() {
const store = loadBackupStore()
const totalBytes = store.backups.reduce((s, b) => s + (b.sizeBytes || 0), 0)
let nextAutoDue = null
if (store.policy.autoEnabled) {
const last = store.policy.lastAutoAt ? Date.parse(store.policy.lastAutoAt) : 0
const intervalMs = store.policy.autoIntervalHours * 3_600_000
const next = (Number.isFinite(last) && last > 0 ? last : 0) + intervalMs
nextAutoDue = new Date(Math.max(Date.now(), next)).toISOString()
// If never run, due now
if (!store.policy.lastAutoAt) nextAutoDue = nowIso()
}
return {
count: store.backups.length,
totalBytes,
policy: store.policy,
backups: store.backups,
nextAutoDue,
}
}
/**
* Run auto-backup if policy says it's due.
* @param {{ force?: boolean, appVersion?: string }} [opts]
* @returns {{ ran: boolean, record?: BackupRecord, reason?: string }}
*/
export function maybeRunAutoBackup(opts = {}) {
const store = loadBackupStore()
const policy = store.policy
if (!policy.autoEnabled && !opts.force) {
return { ran: false, reason: 'auto-disabled' }
}
if (!opts.force && policy.lastAutoAt) {
const last = Date.parse(policy.lastAutoAt)
const due = last + policy.autoIntervalHours * 3_600_000
if (Number.isFinite(last) && Date.now() < due) {
return { ran: false, reason: 'not-due' }
}
}
const { record } = createBackup({
source: 'auto',
include: policy.include,
label: 'Auto backup',
appVersion: opts.appVersion,
download: false,
})
return { ran: true, record }
}
/**
* Start / restart the auto-backup interval timer (client session).
* @param {{ onRun?: (result: ReturnType<typeof maybeRunAutoBackup>) => void, appVersion?: string }} [opts]
*/
export function startAutoBackupScheduler(opts = {}) {
stopAutoBackupScheduler()
const tick = () => {
try {
const result = maybeRunAutoBackup({ appVersion: opts.appVersion })
if (result.ran) opts.onRun?.(result)
} catch (err) {
console.warn('[backup] auto tick failed', err?.message || err)
}
}
// Check every 15 minutes; interval policy decides whether to run
autoTimer = setInterval(tick, 15 * 60 * 1000)
if (typeof autoTimer.unref === 'function') autoTimer.unref()
// Immediate check shortly after start
setTimeout(tick, 8_000)
}
export function stopAutoBackupScheduler() {
if (autoTimer) {
clearInterval(autoTimer)
autoTimer = null
}
}
/**
* Wipe all local history (not current settings).
* @returns {number} removed count
*/
export function clearBackupHistory() {
const store = loadBackupStore()
const n = store.backups.length
store.backups = []
saveBackupStore(store)
return n
}
export default {
BACKUP_FORMAT,
BACKUP_FORMAT_VERSION,
DEFAULT_INCLUDE,
DEFAULT_POLICY,
loadBackupStore,
saveBackupPolicy,
buildBackupPackage,
validateBackupPackage,
createBackup,
downloadBackupPackage,
restoreFromPackage,
parseBackupText,
applyRetention,
getBackupSummary,
maybeRunAutoBackup,
startAutoBackupScheduler,
stopAutoBackupScheduler,
}
+158 -1
View File
@@ -2643,7 +2643,7 @@ services:
<div class="settings-page-scroll"> <div class="settings-page-scroll">
<div class="page-header"> <div class="page-header">
<h2><i class="fas fa-gear"></i>Settings</h2> <h2><i class="fas fa-gear"></i>Settings</h2>
<p class="page-subtitle">Client preferences, catalogs, connections, and about</p> <p class="page-subtitle">Client preferences, backup &amp; restore, catalogs, connections, and about</p>
</div> </div>
<ul class="nav nav-pills settings-subtabs flex-wrap gap-1 mb-3" id="settings-tabs" role="tablist"> <ul class="nav nav-pills settings-subtabs flex-wrap gap-1 mb-3" id="settings-tabs" role="tablist">
@@ -2671,6 +2671,9 @@ services:
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="terminal" role="tab">Terminal</button> <button type="button" class="nav-link" data-settings-tab="terminal" role="tab">Terminal</button>
</li> </li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="backup" role="tab">Backup</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-settings-tab="about" role="tab">About</button> <button type="button" class="nav-link" data-settings-tab="about" role="tab">About</button>
</li> </li>
@@ -2977,6 +2980,160 @@ services:
</div> </div>
</div> </div>
<!-- Backup & restore -->
<div class="settings-panel hidden" id="settings-panel-backup" data-settings-panel="backup">
<div class="settings-section">
<h3>Backup &amp; restore</h3>
<p class="small text-muted mb-3">
Package client preferences, saved peers, templates, and related local data into a
portable JSON archive. Backups may include peer public keys and admin seeds — store them securely.
</p>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label" for="settings-backup-label">Label <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" id="settings-backup-label" class="form-control bg-dark text-white" maxlength="120" placeholder="e.g. before-migrate" autocomplete="off" spellcheck="false">
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="settings-backup-pin">
<label class="form-check-label" for="settings-backup-pin">Pin (skip rotation)</label>
</div>
</div>
</div>
<div class="mt-3">
<div class="form-label mb-2">Include in package</div>
<div class="row g-2 settings-backup-include" id="settings-backup-include">
<div class="col-md-4 col-sm-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="settings-backup-inc-settings" data-backup-section="settings" checked>
<label class="form-check-label" for="settings-backup-inc-settings">Settings &amp; preferences</label>
</div>
</div>
<div class="col-md-4 col-sm-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="settings-backup-inc-peers" data-backup-section="peers" checked>
<label class="form-check-label" for="settings-backup-inc-peers">Saved peers</label>
</div>
</div>
<div class="col-md-4 col-sm-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="settings-backup-inc-templates" data-backup-section="templates" checked>
<label class="form-check-label" for="settings-backup-inc-templates">Saved templates</label>
</div>
</div>
<div class="col-md-4 col-sm-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="settings-backup-inc-tableColumns" data-backup-section="tableColumns" checked>
<label class="form-check-label" for="settings-backup-inc-tableColumns">Table column layouts</label>
</div>
</div>
<div class="col-md-4 col-sm-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="settings-backup-inc-peerEnvs" data-backup-section="peerEnvs" checked>
<label class="form-check-label" for="settings-backup-inc-peerEnvs">Fleet peer environments</label>
</div>
</div>
<div class="col-md-4 col-sm-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="settings-backup-inc-misc" data-backup-section="misc" checked>
<label class="form-check-label" for="settings-backup-inc-misc">UI flags (sidebar, tips)</label>
</div>
</div>
<div class="col-md-4 col-sm-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="settings-backup-inc-notifications" data-backup-section="notifications">
<label class="form-check-label" for="settings-backup-inc-notifications">Notification history</label>
</div>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<button type="button" class="btn btn-primary btn-sm" id="settings-backup-create-btn">
<i class="fas fa-box-archive me-1"></i>Create backup
</button>
<button type="button" class="btn btn-outline-primary btn-sm" id="settings-backup-download-btn" title="Create and download a packaged .json archive">
<i class="fas fa-download me-1"></i>Download package
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="settings-backup-restore-file-btn">
<i class="fas fa-upload me-1"></i>Restore from file…
</button>
<input type="file" id="settings-backup-file-input" class="d-none" accept=".json,application/json">
</div>
<div id="settings-backup-status" class="small text-muted mt-2" role="status" aria-live="polite"></div>
</div>
<div class="settings-section mt-4">
<h3>Retention &amp; auto rotation</h3>
<p class="small text-muted mb-3">
Local history is rotated automatically when you create a backup. Pinned backups are never removed by age or count limits.
Use <strong>Save all</strong> to persist these policy settings.
</p>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label" for="settings-backup-max">Max backups</label>
<input type="number" id="settings-backup-max" class="form-control bg-dark text-white" min="0" max="50" value="10">
<div class="form-text">0 = soft cap (50). Oldest unpinned drop first.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="settings-backup-age">Max age (days)</label>
<input type="number" id="settings-backup-age" class="form-control bg-dark text-white" min="0" max="3650" value="90">
<div class="form-text">0 = no age pruning. Pinned exempt.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="settings-backup-auto">Auto backup</label>
<select id="settings-backup-auto" class="form-select bg-dark text-white">
<option value="0">Off</option>
<option value="1">On</option>
</select>
<div class="form-text">Creates a local package on an interval.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="settings-backup-interval">Interval (hours)</label>
<input type="number" id="settings-backup-interval" class="form-control bg-dark text-white" min="1" max="720" value="24">
<div class="form-text">Checked while the client is open.</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<button type="button" class="btn btn-outline-secondary btn-sm" id="settings-backup-rotate-btn" title="Apply retention rules now">
<i class="fas fa-arrows-rotate me-1"></i>Rotate now
</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="settings-backup-clear-history-btn">
<i class="fas fa-trash-can me-1"></i>Clear history
</button>
</div>
<div id="settings-backup-policy-summary" class="small text-muted mt-2"></div>
</div>
<div class="settings-section mt-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<h3 class="mb-0">Local history</h3>
<button type="button" class="btn btn-outline-secondary btn-sm" id="settings-backup-refresh-btn" title="Refresh list">
<i class="fas fa-sync"></i>
</button>
</div>
<div id="settings-backup-history-empty" class="small text-muted py-2 d-none">
No local backups yet. Create one or restore from a downloaded package.
</div>
<ul id="settings-backup-history" class="list-group list-group-flush settings-backup-history"></ul>
</div>
<div class="settings-section mt-4">
<h3 class="h6">Restore mode</h3>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="settings-backup-restore-mode">When restoring</label>
<select id="settings-backup-restore-mode" class="form-select bg-dark text-white">
<option value="replace">Replace (overwrite selected sections)</option>
<option value="merge">Merge (keep existing keys, package wins on conflict)</option>
</select>
</div>
</div>
<p class="small text-muted mt-2 mb-0">
After a restore that includes peers or settings, reconnect peers and re-open views if the UI looks stale.
</p>
</div>
</div>
<!-- About --> <!-- About -->
<div class="settings-panel hidden" id="settings-panel-about" data-settings-panel="about"> <div class="settings-panel hidden" id="settings-panel-about" data-settings-panel="about">
<div class="settings-section"> <div class="settings-section">
+5 -2
View File
@@ -60,9 +60,10 @@ class NotificationManager {
/** /**
* Load notifications from localStorage * Load notifications from localStorage
* @param {{ force?: boolean }} [opts] — force re-read (e.g. after backup restore)
*/ */
loadFromStorage() { loadFromStorage(opts = {}) {
if (this._storageLoaded) return if (this._storageLoaded && !opts.force) return
try { try {
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(STORAGE_KEY)
if (stored) { if (stored) {
@@ -80,6 +81,8 @@ class NotificationManager {
} }
}) })
this.notifications = this.notifications.slice(-MAX_NOTIFICATIONS) this.notifications = this.notifications.slice(-MAX_NOTIFICATIONS)
} else if (opts.force) {
this.notifications = []
} }
this._storageLoaded = true this._storageLoaded = true
this.notify() this.notify()
+175
View File
@@ -0,0 +1,175 @@
/**
* Client backup package, retention, and restore unit tests.
*/
import test from 'brittle'
import {
BACKUP_FORMAT,
BACKUP_FORMAT_VERSION,
BACKUP_STORE_KEY,
SETTINGS_KEY,
TEMPLATES_KEY,
DEFAULT_POLICY,
normalizePolicy,
applyRetention,
buildBackupPackage,
validateBackupPackage,
serializePackage,
parseBackupText,
createBackup,
restoreFromPackage,
loadBackupStore,
saveBackupPolicy,
clearBackupHistory,
formatBytes,
backupFilename,
} from '../client/backup.js'
/** Minimal localStorage polyfill for Node tests */
function installLocalStorage() {
/** @type {Map<string, string>} */
const map = new Map()
const ls = {
get length() {
return map.size
},
key(i) {
return [...map.keys()][i] ?? null
},
getItem(k) {
return map.has(k) ? map.get(k) : null
},
setItem(k, v) {
map.set(String(k), String(v))
},
removeItem(k) {
map.delete(k)
},
clear() {
map.clear()
},
}
globalThis.localStorage = ls
return ls
}
test('normalizePolicy clamps and defaults', (t) => {
const p = normalizePolicy({
maxBackups: 999,
maxAgeDays: -3,
autoEnabled: 1,
autoIntervalHours: 0,
include: { settings: false, peers: true },
})
t.is(p.maxBackups, 50)
t.is(p.maxAgeDays, DEFAULT_POLICY.maxAgeDays)
t.ok(p.autoEnabled)
t.is(p.autoIntervalHours, DEFAULT_POLICY.autoIntervalHours)
t.is(p.include.settings, false)
t.is(p.include.peers, true)
t.is(p.include.templates, true)
})
test('applyRetention drops by age and count; pinned survive', (t) => {
const now = Date.now()
const day = 86_400_000
const backups = [
{ id: 'old', createdAt: new Date(now - 100 * day).toISOString(), pinned: false, package: {} },
{ id: 'mid', createdAt: new Date(now - 10 * day).toISOString(), pinned: false, package: {} },
{ id: 'pin', createdAt: new Date(now - 200 * day).toISOString(), pinned: true, package: {} },
{ id: 'new', createdAt: new Date(now - 1 * day).toISOString(), pinned: false, package: {} },
]
const kept = applyRetention(backups, { maxBackups: 2, maxAgeDays: 30, autoEnabled: false, autoIntervalHours: 24, include: {}, lastAutoAt: null })
const ids = kept.map((b) => b.id).sort()
// pin always; among unpinned only mid+new pass age, then count keeps 2 total slots but pin takes 1 → 1 unpinned
t.ok(ids.includes('pin'))
t.is(kept.length, 2)
t.ok(ids.includes('new') || ids.includes('mid'))
t.absent(ids.includes('old'))
})
test('build + validate + restore package round-trip', (t) => {
installLocalStorage()
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ density: 'compact', accent: 'violet' }))
localStorage.setItem(TEMPLATES_KEY, JSON.stringify({ nginx: { image: 'nginx' } }))
localStorage.setItem('peardock.tableCols.containers', JSON.stringify({ name: true, image: false }))
const pkg = buildBackupPackage({
label: 'unit-test',
include: {
settings: true,
peers: false,
templates: true,
notifications: false,
tableColumns: true,
peerEnvs: false,
misc: false,
},
})
t.is(pkg.format, BACKUP_FORMAT)
t.is(pkg.version, BACKUP_FORMAT_VERSION)
t.is(pkg.label, 'unit-test')
t.is(pkg.sections.settings.density, 'compact')
t.is(pkg.sections.templates.nginx.image, 'nginx')
const v = validateBackupPackage(pkg)
t.ok(v.ok)
const text = serializePackage(pkg)
const parsed = parseBackupText(text)
t.ok(parsed.ok)
// Wipe and restore
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ density: 'comfortable' }))
localStorage.removeItem(TEMPLATES_KEY)
const r = restoreFromPackage(pkg, {
mode: 'replace',
sections: { settings: true, templates: true, tableColumns: true, peers: false, notifications: false, peerEnvs: false, misc: false },
})
t.ok(r.ok)
t.ok(r.restored.includes('settings'))
t.ok(r.restored.includes('templates'))
const s = JSON.parse(localStorage.getItem(SETTINGS_KEY))
t.is(s.density, 'compact')
t.is(s.accent, 'violet')
const templates = JSON.parse(localStorage.getItem(TEMPLATES_KEY))
t.is(templates.nginx.image, 'nginx')
})
test('createBackup stores history and applies maxBackups rotation', (t) => {
installLocalStorage()
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ density: 'spacious' }))
saveBackupPolicy({ maxBackups: 2, maxAgeDays: 0, autoEnabled: false, include: { settings: true, peers: false, templates: false, notifications: false, tableColumns: false, peerEnvs: false, misc: false } })
createBackup({ label: 'a', source: 'manual' })
createBackup({ label: 'b', source: 'manual' })
createBackup({ label: 'c', source: 'manual' })
const store = loadBackupStore()
t.is(store.backups.length, 2)
t.is(store.backups[0].label, 'c')
t.is(store.backups[1].label, 'b')
})
test('validateBackupPackage rejects garbage', (t) => {
t.is(validateBackupPackage(null).ok, false)
t.is(validateBackupPackage({ format: 'nope' }).ok, false)
t.is(validateBackupPackage({ format: BACKUP_FORMAT, version: 99, sections: {} }).ok, false)
t.is(parseBackupText('not-json').ok, false)
})
test('formatBytes and backupFilename helpers', (t) => {
t.is(formatBytes(500), '500 B')
t.ok(formatBytes(2048).includes('KB'))
const name = backupFilename({ createdAt: '2026-01-02T03:04:05.000Z', label: 'Pre Migrate!' })
t.ok(name.startsWith('peardock-backup-pre-migrate-'))
t.ok(name.endsWith('.json'))
})
test('clearBackupHistory empties store', (t) => {
installLocalStorage()
createBackup({ label: 'x' })
t.ok(loadBackupStore().backups.length >= 1)
const n = clearBackupHistory()
t.ok(n >= 1)
t.is(loadBackupStore().backups.length, 0)
})
+4
View File
@@ -37,6 +37,9 @@ const REQUIRED_IDS = [
'host-view', 'host-view',
'settings-view', 'settings-view',
'settings-panel-peers', 'settings-panel-peers',
'settings-panel-backup',
'settings-backup-history',
'settings-backup-download-btn',
'connection-list', 'connection-list',
'stack-git-url', 'stack-git-url',
'tunnel-create-btn', 'tunnel-create-btn',
@@ -52,6 +55,7 @@ const REQUIRED_SNIPPETS = [
'data-view="swarm"', 'data-view="swarm"',
'data-view="registry"', 'data-view="registry"',
'data-settings-tab="peers"', 'data-settings-tab="peers"',
'data-settings-tab="backup"',
'ENABLE_HOLESAIL', 'ENABLE_HOLESAIL',
'GitOps', 'GitOps',
'collapse-sidebar-btn', 'collapse-sidebar-btn',
+409
View File
@@ -51,6 +51,24 @@ import {
fetchMergedTemplates, fetchMergedTemplates,
clearMergedTemplateCache, clearMergedTemplateCache,
} from '../client/templateLists.js' } from '../client/templateLists.js'
import {
DEFAULT_INCLUDE,
loadBackupStore,
saveBackupPolicy,
createBackup,
downloadBackupPackage,
restoreFromPackage,
parseBackupText,
importBackupToHistory,
getBackupById,
deleteBackup,
setBackupPinned,
applyRetention,
saveBackupStore,
getBackupSummary,
clearBackupHistory,
startAutoBackupScheduler,
} from '../client/backup.js'
const SETTINGS_KEY = 'peardock.settings.v1' const SETTINGS_KEY = 'peardock.settings.v1'
const SIDEBAR_KEY = 'peardock.sidebar.collapsed' const SIDEBAR_KEY = 'peardock.sidebar.collapsed'
@@ -1721,6 +1739,14 @@ export function showSettingsTab(tab) {
// ignore // ignore
} }
} }
if (name === 'backup') {
try {
fillBackupForm()
renderBackupHistory()
} catch {
// ignore
}
}
} }
/** /**
@@ -1758,6 +1784,252 @@ export function readSettingsForm() {
} }
} }
/**
* Read backup include checkboxes + retention policy from the Backup panel.
* @returns {{ include: Record<string, boolean>, maxBackups: number, maxAgeDays: number, autoEnabled: boolean, autoIntervalHours: number }}
*/
export function readBackupForm() {
/** @type {Record<string, boolean>} */
const include = { ...DEFAULT_INCLUDE }
document.querySelectorAll('[data-backup-section]').forEach((el) => {
const key = el.getAttribute('data-backup-section')
if (key) include[key] = Boolean(/** @type {HTMLInputElement} */ (el).checked)
})
return {
include,
maxBackups: Number(document.getElementById('settings-backup-max')?.value) || 0,
maxAgeDays: Number(document.getElementById('settings-backup-age')?.value) || 0,
autoEnabled: document.getElementById('settings-backup-auto')?.value === '1',
autoIntervalHours: Number(document.getElementById('settings-backup-interval')?.value) || 24,
}
}
/**
* Hydrate Backup panel from local store.
*/
export function fillBackupForm() {
const { policy } = loadBackupStore()
setSelectValue('settings-backup-max', policy.maxBackups)
setSelectValue('settings-backup-age', policy.maxAgeDays)
setSelectValue('settings-backup-auto', policy.autoEnabled ? '1' : '0')
setSelectValue('settings-backup-interval', policy.autoIntervalHours)
for (const [key, on] of Object.entries(policy.include || DEFAULT_INCLUDE)) {
setCheckbox(`settings-backup-inc-${key}`, on !== false)
}
updateBackupPolicySummary()
}
function updateBackupPolicySummary() {
const el = document.getElementById('settings-backup-policy-summary')
if (!el) return
const summary = getBackupSummary()
const p = summary.policy
const parts = [
`${summary.count} local backup${summary.count === 1 ? '' : 's'}`,
formatBytes(summary.totalBytes),
`retain max ${p.maxBackups || '∞'} / ${p.maxAgeDays || '∞'}d`,
]
if (p.autoEnabled) {
parts.push(
summary.policy.lastAutoAt
? `auto every ${p.autoIntervalHours}h (last ${formatBackupWhen(summary.policy.lastAutoAt)})`
: `auto every ${p.autoIntervalHours}h (pending)`
)
} else {
parts.push('auto off')
}
el.textContent = parts.join(' · ')
}
/**
* @param {string} iso
* @returns {string}
*/
function formatBackupWhen(iso) {
try {
const d = new Date(iso)
if (!Number.isFinite(d.getTime())) return iso
return d.toLocaleString()
} catch {
return iso
}
}
/**
* Persist retention / include policy from the Backup form.
* @returns {ReturnType<typeof saveBackupPolicy>}
*/
export function persistBackupPolicyFromForm() {
const form = readBackupForm()
const policy = saveBackupPolicy({
maxBackups: form.maxBackups,
maxAgeDays: form.maxAgeDays,
autoEnabled: form.autoEnabled,
autoIntervalHours: form.autoIntervalHours,
include: form.include,
})
startAutoBackupScheduler({
appVersion: document.getElementById('settings-app-version')?.textContent?.trim(),
onRun: (result) => {
if (result.ran) {
showAlert('info', 'Automatic client backup created', { badge: false })
if (document.querySelector('[data-settings-panel="backup"]:not(.hidden)')) {
renderBackupHistory()
}
}
},
})
updateBackupPolicySummary()
return policy
}
/**
* Render local backup history list.
*/
export function renderBackupHistory() {
const list = document.getElementById('settings-backup-history')
const empty = document.getElementById('settings-backup-history-empty')
if (!list) return
const { backups } = getBackupSummary()
updateBackupPolicySummary()
list.innerHTML = ''
if (!backups.length) {
empty?.classList.remove('d-none')
return
}
empty?.classList.add('d-none')
for (const b of backups) {
const li = document.createElement('li')
li.className = 'list-group-item settings-backup-history-item'
li.dataset.backupId = b.id
const secs = (b.sections || []).join(', ') || '—'
const pinBadge = b.pinned
? '<span class="badge text-bg-warning ms-1" title="Pinned — exempt from rotation">pin</span>'
: ''
const src =
b.source === 'auto' ? 'auto' : b.source === 'import' ? 'import' : 'manual'
li.innerHTML = `
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div class="min-w-0 flex-grow-1">
<div class="fw-semibold text-white text-truncate">
${escape(b.label || 'Backup')}
${pinBadge}
<span class="badge text-bg-secondary ms-1">${escape(src)}</span>
</div>
<div class="small text-muted">
${escape(formatBackupWhen(b.createdAt))}
· ${escape(formatBytes(b.sizeBytes))}
· <span class="font-monospace" title="${escape(secs)}">${escape(secs.length > 48 ? secs.slice(0, 48) + '…' : secs)}</span>
</div>
</div>
<div class="btn-group btn-group-sm flex-shrink-0 settings-backup-actions">
<button type="button" class="btn btn-outline-primary" data-backup-action="download" data-id="${escape(b.id)}" title="Download package">
<i class="fas fa-download"></i>
</button>
<button type="button" class="btn btn-outline-success" data-backup-action="restore" data-id="${escape(b.id)}" title="Restore">
<i class="fas fa-clock-rotate-left"></i>
</button>
<button type="button" class="btn btn-outline-secondary" data-backup-action="pin" data-id="${escape(b.id)}" title="${b.pinned ? 'Unpin' : 'Pin'}">
<i class="fas fa-thumbtack"></i>
</button>
<button type="button" class="btn btn-outline-danger" data-backup-action="delete" data-id="${escape(b.id)}" title="Delete">
<i class="fas fa-trash-can"></i>
</button>
</div>
</div>`
list.appendChild(li)
}
}
function setBackupStatus(msg, tone = 'muted') {
const el = document.getElementById('settings-backup-status')
if (!el) return
el.className = `small mt-2 text-${tone === 'danger' ? 'danger' : tone === 'success' ? 'success' : tone === 'warning' ? 'warning' : 'muted'}`
el.textContent = msg || ''
}
/**
* @param {boolean} [download]
*/
function runCreateBackup(download = false) {
try {
persistBackupPolicyFromForm()
const form = readBackupForm()
const label = document.getElementById('settings-backup-label')?.value?.trim() || ''
const pin = Boolean(document.getElementById('settings-backup-pin')?.checked)
const appVersion = document.getElementById('settings-app-version')?.textContent?.trim()
const { record, downloaded } = createBackup({
include: form.include,
label,
pin,
source: 'manual',
download,
appVersion,
})
renderBackupHistory()
if (download && downloaded) {
setBackupStatus(`Created and downloaded ${downloaded.filename} (${formatBytes(downloaded.sizeBytes)})`, 'success')
showAlert('success', `Backup package downloaded (${formatBytes(record.sizeBytes)})`, {
badge: false,
})
} else {
setBackupStatus(`Backup saved to local history (${formatBytes(record.sizeBytes)})`, 'success')
showAlert('success', 'Backup created', { badge: false })
}
} catch (err) {
setBackupStatus(err?.message || 'Backup failed', 'danger')
showAlert('danger', err?.message || 'Backup failed')
}
}
/**
* @param {object} pkg
* @param {{ mode?: string }} [opts]
*/
async function runRestorePackage(pkg, opts = {}) {
const mode =
opts.mode ||
document.getElementById('settings-backup-restore-mode')?.value ||
'replace'
const form = readBackupForm()
const ok = await confirmDestructive(
'Restore from backup?',
mode === 'merge'
? 'Selected sections will be merged into current client data. Peers and settings may change.'
: 'Selected sections will be overwritten with the backup. Peers and settings may change.',
mode === 'replace' ? 'RESTORE' : undefined
)
if (!ok) return false
const result = restoreFromPackage(pkg, {
mode: mode === 'merge' ? 'merge' : 'replace',
sections: form.include,
})
if (!result.ok) {
setBackupStatus(result.error, 'danger')
showAlert('danger', result.error)
return false
}
// Re-apply settings / notifications / peers UI
try {
const s = loadSettings()
applySettings(s)
fillBackupForm()
notificationManager?.loadFromStorage?.({ force: true })
if (typeof window.loadPeersView === 'function') window.loadPeersView()
} catch {
// ignore post-restore refresh errors
}
const warn = result.warnings?.length ? ` (${result.warnings.join('; ')})` : ''
setBackupStatus(`Restored: ${result.restored.join(', ')}${warn}`, 'success')
showAlert(
'success',
`Restored ${result.restored.join(', ')}. Reconnect peers if needed.`,
{ badge: false }
)
return true
}
/** /**
* @param {string} [forceTab] — open a specific settings subtab (e.g. peers) * @param {string} [forceTab] — open a specific settings subtab (e.g. peers)
*/ */
@@ -1832,6 +2104,9 @@ export function loadSettingsView(forceTab) {
templateUrlsDraft = getTemplateListUrls() templateUrlsDraft = getTemplateListUrls()
renderTemplateUrlsEditor() renderTemplateUrlsEditor()
fillBackupForm()
renderBackupHistory()
} }
function renderTemplateUrlsEditor() { function renderTemplateUrlsEditor() {
@@ -2011,6 +2286,15 @@ export function openPalette(navigateToView) {
{ label: 'Tunnels', icon: 'fa-satellite-dish', view: 'tunnels', keywords: 'g t holesail' }, { label: 'Tunnels', icon: 'fa-satellite-dish', view: 'tunnels', keywords: 'g t holesail' },
{ label: 'Access', icon: 'fa-user-shield', view: 'access', keywords: 'g a invites acl' }, { label: 'Access', icon: 'fa-user-shield', view: 'access', keywords: 'g a invites acl' },
{ label: 'Settings', icon: 'fa-gear', view: 'settings', keywords: 'g , prefs preferences' }, { label: 'Settings', icon: 'fa-gear', view: 'settings', keywords: 'g , prefs preferences' },
{
label: 'Backup & restore',
icon: 'fa-box-archive',
keywords: 'backup restore export import package retention rotate',
action: () => {
navigateToView('settings')
showSettingsTab('backup')
},
},
{ {
label: 'Create network (smart)', label: 'Create network (smart)',
icon: 'fa-plus', icon: 'fa-plus',
@@ -2083,6 +2367,20 @@ export async function askUserConfirm(title, body, opts = {}) {
export function initOpsApp({ navigateToView, sendCommand }) { export function initOpsApp({ navigateToView, sendCommand }) {
applySettings() applySettings()
// Client backup auto-rotation scheduler (local packages only)
try {
startAutoBackupScheduler({
appVersion: document.getElementById('settings-app-version')?.textContent?.trim(),
onRun: (result) => {
if (result.ran) {
showAlert('info', 'Automatic client backup created', { badge: false })
}
},
})
} catch (err) {
console.warn('[ops] backup scheduler failed to start', err)
}
// Track F extras (volume browser, schedules, scale, secrets, …) // Track F extras (volume browser, schedules, scale, secrets, …)
import('./track-f-extras.js') import('./track-f-extras.js')
.then((mod) => { .then((mod) => {
@@ -2248,6 +2546,7 @@ export function initOpsApp({ navigateToView, sendCommand }) {
partial.templateListUrls = getTemplateListUrls() partial.templateListUrls = getTemplateListUrls()
const next = saveSettings(partial) const next = saveSettings(partial)
startListAutoRefresh(next.refreshSeconds) startListAutoRefresh(next.refreshSeconds)
persistBackupPolicyFromForm()
if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.() if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.()
finish(true) finish(true)
showAlert('success', 'Preferences saved', { badge: false }) showAlert('success', 'Preferences saved', { badge: false })
@@ -2257,6 +2556,112 @@ export function initOpsApp({ navigateToView, sendCommand }) {
}, 220) }, 220)
}) })
// —— Backup & restore ——
document.getElementById('settings-backup-create-btn')?.addEventListener('click', () => {
runCreateBackup(false)
})
document.getElementById('settings-backup-download-btn')?.addEventListener('click', () => {
runCreateBackup(true)
})
document.getElementById('settings-backup-refresh-btn')?.addEventListener('click', () => {
fillBackupForm()
renderBackupHistory()
setBackupStatus('History refreshed')
})
document.getElementById('settings-backup-rotate-btn')?.addEventListener('click', () => {
try {
persistBackupPolicyFromForm()
const store = loadBackupStore()
const before = store.backups.length
store.backups = applyRetention(store.backups, store.policy)
saveBackupStore(store)
const removed = before - store.backups.length
renderBackupHistory()
setBackupStatus(
removed > 0 ? `Rotated — removed ${removed} backup${removed === 1 ? '' : 's'}` : 'Retention already satisfied',
removed > 0 ? 'success' : 'muted'
)
if (removed > 0) showAlert('info', `Rotated backup history (${removed})`, { badge: false })
} catch (err) {
setBackupStatus(err?.message || 'Rotate failed', 'danger')
}
})
document.getElementById('settings-backup-clear-history-btn')?.addEventListener('click', async () => {
const ok = await confirmDestructive(
'Clear backup history?',
'Removes all local backup packages from this client. Downloaded files are not deleted.',
'CLEAR'
)
if (!ok) return
const n = clearBackupHistory()
renderBackupHistory()
setBackupStatus(n ? `Cleared ${n} backup${n === 1 ? '' : 's'}` : 'History was already empty', 'success')
})
document.getElementById('settings-backup-restore-file-btn')?.addEventListener('click', () => {
document.getElementById('settings-backup-file-input')?.click()
})
document.getElementById('settings-backup-file-input')?.addEventListener('change', async (e) => {
const input = /** @type {HTMLInputElement} */ (e.target)
const file = input.files?.[0]
input.value = ''
if (!file) return
try {
const text = await file.text()
const parsed = parseBackupText(text)
if (!parsed.ok) {
setBackupStatus(parsed.error, 'danger')
showAlert('danger', parsed.error)
return
}
// Keep a copy in history as import, then restore
importBackupToHistory(parsed.package, { label: file.name.replace(/\.json$/i, '') })
renderBackupHistory()
await runRestorePackage(parsed.package)
} catch (err) {
setBackupStatus(err?.message || 'Could not read backup file', 'danger')
showAlert('danger', err?.message || 'Could not read backup file')
}
})
document.getElementById('settings-backup-history')?.addEventListener('click', async (e) => {
const btn = /** @type {HTMLElement|null} */ (e.target?.closest?.('[data-backup-action]'))
if (!btn) return
const id = btn.getAttribute('data-id')
const action = btn.getAttribute('data-backup-action')
if (!id || !action) return
const rec = getBackupById(id)
if (!rec) {
setBackupStatus('Backup not found', 'warning')
renderBackupHistory()
return
}
if (action === 'download') {
try {
const dl = downloadBackupPackage(rec.package)
setBackupStatus(`Downloaded ${dl.filename}`, 'success')
} catch (err) {
setBackupStatus(err?.message || 'Download failed', 'danger')
}
return
}
if (action === 'restore') {
await runRestorePackage(rec.package)
return
}
if (action === 'pin') {
setBackupPinned(id, !rec.pinned)
renderBackupHistory()
setBackupStatus(rec.pinned ? 'Unpinned' : 'Pinned — exempt from rotation', 'success')
return
}
if (action === 'delete') {
const ok = await askUserConfirm('Delete this backup?', 'Remove this package from local history only.')
if (!ok) return
deleteBackup(id)
renderBackupHistory()
setBackupStatus('Backup deleted')
}
})
document.getElementById('settings-template-url-add')?.addEventListener('click', () => { document.getElementById('settings-template-url-add')?.addEventListener('click', () => {
addTemplateUrlFromInput() addTemplateUrlFromInput()
}) })
@@ -2465,6 +2870,10 @@ export function initOpsApp({ navigateToView, sendCommand }) {
loadTunnelsView, loadTunnelsView,
loadSwarmView, loadSwarmView,
loadSettingsView, loadSettingsView,
fillBackupForm,
renderBackupHistory,
persistBackupPolicyFromForm,
readBackupForm,
createTunnelFromForm, createTunnelFromForm,
closeTunnelById, closeTunnelById,
connectLocalTunnel, connectLocalTunnel,
+19
View File
@@ -1113,6 +1113,25 @@
text-align: left; text-align: left;
} }
/* Settings → Backup history */
.settings-backup-history {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.5rem;
overflow: hidden;
}
.settings-backup-history-item {
padding: 0.75rem 1rem;
}
.settings-backup-history-item .settings-backup-actions .btn {
min-width: 2.25rem;
}
.settings-backup-include .form-check-label {
font-size: 0.9rem;
}
/* kbd chips in about/settings */ /* kbd chips in about/settings */
kbd { kbd {
background: #1a222e; background: #1a222e;