diff --git a/containers/libreportal/frontend/features/backup/index.js b/containers/libreportal/frontend/features/backup/index.js index 81e663e..76dcef7 100644 --- a/containers/libreportal/frontend/features/backup/index.js +++ b/containers/libreportal/frontend/features/backup/index.js @@ -14,6 +14,7 @@ LP.features.register({ routes: ['/backup', '/backup*'], // Controllers the feature needs; lazy-loaded on first mount (idempotent). scripts: [ + '/js/components/backup/backup-schema.js', '/js/components/backup/backup-page.js', '/js/components/backup/backup-app-card.js', ], diff --git a/containers/libreportal/frontend/js/components/backup/backup-page.js b/containers/libreportal/frontend/js/components/backup/backup-page.js index 1662ca3..82bf636 100644 --- a/containers/libreportal/frontend/js/components/backup/backup-page.js +++ b/containers/libreportal/frontend/js/components/backup/backup-page.js @@ -2,91 +2,8 @@ // Reads JSON snapshots written by scripts/webui/data/generators/backup/* and // dispatches actions back into the task system (which calls bash CLI). -// Retention presets — pick the persona that matches you. Each maps to the -// five underlying restic --keep-* values. "Custom" reveals the raw fields. -const BACKUP_RETENTION_PRESETS = { - 'inherit-global': { last: '', daily: '', weekly: '', monthly: '', yearly: '' }, - 'self-hosting': { last: '', daily: '30', weekly: '', monthly: '', yearly: '' }, - 'personal': { last: '', daily: '30', weekly: '', monthly: '6', yearly: '' }, - 'enterprise': { last: '', daily: '30', weekly: '', monthly: '12', yearly: '5' } -}; -const BACKUP_RETENTION_PRESET_META = { - 'inherit-global': { label: 'Inherit global retention', hint: 'Use whatever the Configuration tab specifies. Pick something else here only when this location needs a different policy.' }, - 'self-hosting': { label: 'Self-hosting', hint: '30 days of daily backups. Plenty for a homelab — covers accidental deletes and app screw-ups.' }, - 'personal': { label: 'Personal', hint: '30 days of daily backups plus 6 monthly backups. Good for personal data where "what did this look like last summer" matters.' }, - 'enterprise': { label: 'Enterprise', hint: '30 daily + 12 monthly + 5 yearly. Compliance-style retention with multi-year history.' }, - 'custom': { label: 'Custom…', hint: 'Define each retention tier yourself.' } -}; - -// Per-location field metadata. Configs.json doesn't carry titles for -// CFG_BACKUP_LOC_N_* (locations are dynamic), so we provide them inline. -// ConfigShared.generateField uses TITLE + key-based widget heuristics; the -// regexes in config-options.js / config-shared.js already cover _TYPE, -// _KEEP_*, _SECRET_KEY, _ACCOUNT_KEY for the right widgets. -const BACKUP_LOC_FIELD_DEFS = { - NAME: { title: 'Friendly name', description: 'Shown in lists and on the dashboard.' }, - ENABLED: { title: 'Enabled', description: 'Push backups to this location.' }, - ENGINE: { title: 'Engine', description: 'Backup engine used at this location.' }, - TYPE: { title: 'Type', description: 'Backend the engine uses to talk to this location.' }, - PATH_MODE: { title: 'Path Mode', description: 'Automatic uses the Default Backup Location from the Backup Engine config (one subfolder per location). Pick Custom to use a specific path (e.g. an attached drive or a NAS mount).' }, - PATH: { title: 'Custom path', description: 'Filesystem path on this server. Used only when Path is set to Custom.' }, - URI: { title: 'Repository URI (override)', description: 'Custom restic URI — leave blank to build from the fields below.' }, - SSH_USER: { title: 'SSH user', description: '' }, - SSH_HOST: { title: 'SSH host', description: '' }, - SSH_PORT: { title: 'SSH port', description: '' }, - SSH_PATH: { title: 'SSH remote path', description: 'Path on the remote host where the repo lives.' }, - SSH_AUTH: { title: 'SSH authentication', description: 'Key auth uses ~/.ssh/id_rsa on this host. Password mode pipes via sshpass — restic + borg only; kopia requires keys.' }, - SSH_PASS: { title: 'SSH password', description: 'Used only when SSH authentication is set to Password.' }, - S3_ACCESS_KEY: { title: 'S3 access key', description: '' }, - S3_SECRET_KEY: { title: 'S3 secret', description: '' }, - B2_ACCOUNT_ID: { title: 'B2 account ID', description: '' }, - B2_ACCOUNT_KEY: { title: 'B2 account key', description: '' }, - APPEND_ONLY: { title: 'Append-only', description: 'Ransomware-safe — refuse forget/prune for this location even if LibrePortal itself is compromised. Trades off automatic retention cleanup.' }, - CUSTOM_RETENTION: { title: 'Use custom retention', description: 'Otherwise this location inherits the global retention.' }, - KEEP_LAST: { title: 'Keep last', description: 'Backups to always retain.' }, - KEEP_DAILY: { title: 'Keep daily', description: 'One backup per day for this many days.' }, - KEEP_WEEKLY: { title: 'Keep weekly', description: 'One backup per week for this many weeks.' }, - KEEP_MONTHLY: { title: 'Keep monthly', description: 'One backup per month for this many months.' }, - KEEP_YEARLY: { title: 'Keep yearly', description: 'One backup per year for this many years.' } -}; - -// Fallback for the per-type field schema. The live source is the generator- -// emitted data/backup/generated/schema.json (loaded into this.locSchema and -// read via locFieldsForType); this map is only used if that fetch fails. -// Type leads each list (it shapes the rest of the form); ENGINE stays in the -// list but locFieldGroups folds it into the Advanced tab. -const BACKUP_LOC_FIELDS_BY_TYPE = { - local: ['TYPE', 'NAME', 'ENGINE', 'PATH_MODE', 'PATH', 'APPEND_ONLY'], - sftp: ['TYPE', 'NAME', 'ENGINE', 'SSH_USER', 'SSH_HOST', 'SSH_PORT', 'SSH_PATH', 'SSH_AUTH', 'SSH_PASS', 'URI', 'APPEND_ONLY'], - rest: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'], - s3: ['TYPE', 'NAME', 'ENGINE', 'URI', 'S3_ACCESS_KEY', 'S3_SECRET_KEY', 'APPEND_ONLY'], - b2: ['TYPE', 'NAME', 'ENGINE', 'URI', 'B2_ACCOUNT_ID', 'B2_ACCOUNT_KEY', 'APPEND_ONLY'], - gs: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'], - azure: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'], - rclone: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'] -}; - -// Suffixes that live in the editor's "Advanced" tab. configs.json can flag -// more via a **ADVANCED** comment marker; this set keeps the known overrides -// advanced even on legacy location.configs that predate the marker. Engine is -// here too — the system picks a sensible default, so most users never touch it. -const LOC_ADVANCED_SUFFIXES = new Set(['ENGINE', 'URI', 'SSH_PORT', 'APPEND_ONLY']); - -function backupRetentionDetectPreset(values, includeInherit = false) { - const norm = (v) => (v == null ? '' : String(v).trim()); - for (const [key, p] of Object.entries(BACKUP_RETENTION_PRESETS)) { - if (key === 'inherit-global' && !includeInherit) continue; - if (norm(values.last) === norm(p.last) && - norm(values.daily) === norm(p.daily) && - norm(values.weekly) === norm(p.weekly) && - norm(values.monthly) === norm(p.monthly) && - norm(values.yearly) === norm(p.yearly)) { - return key; - } - } - return 'custom'; -} +// Module-level schema/retention data moved to backup-schema.js (loaded first). class BackupPage { constructor() { diff --git a/containers/libreportal/frontend/js/components/backup/backup-schema.js b/containers/libreportal/frontend/js/components/backup/backup-schema.js new file mode 100644 index 0000000..c4ae6ea --- /dev/null +++ b/containers/libreportal/frontend/js/components/backup/backup-schema.js @@ -0,0 +1,90 @@ +// Backup schema + retention data — the module-level constants and the +// retention-preset detector extracted verbatim from backup-page.js. +// Loaded before backup-page.js (feature scripts array, sequential) so the +// BackupPage methods that reference these globals at runtime resolve them. + +// Retention presets — pick the persona that matches you. Each maps to the +// five underlying restic --keep-* values. "Custom" reveals the raw fields. +const BACKUP_RETENTION_PRESETS = { + 'inherit-global': { last: '', daily: '', weekly: '', monthly: '', yearly: '' }, + 'self-hosting': { last: '', daily: '30', weekly: '', monthly: '', yearly: '' }, + 'personal': { last: '', daily: '30', weekly: '', monthly: '6', yearly: '' }, + 'enterprise': { last: '', daily: '30', weekly: '', monthly: '12', yearly: '5' } +}; + +const BACKUP_RETENTION_PRESET_META = { + 'inherit-global': { label: 'Inherit global retention', hint: 'Use whatever the Configuration tab specifies. Pick something else here only when this location needs a different policy.' }, + 'self-hosting': { label: 'Self-hosting', hint: '30 days of daily backups. Plenty for a homelab — covers accidental deletes and app screw-ups.' }, + 'personal': { label: 'Personal', hint: '30 days of daily backups plus 6 monthly backups. Good for personal data where "what did this look like last summer" matters.' }, + 'enterprise': { label: 'Enterprise', hint: '30 daily + 12 monthly + 5 yearly. Compliance-style retention with multi-year history.' }, + 'custom': { label: 'Custom…', hint: 'Define each retention tier yourself.' } +}; + +// Per-location field metadata. Configs.json doesn't carry titles for +// CFG_BACKUP_LOC_N_* (locations are dynamic), so we provide them inline. +// ConfigShared.generateField uses TITLE + key-based widget heuristics; the +// regexes in config-options.js / config-shared.js already cover _TYPE, +// _KEEP_*, _SECRET_KEY, _ACCOUNT_KEY for the right widgets. +const BACKUP_LOC_FIELD_DEFS = { + NAME: { title: 'Friendly name', description: 'Shown in lists and on the dashboard.' }, + ENABLED: { title: 'Enabled', description: 'Push backups to this location.' }, + ENGINE: { title: 'Engine', description: 'Backup engine used at this location.' }, + TYPE: { title: 'Type', description: 'Backend the engine uses to talk to this location.' }, + PATH_MODE: { title: 'Path Mode', description: 'Automatic uses the Default Backup Location from the Backup Engine config (one subfolder per location). Pick Custom to use a specific path (e.g. an attached drive or a NAS mount).' }, + PATH: { title: 'Custom path', description: 'Filesystem path on this server. Used only when Path is set to Custom.' }, + URI: { title: 'Repository URI (override)', description: 'Custom restic URI — leave blank to build from the fields below.' }, + SSH_USER: { title: 'SSH user', description: '' }, + SSH_HOST: { title: 'SSH host', description: '' }, + SSH_PORT: { title: 'SSH port', description: '' }, + SSH_PATH: { title: 'SSH remote path', description: 'Path on the remote host where the repo lives.' }, + SSH_AUTH: { title: 'SSH authentication', description: 'Key auth uses ~/.ssh/id_rsa on this host. Password mode pipes via sshpass — restic + borg only; kopia requires keys.' }, + SSH_PASS: { title: 'SSH password', description: 'Used only when SSH authentication is set to Password.' }, + S3_ACCESS_KEY: { title: 'S3 access key', description: '' }, + S3_SECRET_KEY: { title: 'S3 secret', description: '' }, + B2_ACCOUNT_ID: { title: 'B2 account ID', description: '' }, + B2_ACCOUNT_KEY: { title: 'B2 account key', description: '' }, + APPEND_ONLY: { title: 'Append-only', description: 'Ransomware-safe — refuse forget/prune for this location even if LibrePortal itself is compromised. Trades off automatic retention cleanup.' }, + CUSTOM_RETENTION: { title: 'Use custom retention', description: 'Otherwise this location inherits the global retention.' }, + KEEP_LAST: { title: 'Keep last', description: 'Backups to always retain.' }, + KEEP_DAILY: { title: 'Keep daily', description: 'One backup per day for this many days.' }, + KEEP_WEEKLY: { title: 'Keep weekly', description: 'One backup per week for this many weeks.' }, + KEEP_MONTHLY: { title: 'Keep monthly', description: 'One backup per month for this many months.' }, + KEEP_YEARLY: { title: 'Keep yearly', description: 'One backup per year for this many years.' } +}; + +// Fallback for the per-type field schema. The live source is the generator- +// emitted data/backup/generated/schema.json (loaded into this.locSchema and +// read via locFieldsForType); this map is only used if that fetch fails. +// Type leads each list (it shapes the rest of the form); ENGINE stays in the +// list but locFieldGroups folds it into the Advanced tab. +const BACKUP_LOC_FIELDS_BY_TYPE = { + local: ['TYPE', 'NAME', 'ENGINE', 'PATH_MODE', 'PATH', 'APPEND_ONLY'], + sftp: ['TYPE', 'NAME', 'ENGINE', 'SSH_USER', 'SSH_HOST', 'SSH_PORT', 'SSH_PATH', 'SSH_AUTH', 'SSH_PASS', 'URI', 'APPEND_ONLY'], + rest: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'], + s3: ['TYPE', 'NAME', 'ENGINE', 'URI', 'S3_ACCESS_KEY', 'S3_SECRET_KEY', 'APPEND_ONLY'], + b2: ['TYPE', 'NAME', 'ENGINE', 'URI', 'B2_ACCOUNT_ID', 'B2_ACCOUNT_KEY', 'APPEND_ONLY'], + gs: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'], + azure: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'], + rclone: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'] +}; + +// Suffixes that live in the editor's "Advanced" tab. configs.json can flag +// more via a **ADVANCED** comment marker; this set keeps the known overrides +// advanced even on legacy location.configs that predate the marker. Engine is +// here too — the system picks a sensible default, so most users never touch it. +const LOC_ADVANCED_SUFFIXES = new Set(['ENGINE', 'URI', 'SSH_PORT', 'APPEND_ONLY']); + +function backupRetentionDetectPreset(values, includeInherit = false) { + const norm = (v) => (v == null ? '' : String(v).trim()); + for (const [key, p] of Object.entries(BACKUP_RETENTION_PRESETS)) { + if (key === 'inherit-global' && !includeInherit) continue; + if (norm(values.last) === norm(p.last) && + norm(values.daily) === norm(p.daily) && + norm(values.weekly) === norm(p.weekly) && + norm(values.monthly) === norm(p.monthly) && + norm(values.yearly) === norm(p.yearly)) { + return key; + } + } + return 'custom'; +} diff --git a/containers/libreportal/frontend/kernel/lifecycle.js b/containers/libreportal/frontend/kernel/lifecycle.js index fe44b03..7630fa0 100644 --- a/containers/libreportal/frontend/kernel/lifecycle.js +++ b/containers/libreportal/frontend/kernel/lifecycle.js @@ -23,9 +23,14 @@ this._unsubs = []; // backs ctx.sub() — SSE/bus/timer handles } - // Lazy-load the feature's controller scripts (idempotent across re-mounts). - loadScripts(list) { - return Promise.all((list || []).map(src => this.shell.loadScript(src))); + // Lazy-load the feature's controller scripts in array order (idempotent + // across re-mounts). Sequential, not parallel, so a feature can list a + // base file before files that augment it (e.g. a class definition before + // prototype-extension modules) — order in the array is honoured. + async loadScripts(list) { + for (const src of (list || [])) { + await this.shell.loadScript(src); + } } // Fetch an HTML fragment (same path the legacy handlers used).