refactor(webui): begin backup god-file decomposition + sequential feature scripts

- kernel/lifecycle.js: ctx.loadScripts now loads in array order (sequential),
  so a feature can list a base file before files that augment it. Strictly
  safer than the previous parallel load.
- Extract the module-level schema/retention data (the BACKUP_* maps + the
  retention-preset detector, 83 lines) out of backup-page.js into a new
  backup-schema.js, loaded first. Verbatim move — no logic change. First slice
  of the backup decomposition (god-file: 2553 -> 2470 lines).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-30 01:14:46 +01:00
parent 86fc41fe77
commit eeb1baf563
4 changed files with 100 additions and 87 deletions

View File

@ -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',
],

View File

@ -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() {

View File

@ -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';
}

View File

@ -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).