* fix(backup): make backup settings actually apply (#871) (stable) - Wire the What-to-Backup toggles into the walker: honor backup_include_thumbnails / backup_include_photos (opt-out, default ON) and accept the UI's backup_include_archives spelling for the archived gate (the engine expected _archived, so the Archives checkbox silently never worked). - Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that node-postgres returns as a string, and the S3 path concatenated it onto the byte counter; coerce to Number at the source. - Compute the real next scheduled run (cron-parser) and return it as nextBackup; the UI read a field the API never sent and rendered a hardcoded 'Not scheduled'. A named schedule label now beats the stray default cron the UI always sent, which silently turned weekly schedules into daily 03:00 runs. - Never back up filesystem noise (.nfs* silly-renames, .DS_Store, Thumbs.db) and honor backup_exclude_patterns in the walker (previously rsync-only). - Remove the compression/encryption toggles from the configuration UI: no backend implementation exists, and collecting an encryption passphrase while uploading plaintext is a false promise. * fix(backup): close the review gaps in the settings wiring (stable) - The UI's backup_include_archives now beats the migration-seeded backup_include_archived: every install has the singular key seeded true, so the alias-only-when-absent lookup made unchecking Archives a no-op. - rsync destinations now receive the de-selected What-to-Backup paths and the noise filters as anchored --exclude args; previously rsync synced the whole storage root and the walker's selection only shaped the manifest, which then misreported what was actually transferred. - Escape regex metacharacters in the walker's glob matcher: '.nfs*' compiled to /^.nfs.*$/ whose leading dot matched any character, so files like anfs-photo.jpg were silently dropped from backups. - The Backup Coverage report now uses the same gate as the walker (new 'skipped-by-setting' status) instead of re-implementing it without the opt-out toggles and the archives alias. * fix(backup): make the coverage diagnostics agree with the walker - The coverage table shows the alias-aware flag value the gate actually used, instead of the seeded backup_include_archived shadowed by the UI's plural key (true next to a 'Gated off' badge). - skipped-by-setting paths are now counted in the coverage summary (backend, TS contract, summary card, EN/DE locales) so the totals reconcile again when Photos or Thumbnails is unchecked. - The form's thumbnail default now matches the backend's never-saved fallback (include): the checkbox no longer shows 'off' while thumbnails are being backed up, and saving an unrelated setting no longer flips the backup scope. (stable) * fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display - Saving a named schedule no longer wipes the stored custom cron: the backend already prefers the label, so the cron field stays inert for named schedules and is preserved for switching back to Custom. A custom schedule now validates the 5-field expression before saving (the backend silently fell back to daily 02:00 on a blank value). - resolveExcludedBackupPaths now also returns rows disabled via include_in_default, so rsync excludes them; the enabled-only loader hid them and rsync transferred their contents anyway. - The coverage table normalizes flag values like the walker does — Boolean('false') displayed true beside a gated-off badge. (stable)
This commit is contained in:
@@ -177,4 +177,203 @@ describe('backupService — configurable walker (backup_paths)', () => {
|
|||||||
const filesOn = await backupService.getFilesToBackup(true);
|
const filesOn = await backupService.getFilesToBackup(true);
|
||||||
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
|
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
|
||||||
|
describe('UI opt-out toggles (issue #871)', () => {
|
||||||
|
it('unchecking Thumbnails excludes thumbnails/', async () => {
|
||||||
|
seedFile('thumbnails/E1/thumb.jpg');
|
||||||
|
seedFile('events/active/E1/photo.jpg');
|
||||||
|
|
||||||
|
const files = await backupService.getFilesToBackup({
|
||||||
|
backup_include_thumbnails: false,
|
||||||
|
});
|
||||||
|
const rels = files.map((f) => f.relativePath);
|
||||||
|
|
||||||
|
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||||
|
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unchecking Photos excludes events/active', async () => {
|
||||||
|
seedFile('thumbnails/E1/thumb.jpg');
|
||||||
|
seedFile('events/active/E1/photo.jpg');
|
||||||
|
|
||||||
|
const files = await backupService.getFilesToBackup({
|
||||||
|
backup_include_photos: false,
|
||||||
|
});
|
||||||
|
const rels = files.map((f) => f.relativePath);
|
||||||
|
|
||||||
|
expect(rels).toContain('thumbnails/E1/thumb.jpg');
|
||||||
|
expect(rels).not.toContain('events/active/E1/photo.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to including everything when the keys were never saved', async () => {
|
||||||
|
seedFile('thumbnails/E1/thumb.jpg');
|
||||||
|
seedFile('events/active/E1/photo.jpg');
|
||||||
|
|
||||||
|
const files = await backupService.getFilesToBackup({});
|
||||||
|
const rels = files.map((f) => f.relativePath);
|
||||||
|
|
||||||
|
expect(rels).toContain('thumbnails/E1/thumb.jpg');
|
||||||
|
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
|
||||||
|
seedFile('events/archived/E4/archived.jpg');
|
||||||
|
|
||||||
|
const files = await backupService.getFilesToBackup({
|
||||||
|
backup_include_archives: true,
|
||||||
|
});
|
||||||
|
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the UI plural key beats the migration-seeded singular key', async () => {
|
||||||
|
// Migration seeds backup_include_archived=true on every install; the
|
||||||
|
// form only ever writes the plural key, so unchecking Archives must
|
||||||
|
// win over the stale seeded value.
|
||||||
|
seedFile('events/archived/E5/archived.jpg');
|
||||||
|
|
||||||
|
const files = await backupService.getFilesToBackup({
|
||||||
|
backup_include_archived: true, // seeded default
|
||||||
|
backup_include_archives: false, // what the admin actually chose
|
||||||
|
});
|
||||||
|
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
|
||||||
|
const excluded = await backupService.resolveExcludedBackupPaths({
|
||||||
|
backup_include_thumbnails: false,
|
||||||
|
backup_include_archives: false,
|
||||||
|
});
|
||||||
|
expect(excluded.map((r) => r.path)).toEqual(
|
||||||
|
expect.arrayContaining(['thumbnails', 'events/archived'])
|
||||||
|
);
|
||||||
|
|
||||||
|
const args = backupService.buildRsyncArgs(
|
||||||
|
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
|
||||||
|
excluded.map((r) => `/${r.path}/`)
|
||||||
|
);
|
||||||
|
const excludes = args
|
||||||
|
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(excludes).toEqual(expect.arrayContaining([
|
||||||
|
'.nfs*',
|
||||||
|
'/thumbnails/',
|
||||||
|
'/events/archived/',
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rows toggled off via include_in_default also become rsync excludes', async () => {
|
||||||
|
// The enabled-only loader hides these rows from the walker, but rsync
|
||||||
|
// syncs the whole storage root, so they must still appear as excludes.
|
||||||
|
await db('backup_paths').where('path', 'previews').update({
|
||||||
|
include_in_default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const excluded = await backupService.resolveExcludedBackupPaths({});
|
||||||
|
expect(excluded.map((r) => r.path)).toContain('previews');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
|
||||||
|
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
|
||||||
|
seedFile('thumbnails/E1/.nfs000000000000006600000008');
|
||||||
|
seedFile('events/active/E1/.DS_Store');
|
||||||
|
seedFile('events/active/E1/photo.jpg');
|
||||||
|
|
||||||
|
const files = await backupService.getFilesToBackup({});
|
||||||
|
const rels = files.map((f) => f.relativePath);
|
||||||
|
|
||||||
|
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||||
|
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
|
||||||
|
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
|
||||||
|
seedFile('events/active/E1/photo.jpg');
|
||||||
|
seedFile('events/active/E1/scratch.tmp');
|
||||||
|
|
||||||
|
const files = await backupService.getFilesToBackup({
|
||||||
|
backup_exclude_patterns: ['*.tmp'],
|
||||||
|
});
|
||||||
|
const rels = files.map((f) => f.relativePath);
|
||||||
|
|
||||||
|
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||||
|
expect(rels).not.toContain('events/active/E1/scratch.tmp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
|
||||||
|
seedFile('events/active/E1/anfs-photo.jpg');
|
||||||
|
seedFile('events/active/E1/notes-tmp');
|
||||||
|
|
||||||
|
const files = await backupService.getFilesToBackup({
|
||||||
|
backup_exclude_patterns: ['*.tmp'],
|
||||||
|
});
|
||||||
|
const rels = files.map((f) => f.relativePath);
|
||||||
|
|
||||||
|
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
|
||||||
|
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
|
||||||
|
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
|
||||||
|
expect(rels).toContain('events/active/E1/notes-tmp');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
|
||||||
|
// "next backup" was a hardcoded "tomorrow 02:00".
|
||||||
|
describe('schedule resolution + next run (issue #871)', () => {
|
||||||
|
it('a named label beats the stray default cron the UI used to send', () => {
|
||||||
|
expect(backupService.resolveScheduleCron({
|
||||||
|
backup_schedule: 'weekly',
|
||||||
|
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
|
||||||
|
})).toBe('0 3 * * 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('custom schedules use the cron field', () => {
|
||||||
|
expect(backupService.resolveScheduleCron({
|
||||||
|
backup_schedule: 'custom',
|
||||||
|
backup_schedule_cron: '15 5 * * 2',
|
||||||
|
})).toBe('15 5 * * 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default daily cron', () => {
|
||||||
|
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getNextScheduledRun is null when backups are disabled', () => {
|
||||||
|
expect(backupService.getNextScheduledRun(null)).toBeNull();
|
||||||
|
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getNextScheduledRun returns the real next weekly fire time', () => {
|
||||||
|
const iso = backupService.getNextScheduledRun({
|
||||||
|
backup_enabled: true,
|
||||||
|
backup_schedule: 'weekly',
|
||||||
|
backup_schedule_cron: '0 3 * * *',
|
||||||
|
});
|
||||||
|
const next = new Date(iso);
|
||||||
|
expect(Number.isNaN(next.getTime())).toBe(false);
|
||||||
|
expect(next.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
expect(next.getDay()).toBe(0); // Sunday
|
||||||
|
expect(next.getHours()).toBe(3); // 03:00
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
|
||||||
|
// column, node-postgres returns int8 as a string, and the S3 path did
|
||||||
|
// `backedUpSize += size` — string concatenation.
|
||||||
|
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
|
||||||
|
await db('database_backup_runs').del();
|
||||||
|
await db('database_backup_runs').insert({
|
||||||
|
backup_type: 'full',
|
||||||
|
status: 'completed',
|
||||||
|
file_path: '/backups/db/dump.sql.gz',
|
||||||
|
// Simulate the PG int8-as-string driver behaviour (sqlite stores
|
||||||
|
// whatever it is handed, so the string round-trips).
|
||||||
|
file_size_bytes: '421988',
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
completed_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = await backupService.getDatabaseBackupInfo();
|
||||||
|
expect(typeof info.size).toBe('number');
|
||||||
|
expect(info.size).toBe(421988);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+24
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.45.4",
|
"version": "3.45.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.45.4",
|
"version": "3.45.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"cron-parser": "^4.9.0",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@@ -5184,6 +5185,18 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-fetch": {
|
"node_modules/cross-fetch": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
|
||||||
@@ -8505,6 +8518,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mailparser": {
|
"node_modules/mailparser": {
|
||||||
"version": "3.9.14",
|
"version": "3.9.14",
|
||||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.14.tgz",
|
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.14.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"cron-parser": "^4.9.0",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|||||||
@@ -212,17 +212,20 @@ async function buildConfiguredPathReport(configuredRows, config) {
|
|||||||
const includedInDefault = Boolean(row.include_in_default);
|
const includedInDefault = Boolean(row.include_in_default);
|
||||||
let featureFlagValue = null;
|
let featureFlagValue = null;
|
||||||
if (row.feature_flag) {
|
if (row.feature_flag) {
|
||||||
const v = config[row.feature_flag];
|
// Alias-aware: show the value the gate actually used, not a seeded
|
||||||
featureFlagValue = v === undefined ? null : Boolean(v);
|
// canonical key shadowed by the UI's spelling. Normalize like the
|
||||||
|
// walker does — Boolean('false') is true.
|
||||||
|
const v = backupService.effectiveFlagValue(row, config);
|
||||||
|
featureFlagValue = v === undefined || v === null ? null : backupService.normalizeBoolean(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
let coverage;
|
let coverage;
|
||||||
if (!includedInDefault) {
|
if (!includedInDefault) {
|
||||||
coverage = 'skipped-by-toggle';
|
coverage = 'skipped-by-toggle';
|
||||||
} else if (row.feature_flag && featureFlagValue !== true) {
|
} else if (!backupService.backupPathIncluded(row, config)) {
|
||||||
// null (unset) and explicit false both gate the path off — matches
|
// Same gate the walker uses — feature flags (incl. the UI's
|
||||||
// the walker's normalizeBoolean semantics
|
// backup_include_archives alias) and the What-to-Backup opt-outs.
|
||||||
coverage = 'skipped-by-feature-flag';
|
coverage = row.feature_flag ? 'skipped-by-feature-flag' : 'skipped-by-setting';
|
||||||
} else if (!stat.exists) {
|
} else if (!stat.exists) {
|
||||||
coverage = 'missing-on-disk';
|
coverage = 'missing-on-disk';
|
||||||
} else {
|
} else {
|
||||||
@@ -313,6 +316,7 @@ async function getCoverageReport() {
|
|||||||
willScanCount: paths.filter((p) => p.coverage === 'will-scan').length,
|
willScanCount: paths.filter((p) => p.coverage === 'will-scan').length,
|
||||||
skippedByToggleCount: paths.filter((p) => p.coverage === 'skipped-by-toggle').length,
|
skippedByToggleCount: paths.filter((p) => p.coverage === 'skipped-by-toggle').length,
|
||||||
skippedByFeatureFlagCount: paths.filter((p) => p.coverage === 'skipped-by-feature-flag').length,
|
skippedByFeatureFlagCount: paths.filter((p) => p.coverage === 'skipped-by-feature-flag').length,
|
||||||
|
skippedBySettingCount: paths.filter((p) => p.coverage === 'skipped-by-setting').length,
|
||||||
missingOnDiskCount: paths.filter((p) => p.coverage === 'missing-on-disk').length,
|
missingOnDiskCount: paths.filter((p) => p.coverage === 'missing-on-disk').length,
|
||||||
driftCount: unconfiguredOnDisk.length,
|
driftCount: unconfiguredOnDisk.length,
|
||||||
tableMissingFallbackInUse: fallback,
|
tableMissingFallbackInUse: fallback,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const os = require('os');
|
|||||||
const { promisify } = require('util');
|
const { promisify } = require('util');
|
||||||
|
|
||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
|
const cronParser = require('cron-parser');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { queueEmail } = require('./emailProcessor');
|
const { queueEmail } = require('./emailProcessor');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -348,7 +349,10 @@ async function getDatabaseBackupInfoInternal() {
|
|||||||
return {
|
return {
|
||||||
type: recent.backup_type || 'unknown',
|
type: recent.backup_type || 'unknown',
|
||||||
backupFile: recent.file_path,
|
backupFile: recent.file_path,
|
||||||
size: recent.file_size_bytes,
|
// file_size_bytes is a bigInteger column — node-postgres returns int8
|
||||||
|
// as a STRING, and `backedUpSize += size` then concatenates instead of
|
||||||
|
// adding (issue #871: "167.6 TB" dashboard size). Coerce at the source.
|
||||||
|
size: Number(recent.file_size_bytes) || 0,
|
||||||
checksum: recent.checksum,
|
checksum: recent.checksum,
|
||||||
hasChanged,
|
hasChanged,
|
||||||
backupTime: recent.completed_at,
|
backupTime: recent.completed_at,
|
||||||
@@ -389,7 +393,11 @@ async function scanDirectory(dirPath, fileList, basePath, excludePatterns = [])
|
|||||||
|
|
||||||
const isExcluded = excludePatterns.some(pattern => {
|
const isExcluded = excludePatterns.some(pattern => {
|
||||||
if (pattern.includes('*')) {
|
if (pattern.includes('*')) {
|
||||||
const regex = new RegExp(`^${pattern.replace(/\*/g, '.*')}$`);
|
// Escape regex metacharacters before expanding the glob star — the
|
||||||
|
// raw replace turned '.nfs*' into /^.nfs.*$/ whose leading dot
|
||||||
|
// matched any character (e.g. 'anfs-photo.jpg' was excluded too).
|
||||||
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||||
|
const regex = new RegExp(`^${escaped}$`);
|
||||||
return regex.test(entry.name);
|
return regex.test(entry.name);
|
||||||
}
|
}
|
||||||
return entry.name === pattern;
|
return entry.name === pattern;
|
||||||
@@ -437,6 +445,26 @@ const LEGACY_BACKUP_PATHS = [
|
|||||||
{ path: 'business-docs', feature_flag: null },
|
{ path: 'business-docs', feature_flag: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// "What to Backup" opt-OUT toggles written by BackupConfiguration.tsx.
|
||||||
|
// Default-ON semantics: only an explicit false excludes the path, so
|
||||||
|
// installs that never saved the backup form keep backing up everything
|
||||||
|
// (issue #871: unchecking Thumbnails had no effect because these keys
|
||||||
|
// were stored but never read).
|
||||||
|
const OPT_OUT_FLAGS = {
|
||||||
|
'events/active': 'backup_include_photos',
|
||||||
|
'thumbnails': 'backup_include_thumbnails',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The UI "Archives" checkbox writes backup_include_archives (plural) while
|
||||||
|
// the feature_flag rows use backup_include_archived — accept both.
|
||||||
|
const FLAG_ALIASES = {
|
||||||
|
backup_include_archived: 'backup_include_archives',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filesystem noise that must never land in a backup: NFS silly-rename
|
||||||
|
// artifacts (issue #871 showed .nfs* files uploaded to S3) and OS metadata.
|
||||||
|
const DEFAULT_EXCLUDE_PATTERNS = ['.nfs*', '.DS_Store', 'Thumbs.db'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the walker's target subdirectories from `backup_paths`.
|
* Resolve the walker's target subdirectories from `backup_paths`.
|
||||||
*
|
*
|
||||||
@@ -456,34 +484,84 @@ const LEGACY_BACKUP_PATHS = [
|
|||||||
* Used to evaluate feature_flag gates.
|
* Used to evaluate feature_flag gates.
|
||||||
* @returns {Promise<Array<{ path: string, feature_flag: string|null }>>}
|
* @returns {Promise<Array<{ path: string, feature_flag: string|null }>>}
|
||||||
*/
|
*/
|
||||||
async function resolveBackupPaths(config) {
|
async function loadBackupPathRows({ includeDisabled = false } = {}) {
|
||||||
let rows;
|
|
||||||
try {
|
try {
|
||||||
if (!(await db.schema.hasTable('backup_paths'))) {
|
if (!(await db.schema.hasTable('backup_paths'))) {
|
||||||
logger.warn('backup_paths table missing — falling back to LEGACY_BACKUP_PATHS');
|
logger.warn('backup_paths table missing — falling back to LEGACY_BACKUP_PATHS');
|
||||||
rows = LEGACY_BACKUP_PATHS;
|
return LEGACY_BACKUP_PATHS;
|
||||||
} else {
|
}
|
||||||
rows = await db('backup_paths')
|
let query = db('backup_paths')
|
||||||
.where('include_in_default', formatBoolean(true))
|
|
||||||
.orderBy('display_order', 'asc')
|
.orderBy('display_order', 'asc')
|
||||||
.select('path', 'feature_flag');
|
.select('path', 'feature_flag', 'include_in_default');
|
||||||
if (!rows.length) {
|
if (!includeDisabled) {
|
||||||
|
query = query.where('include_in_default', formatBoolean(true));
|
||||||
|
}
|
||||||
|
const rows = await query;
|
||||||
|
if (!rows.filter((r) => normalizeBoolean(r.include_in_default)).length) {
|
||||||
logger.warn('backup_paths has no rows with include_in_default=true — falling back to LEGACY_BACKUP_PATHS');
|
logger.warn('backup_paths has no rows with include_in_default=true — falling back to LEGACY_BACKUP_PATHS');
|
||||||
rows = LEGACY_BACKUP_PATHS;
|
return LEGACY_BACKUP_PATHS;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return rows;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`Failed to query backup_paths (${err.message}) — falling back to LEGACY_BACKUP_PATHS`);
|
logger.warn(`Failed to query backup_paths (${err.message}) — falling back to LEGACY_BACKUP_PATHS`);
|
||||||
rows = LEGACY_BACKUP_PATHS;
|
return LEGACY_BACKUP_PATHS;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply feature_flag gating. A row with feature_flag='backup_include_archived'
|
// Per-row gate. Applies the UI opt-out toggles first, then feature_flag
|
||||||
// requires config.backup_include_archived to be truthy (same semantics as
|
// gating: a row with feature_flag='backup_include_archived' requires the
|
||||||
// the historical `includeArchived` parameter).
|
// corresponding config key to be truthy (same semantics as the historical
|
||||||
return rows.filter((row) => {
|
// `includeArchived` parameter).
|
||||||
|
function backupPathIncluded(row, config) {
|
||||||
|
const optOutKey = OPT_OUT_FLAGS[row.path];
|
||||||
|
if (optOutKey && config) {
|
||||||
|
const optOutValue = config[optOutKey];
|
||||||
|
if (optOutValue !== undefined && optOutValue !== null && normalizeBoolean(optOutValue) === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!row.feature_flag) return true;
|
if (!row.feature_flag) return true;
|
||||||
const flagValue = config ? config[row.feature_flag] : undefined;
|
let flagValue;
|
||||||
|
if (config) {
|
||||||
|
// The alias (backup_include_archives) is what the current UI writes;
|
||||||
|
// the canonical singular key is seeded true by migration on every
|
||||||
|
// install, so the UI value must take precedence or the checkbox can
|
||||||
|
// never turn the flag off.
|
||||||
|
const alias = FLAG_ALIASES[row.feature_flag];
|
||||||
|
if (alias && config[alias] !== undefined && config[alias] !== null) {
|
||||||
|
flagValue = config[alias];
|
||||||
|
} else {
|
||||||
|
flagValue = config[row.feature_flag];
|
||||||
|
}
|
||||||
|
}
|
||||||
return normalizeBoolean(flagValue);
|
return normalizeBoolean(flagValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The raw config value the gate actually consulted for a row's feature
|
||||||
|
// flag (alias-aware) — the coverage report shows it next to the status,
|
||||||
|
// so it must not display the shadowed seeded key.
|
||||||
|
function effectiveFlagValue(row, config) {
|
||||||
|
if (!row.feature_flag || !config) return undefined;
|
||||||
|
const alias = FLAG_ALIASES[row.feature_flag];
|
||||||
|
if (alias && config[alias] !== undefined && config[alias] !== null) {
|
||||||
|
return config[alias];
|
||||||
|
}
|
||||||
|
return config[row.feature_flag];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBackupPaths(config) {
|
||||||
|
return (await loadBackupPathRows()).filter((row) => backupPathIncluded(row, config));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rows the admin de-selected — the rsync destination needs them as
|
||||||
|
// --exclude filters because it syncs the whole storage root rather than
|
||||||
|
// the walker's file list. Includes rows with include_in_default=false,
|
||||||
|
// which the enabled-only loader would otherwise hide from rsync entirely.
|
||||||
|
async function resolveExcludedBackupPaths(config) {
|
||||||
|
const rows = await loadBackupPathRows({ includeDisabled: true });
|
||||||
|
return rows.filter((row) => {
|
||||||
|
const disabled = row.include_in_default !== undefined && !normalizeBoolean(row.include_in_default);
|
||||||
|
return disabled || !backupPathIncluded(row, config);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +652,14 @@ async function getFilesToBackupInternal(configOrIncludeArchived = true) {
|
|||||||
|
|
||||||
const targets = await resolveBackupPaths(config);
|
const targets = await resolveBackupPaths(config);
|
||||||
|
|
||||||
|
// backup_exclude_patterns was only honored by the rsync destination
|
||||||
|
// (as --exclude args); the local/S3 walker ignored it. Merge it with
|
||||||
|
// the always-on noise filters here so every destination agrees.
|
||||||
|
const configuredExcludes = Array.isArray(config.backup_exclude_patterns)
|
||||||
|
? config.backup_exclude_patterns
|
||||||
|
: [];
|
||||||
|
const excludePatterns = [...new Set([...DEFAULT_EXCLUDE_PATTERNS, ...configuredExcludes])];
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
// CRM document estate is special-cased in the comment block below
|
// CRM document estate is special-cased in the comment block below
|
||||||
// because it's the most expensive omission to recover from:
|
// because it's the most expensive omission to recover from:
|
||||||
@@ -589,7 +675,7 @@ async function getFilesToBackupInternal(configOrIncludeArchived = true) {
|
|||||||
// those values refer to do not, leaving every CRM *_path column a
|
// those values refer to do not, leaving every CRM *_path column a
|
||||||
// broken FK. scanDirectory short-circuits on ENOENT so installs
|
// broken FK. scanDirectory short-circuits on ENOENT so installs
|
||||||
// that never used CRM features won't error.
|
// that never used CRM features won't error.
|
||||||
await scanDirectory(path.join(storagePath, target.path), files, storagePath);
|
await scanDirectory(path.join(storagePath, target.path), files, storagePath, excludePatterns);
|
||||||
}
|
}
|
||||||
|
|
||||||
return files;
|
return files;
|
||||||
@@ -686,7 +772,7 @@ function validateRsyncParam(value, label) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRsyncArgs(config) {
|
function buildRsyncArgs(config, extraExcludes = []) {
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
const host = validateRsyncParam(config.backup_rsync_host, 'host');
|
const host = validateRsyncParam(config.backup_rsync_host, 'host');
|
||||||
const remotePath = validateRsyncParam(config.backup_rsync_path, 'remote path');
|
const remotePath = validateRsyncParam(config.backup_rsync_path, 'remote path');
|
||||||
@@ -713,7 +799,14 @@ function buildRsyncArgs(config) {
|
|||||||
args.push('-e', `ssh -i ${sshKey} -o StrictHostKeyChecking=no`);
|
args.push('-e', `ssh -i ${sshKey} -o StrictHostKeyChecking=no`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const excludePatterns = config.backup_exclude_patterns || [];
|
// Same noise filters as the walker, plus the de-selected backup paths
|
||||||
|
// (extraExcludes) — rsync syncs the whole storage root, so this is the
|
||||||
|
// only place the What-to-Backup selection can take effect for rsync.
|
||||||
|
const excludePatterns = [...new Set([
|
||||||
|
...DEFAULT_EXCLUDE_PATTERNS,
|
||||||
|
...(Array.isArray(config.backup_exclude_patterns) ? config.backup_exclude_patterns : []),
|
||||||
|
...extraExcludes,
|
||||||
|
])];
|
||||||
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
|
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
|
||||||
|
|
||||||
const source = `${storagePath}/`;
|
const source = `${storagePath}/`;
|
||||||
@@ -752,7 +845,11 @@ function parseRsyncStats(output) {
|
|||||||
|
|
||||||
async function performRsyncBackup(config, files) {
|
async function performRsyncBackup(config, files) {
|
||||||
const { spawnAsync } = require('../utils/safeExec');
|
const { spawnAsync } = require('../utils/safeExec');
|
||||||
const rsyncArgs = buildRsyncArgs(config);
|
// Anchored excludes for the de-selected What-to-Backup paths; rsync
|
||||||
|
// otherwise transfers the whole storage root regardless of the walker's
|
||||||
|
// file list (which only feeds manifests and file state).
|
||||||
|
const excludedPaths = await resolveExcludedBackupPaths(config);
|
||||||
|
const rsyncArgs = buildRsyncArgs(config, excludedPaths.map((row) => `/${row.path}/`));
|
||||||
const { stdout } = await spawnAsync('rsync', rsyncArgs);
|
const { stdout } = await spawnAsync('rsync', rsyncArgs);
|
||||||
const stats = parseRsyncStats(stdout);
|
const stats = parseRsyncStats(stdout);
|
||||||
|
|
||||||
@@ -1175,6 +1272,50 @@ async function runBackupInternal(isManual = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two settings cooperate here:
|
||||||
|
// - backup_schedule — UI label like "daily" / "weekly" / "custom"
|
||||||
|
// - backup_schedule_cron — actual cron expression (custom schedules)
|
||||||
|
// Older startup code read backup_schedule and crashed when it found a label
|
||||||
|
// instead of a cron expression. Resolution order: explicit cron field, then
|
||||||
|
// map known labels, then fall back to default.
|
||||||
|
const NAMED_SCHEDULES = {
|
||||||
|
hourly: '0 * * * *',
|
||||||
|
daily: '0 2 * * *',
|
||||||
|
weekly: '0 3 * * 0', // Sunday 03:00
|
||||||
|
monthly: '0 4 1 * *',
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveScheduleCron(config) {
|
||||||
|
const isCronExpression = (s) => typeof s === 'string' && /^\s*\S+(\s+\S+){4}\s*$/.test(s);
|
||||||
|
const readSetting = (key) => {
|
||||||
|
if (config && Object.prototype.hasOwnProperty.call(config, key)) {
|
||||||
|
return String(config[key] ?? '').trim();
|
||||||
|
}
|
||||||
|
if (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, key)) {
|
||||||
|
return String(parseSettingValue(config.__raw[key]) ?? '').trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
let schedule = '0 2 * * *';
|
||||||
|
const cronCandidate = readSetting('backup_schedule_cron');
|
||||||
|
const labelCandidate = readSetting('backup_schedule');
|
||||||
|
// A named label wins over the cron field: the UI always used to send its
|
||||||
|
// default cron ('0 3 * * *') alongside e.g. backup_schedule='weekly', which
|
||||||
|
// silently turned weekly schedules into daily ones (issue #871). The cron
|
||||||
|
// field only applies for 'custom' (or when no known label is set).
|
||||||
|
if (labelCandidate && labelCandidate.toLowerCase() !== 'custom' && NAMED_SCHEDULES[labelCandidate.toLowerCase()]) {
|
||||||
|
schedule = NAMED_SCHEDULES[labelCandidate.toLowerCase()];
|
||||||
|
} else if (cronCandidate && isCronExpression(cronCandidate)) {
|
||||||
|
schedule = cronCandidate;
|
||||||
|
} else if (labelCandidate && isCronExpression(labelCandidate)) {
|
||||||
|
// Back-compat: a deployment that wrote a cron expression directly into
|
||||||
|
// backup_schedule (no _cron field) still works.
|
||||||
|
schedule = labelCandidate;
|
||||||
|
}
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
async function startBackupService() {
|
async function startBackupService() {
|
||||||
try {
|
try {
|
||||||
const config = await resolveConfigWithFallback();
|
const config = await resolveConfigWithFallback();
|
||||||
@@ -1192,42 +1333,7 @@ async function startBackupService() {
|
|||||||
backupJob = null;
|
backupJob = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two settings cooperate here:
|
const schedule = resolveScheduleCron(config);
|
||||||
// - backup_schedule — UI label like "daily" / "weekly" / "custom"
|
|
||||||
// - backup_schedule_cron — actual cron expression
|
|
||||||
// The frontend writes both (BackupConfiguration.jsx). Older startup code
|
|
||||||
// here read backup_schedule and crashed when it found a label instead of
|
|
||||||
// a cron expression. Resolution order: explicit cron field, then map known
|
|
||||||
// labels, then fall back to default.
|
|
||||||
const NAMED_SCHEDULES = {
|
|
||||||
hourly: '0 * * * *',
|
|
||||||
daily: '0 2 * * *',
|
|
||||||
weekly: '0 3 * * 0', // Sunday 03:00
|
|
||||||
monthly: '0 4 1 * *',
|
|
||||||
};
|
|
||||||
const isCronExpression = (s) => typeof s === 'string' && /^\s*\S+(\s+\S+){4}\s*$/.test(s);
|
|
||||||
const readSetting = (key) => {
|
|
||||||
if (config && Object.prototype.hasOwnProperty.call(config, key)) {
|
|
||||||
return String(config[key] ?? '').trim();
|
|
||||||
}
|
|
||||||
if (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, key)) {
|
|
||||||
return String(parseSettingValue(config.__raw[key]) ?? '').trim();
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
let schedule = '0 2 * * *';
|
|
||||||
const cronCandidate = readSetting('backup_schedule_cron');
|
|
||||||
const labelCandidate = readSetting('backup_schedule');
|
|
||||||
if (cronCandidate && isCronExpression(cronCandidate)) {
|
|
||||||
schedule = cronCandidate;
|
|
||||||
} else if (labelCandidate && NAMED_SCHEDULES[labelCandidate.toLowerCase()]) {
|
|
||||||
schedule = NAMED_SCHEDULES[labelCandidate.toLowerCase()];
|
|
||||||
} else if (labelCandidate && isCronExpression(labelCandidate)) {
|
|
||||||
// Back-compat: a deployment that wrote a cron expression directly into
|
|
||||||
// backup_schedule (no _cron field) still works.
|
|
||||||
schedule = labelCandidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
backupJob = cron.schedule(schedule, async () => {
|
backupJob = cron.schedule(schedule, async () => {
|
||||||
logger.info('Starting scheduled backup');
|
logger.info('Starting scheduled backup');
|
||||||
@@ -1319,6 +1425,8 @@ async function getBackupStatus(limit = 10) {
|
|||||||
// ago looked identical to a successful one. Same "silent failure
|
// ago looked identical to a successful one. Same "silent failure
|
||||||
// not surfaced" class Stage A was designed to fight.
|
// not surfaced" class Stage A was designed to fight.
|
||||||
const lastSuccessful = runs.find(r => r.status === 'completed') || null;
|
const lastSuccessful = runs.find(r => r.status === 'completed') || null;
|
||||||
|
const config = await getBackupConfigInternal();
|
||||||
|
const nextRun = getNextScheduledRun(config);
|
||||||
// Detect zombie running rows (started >30min ago, never updated)
|
// Detect zombie running rows (started >30min ago, never updated)
|
||||||
// — these are processes that died without writing a completed_at.
|
// — these are processes that died without writing a completed_at.
|
||||||
// Surface them so the admin can tell at a glance vs a live run.
|
// Surface them so the admin can tell at a glance vs a live run.
|
||||||
@@ -1339,7 +1447,8 @@ async function getBackupStatus(limit = 10) {
|
|||||||
recentRuns: runs,
|
recentRuns: runs,
|
||||||
recentBackups: runs, // Alias for frontend compatibility
|
recentBackups: runs, // Alias for frontend compatibility
|
||||||
totalBackups: runs.filter(r => r.status === 'completed').length,
|
totalBackups: runs.filter(r => r.status === 'completed').length,
|
||||||
nextScheduledRun: getNextScheduledRun()
|
nextScheduledRun: nextRun,
|
||||||
|
nextBackup: nextRun // BackupManagement.tsx reads this name
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get backup status:', error);
|
logger.error('Failed to get backup status:', error);
|
||||||
@@ -1351,12 +1460,19 @@ async function getBackupStatus(limit = 10) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNextScheduledRun() {
|
function getNextScheduledRun(config) {
|
||||||
const now = new Date();
|
// null → the UI shows "Not scheduled". Only a real, enabled schedule
|
||||||
const next = new Date(now);
|
// produces a date (issue #871: this used to be a hardcoded "tomorrow
|
||||||
next.setDate(now.getDate() + 1);
|
// 02:00" that ignored the configured schedule entirely).
|
||||||
next.setHours(2, 0, 0, 0);
|
if (!config || !normalizeBoolean(config.backup_enabled)) {
|
||||||
return next.toISOString();
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return cronParser.parseExpression(resolveScheduleCron(config)).next().toISOString();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not compute next backup run: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupOldBackupRuns(retentionDays = 30) {
|
async function cleanupOldBackupRuns(retentionDays = 30) {
|
||||||
@@ -1569,5 +1685,13 @@ service.getBackupStatus = getBackupStatus;
|
|||||||
service.cleanupOldBackupRuns = cleanupOldBackupRuns;
|
service.cleanupOldBackupRuns = cleanupOldBackupRuns;
|
||||||
service.getBackupManifest = getBackupManifest;
|
service.getBackupManifest = getBackupManifest;
|
||||||
service.validateBackupManifest = validateBackupManifest;
|
service.validateBackupManifest = validateBackupManifest;
|
||||||
|
service.resolveBackupPaths = resolveBackupPaths;
|
||||||
|
service.resolveExcludedBackupPaths = resolveExcludedBackupPaths;
|
||||||
|
service.backupPathIncluded = backupPathIncluded;
|
||||||
|
service.effectiveFlagValue = effectiveFlagValue;
|
||||||
|
service.normalizeBoolean = normalizeBoolean;
|
||||||
|
service.buildRsyncArgs = buildRsyncArgs;
|
||||||
|
service.resolveScheduleCron = resolveScheduleCron;
|
||||||
|
service.getNextScheduledRun = getNextScheduledRun;
|
||||||
|
|
||||||
module.exports = service;
|
module.exports = service;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Cloud,
|
Cloud,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
AlertCircle,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Wifi,
|
Wifi,
|
||||||
@@ -102,7 +101,9 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
|
|||||||
backup_include_database: true,
|
backup_include_database: true,
|
||||||
backup_include_photos: true,
|
backup_include_photos: true,
|
||||||
backup_include_archives: true,
|
backup_include_archives: true,
|
||||||
backup_include_thumbnails: false,
|
// Matches the backend never-saved fallback (include everything) so the
|
||||||
|
// form does not show "off" while thumbnails are in fact being backed up.
|
||||||
|
backup_include_thumbnails: true,
|
||||||
backup_include_temp: false,
|
backup_include_temp: false,
|
||||||
backup_compression: true,
|
backup_compression: true,
|
||||||
backup_encryption: false,
|
backup_encryption: false,
|
||||||
@@ -111,8 +112,7 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
|
|||||||
|
|
||||||
const [showSecrets, setShowSecrets] = useState({
|
const [showSecrets, setShowSecrets] = useState({
|
||||||
s3_secret_key: false,
|
s3_secret_key: false,
|
||||||
ssh_key: false,
|
ssh_key: false
|
||||||
encryption_passphrase: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [testingConnection, setTestingConnection] = useState(false);
|
const [testingConnection, setTestingConnection] = useState(false);
|
||||||
@@ -152,6 +152,15 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A custom schedule needs a real 5-field cron — the backend silently
|
||||||
|
// falls back to daily 02:00 otherwise. For named schedules the stored
|
||||||
|
// cron is kept (the backend prefers the label), so switching back to
|
||||||
|
// Custom keeps the previously saved expression.
|
||||||
|
if (formData.backup_schedule === 'custom' && !/^\s*\S+(\s+\S+){4}\s*$/.test(formData.backup_schedule_cron)) {
|
||||||
|
toast.error(t('backup.configuration.messages.invalidCron', 'Please enter a valid cron expression (5 fields)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onSave(formData);
|
onSave(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -546,69 +555,6 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Advanced Options */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.advancedOptions.title')}</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.backup_compression}
|
|
||||||
onChange={(e) => handleChange('backup_compression', e.target.checked)}
|
|
||||||
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
|
||||||
/>
|
|
||||||
<div className="ml-3">
|
|
||||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.compression')}</span>
|
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center mb-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.backup_encryption}
|
|
||||||
onChange={(e) => handleChange('backup_encryption', e.target.checked)}
|
|
||||||
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
|
||||||
/>
|
|
||||||
<div className="ml-3">
|
|
||||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.encryption')}</span>
|
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{formData.backup_encryption && (
|
|
||||||
<div className="ml-7">
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
||||||
{t('backup.configuration.advancedOptions.encryptionPassphrase')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
type={showSecrets.encryption_passphrase ? 'text' : 'password'}
|
|
||||||
value={formData.backup_encryption_passphrase}
|
|
||||||
onChange={(e) => handleChange('backup_encryption_passphrase', e.target.value)}
|
|
||||||
placeholder={t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
|
|
||||||
required={formData.backup_encryption}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowSecrets(prev => ({ ...prev, encryption_passphrase: !prev.encryption_passphrase }))}
|
|
||||||
className="absolute top-1/2 -translate-y-1/2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
|
||||||
>
|
|
||||||
{showSecrets.encryption_passphrase ? <EyeOff size={20} /> : <Eye size={20} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-red-600">
|
|
||||||
<AlertCircle className="inline h-3 w-3 mr-1" />
|
|
||||||
{t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -258,6 +258,12 @@ const SummaryCard: React.FC<{
|
|||||||
value={String(summary.skippedByFeatureFlagCount)}
|
value={String(summary.skippedByFeatureFlagCount)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{summary.skippedBySettingCount > 0 && (
|
||||||
|
<Row
|
||||||
|
label={t('backup.coverage.summary.skippedBySetting', 'Skipped (backup settings)')}
|
||||||
|
value={String(summary.skippedBySettingCount)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{summary.missingOnDiskCount > 0 && (
|
{summary.missingOnDiskCount > 0 && (
|
||||||
<Row
|
<Row
|
||||||
label={t('backup.coverage.summary.missingOnDisk', 'Missing on disk')}
|
label={t('backup.coverage.summary.missingOnDisk', 'Missing on disk')}
|
||||||
@@ -380,6 +386,10 @@ const CoverageBadge: React.FC<{ coverage: BackupPathCoverage }> = ({ coverage })
|
|||||||
tone: 'neutral',
|
tone: 'neutral',
|
||||||
label: t('backup.coverage.coverage.skippedByFlag', 'Gated off'),
|
label: t('backup.coverage.coverage.skippedByFlag', 'Gated off'),
|
||||||
},
|
},
|
||||||
|
'skipped-by-setting': {
|
||||||
|
tone: 'neutral',
|
||||||
|
label: t('backup.coverage.coverage.skippedBySetting', 'Off (backup settings)'),
|
||||||
|
},
|
||||||
'missing-on-disk': {
|
'missing-on-disk': {
|
||||||
tone: 'amber',
|
tone: 'amber',
|
||||||
label: t('backup.coverage.coverage.missingOnDisk', 'Missing on disk'),
|
label: t('backup.coverage.coverage.missingOnDisk', 'Missing on disk'),
|
||||||
|
|||||||
@@ -416,6 +416,7 @@
|
|||||||
"willScan": "Wird gescannt",
|
"willScan": "Wird gescannt",
|
||||||
"skippedByToggle": "Übersprungen (Schalter aus)",
|
"skippedByToggle": "Übersprungen (Schalter aus)",
|
||||||
"skippedByFlag": "Übersprungen (Feature-Flag)",
|
"skippedByFlag": "Übersprungen (Feature-Flag)",
|
||||||
|
"skippedBySetting": "Übersprungen (Backup-Einstellungen)",
|
||||||
"missingOnDisk": "Auf Festplatte fehlend",
|
"missingOnDisk": "Auf Festplatte fehlend",
|
||||||
"drift": "Nicht konfiguriert auf Festplatte (Drift)"
|
"drift": "Nicht konfiguriert auf Festplatte (Drift)"
|
||||||
},
|
},
|
||||||
@@ -430,6 +431,7 @@
|
|||||||
"willScan": "Wird gescannt",
|
"willScan": "Wird gescannt",
|
||||||
"skippedByToggle": "Aus",
|
"skippedByToggle": "Aus",
|
||||||
"skippedByFlag": "Per Flag aus",
|
"skippedByFlag": "Per Flag aus",
|
||||||
|
"skippedBySetting": "In Backup-Einstellungen aus",
|
||||||
"missingOnDisk": "Auf Festplatte fehlend"
|
"missingOnDisk": "Auf Festplatte fehlend"
|
||||||
},
|
},
|
||||||
"drift": {
|
"drift": {
|
||||||
|
|||||||
@@ -2862,6 +2862,7 @@
|
|||||||
"willScan": "Will scan",
|
"willScan": "Will scan",
|
||||||
"skippedByToggle": "Skipped (toggle off)",
|
"skippedByToggle": "Skipped (toggle off)",
|
||||||
"skippedByFlag": "Skipped (feature flag)",
|
"skippedByFlag": "Skipped (feature flag)",
|
||||||
|
"skippedBySetting": "Skipped (backup settings)",
|
||||||
"missingOnDisk": "Missing on disk",
|
"missingOnDisk": "Missing on disk",
|
||||||
"drift": "Unconfigured on disk (drift)"
|
"drift": "Unconfigured on disk (drift)"
|
||||||
},
|
},
|
||||||
@@ -2876,6 +2877,7 @@
|
|||||||
"willScan": "Will scan",
|
"willScan": "Will scan",
|
||||||
"skippedByToggle": "Off",
|
"skippedByToggle": "Off",
|
||||||
"skippedByFlag": "Gated off",
|
"skippedByFlag": "Gated off",
|
||||||
|
"skippedBySetting": "Off (backup settings)",
|
||||||
"missingOnDisk": "Missing on disk"
|
"missingOnDisk": "Missing on disk"
|
||||||
},
|
},
|
||||||
"drift": {
|
"drift": {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const BackupManagement: React.FC = () => {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Clock className="h-5 w-5 text-neutral-400" />
|
<Clock className="h-5 w-5 text-neutral-400" />
|
||||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('backup.status.nextBackup')}: {backupStatus?.nextBackup || t('backup.status.notScheduled')}
|
{t('backup.status.nextBackup')}: {backupStatus?.nextBackup ? fmtDateTime(backupStatus.nextBackup) : t('backup.status.notScheduled')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ export type BackupPathCoverage =
|
|||||||
| 'will-scan'
|
| 'will-scan'
|
||||||
| 'skipped-by-toggle'
|
| 'skipped-by-toggle'
|
||||||
| 'skipped-by-feature-flag'
|
| 'skipped-by-feature-flag'
|
||||||
|
| 'skipped-by-setting'
|
||||||
| 'missing-on-disk';
|
| 'missing-on-disk';
|
||||||
|
|
||||||
export interface BackupCoveragePath {
|
export interface BackupCoveragePath {
|
||||||
@@ -289,6 +290,7 @@ export interface BackupCoverageReport {
|
|||||||
willScanCount: number;
|
willScanCount: number;
|
||||||
skippedByToggleCount: number;
|
skippedByToggleCount: number;
|
||||||
skippedByFeatureFlagCount: number;
|
skippedByFeatureFlagCount: number;
|
||||||
|
skippedBySettingCount: number;
|
||||||
missingOnDiskCount: number;
|
missingOnDiskCount: number;
|
||||||
driftCount: number;
|
driftCount: number;
|
||||||
tableMissingFallbackInUse: boolean;
|
tableMissingFallbackInUse: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user