@@ -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
@@ -2643,7 +2643,7 @@ services:
|
||||
<div class="settings-page-scroll">
|
||||
<div class="page-header">
|
||||
<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 & restore, catalogs, connections, and about</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<button type="button" class="nav-link" data-settings-tab="terminal" role="tab">Terminal</button>
|
||||
</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">
|
||||
<button type="button" class="nav-link" data-settings-tab="about" role="tab">About</button>
|
||||
</li>
|
||||
@@ -2977,6 +2980,160 @@ services:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup & restore -->
|
||||
<div class="settings-panel hidden" id="settings-panel-backup" data-settings-panel="backup">
|
||||
<div class="settings-section">
|
||||
<h3>Backup & 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 & 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 & 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 -->
|
||||
<div class="settings-panel hidden" id="settings-panel-about" data-settings-panel="about">
|
||||
<div class="settings-section">
|
||||
|
||||
@@ -60,9 +60,10 @@ class NotificationManager {
|
||||
|
||||
/**
|
||||
* Load notifications from localStorage
|
||||
* @param {{ force?: boolean }} [opts] — force re-read (e.g. after backup restore)
|
||||
*/
|
||||
loadFromStorage() {
|
||||
if (this._storageLoaded) return
|
||||
loadFromStorage(opts = {}) {
|
||||
if (this._storageLoaded && !opts.force) return
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
@@ -80,6 +81,8 @@ class NotificationManager {
|
||||
}
|
||||
})
|
||||
this.notifications = this.notifications.slice(-MAX_NOTIFICATIONS)
|
||||
} else if (opts.force) {
|
||||
this.notifications = []
|
||||
}
|
||||
this._storageLoaded = true
|
||||
this.notify()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -37,6 +37,9 @@ const REQUIRED_IDS = [
|
||||
'host-view',
|
||||
'settings-view',
|
||||
'settings-panel-peers',
|
||||
'settings-panel-backup',
|
||||
'settings-backup-history',
|
||||
'settings-backup-download-btn',
|
||||
'connection-list',
|
||||
'stack-git-url',
|
||||
'tunnel-create-btn',
|
||||
@@ -52,6 +55,7 @@ const REQUIRED_SNIPPETS = [
|
||||
'data-view="swarm"',
|
||||
'data-view="registry"',
|
||||
'data-settings-tab="peers"',
|
||||
'data-settings-tab="backup"',
|
||||
'ENABLE_HOLESAIL',
|
||||
'GitOps',
|
||||
'collapse-sidebar-btn',
|
||||
|
||||
+409
@@ -51,6 +51,24 @@ import {
|
||||
fetchMergedTemplates,
|
||||
clearMergedTemplateCache,
|
||||
} 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 SIDEBAR_KEY = 'peardock.sidebar.collapsed'
|
||||
@@ -1721,6 +1739,14 @@ export function showSettingsTab(tab) {
|
||||
// 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)
|
||||
*/
|
||||
@@ -1832,6 +2104,9 @@ export function loadSettingsView(forceTab) {
|
||||
|
||||
templateUrlsDraft = getTemplateListUrls()
|
||||
renderTemplateUrlsEditor()
|
||||
|
||||
fillBackupForm()
|
||||
renderBackupHistory()
|
||||
}
|
||||
|
||||
function renderTemplateUrlsEditor() {
|
||||
@@ -2011,6 +2286,15 @@ export function openPalette(navigateToView) {
|
||||
{ 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: '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)',
|
||||
icon: 'fa-plus',
|
||||
@@ -2083,6 +2367,20 @@ export async function askUserConfirm(title, body, opts = {}) {
|
||||
export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
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, …)
|
||||
import('./track-f-extras.js')
|
||||
.then((mod) => {
|
||||
@@ -2248,6 +2546,7 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
partial.templateListUrls = getTemplateListUrls()
|
||||
const next = saveSettings(partial)
|
||||
startListAutoRefresh(next.refreshSeconds)
|
||||
persistBackupPolicyFromForm()
|
||||
if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.()
|
||||
finish(true)
|
||||
showAlert('success', 'Preferences saved', { badge: false })
|
||||
@@ -2257,6 +2556,112 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
}, 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', () => {
|
||||
addTemplateUrlFromInput()
|
||||
})
|
||||
@@ -2465,6 +2870,10 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
loadTunnelsView,
|
||||
loadSwarmView,
|
||||
loadSettingsView,
|
||||
fillBackupForm,
|
||||
renderBackupHistory,
|
||||
persistBackupPolicyFromForm,
|
||||
readBackupForm,
|
||||
createTunnelFromForm,
|
||||
closeTunnelById,
|
||||
connectLocalTunnel,
|
||||
|
||||
+19
@@ -1113,6 +1113,25 @@
|
||||
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 {
|
||||
background: #1a222e;
|
||||
|
||||
Reference in New Issue
Block a user