feat: add granular auto-backup scheduling and timezone support
Add UI controls for configuring auto-backup schedule with hour, day of week, and day of month pickers. The hour picker respects the user's 12h/24h time format preference from settings. Add TZ environment variable support via docker-compose so the container runs in the configured timezone. The timezone is passed to node-cron for accurate scheduling and exposed via the API so the UI displays it. Fix SQLite UTC timestamp handling by appending Z suffix to all timestamps sent to the client, ensuring proper timezone conversion in the browser. Made-with: Cursor
This commit is contained in:
@@ -12,6 +12,11 @@ const router = express.Router();
|
||||
|
||||
router.use(authenticate, adminOnly);
|
||||
|
||||
function utcSuffix(ts: string | null | undefined): string | null {
|
||||
if (!ts) return null;
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
router.get('/users', (req: Request, res: Response) => {
|
||||
const users = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||
@@ -21,7 +26,13 @@ router.get('/users', (req: Request, res: Response) => {
|
||||
const { getOnlineUserIds } = require('../websocket');
|
||||
onlineUserIds = getOnlineUserIds();
|
||||
} catch { /* */ }
|
||||
const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
|
||||
const usersWithStatus = users.map(u => ({
|
||||
...u,
|
||||
created_at: utcSuffix(u.created_at),
|
||||
updated_at: utcSuffix(u.updated_at as string),
|
||||
last_login: utcSuffix(u.last_login),
|
||||
online: onlineUserIds.has(u.id),
|
||||
}));
|
||||
res.json({ users: usersWithStatus });
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ function getPendingMfaSecret(userId: number): string | null {
|
||||
return row.secret;
|
||||
}
|
||||
|
||||
function utcSuffix(ts: string | null | undefined): string | null {
|
||||
if (!ts) return null;
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
function stripUserForClient(user: User): Record<string, unknown> {
|
||||
const {
|
||||
password_hash: _p,
|
||||
@@ -39,6 +44,9 @@ function stripUserForClient(user: User): Record<string, unknown> {
|
||||
} = user;
|
||||
return {
|
||||
...rest,
|
||||
created_at: utcSuffix(rest.created_at),
|
||||
updated_at: utcSuffix(rest.updated_at),
|
||||
last_login: utcSuffix(rest.last_login),
|
||||
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
|
||||
};
|
||||
}
|
||||
@@ -146,6 +154,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
||||
demo_mode: isDemo,
|
||||
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -212,17 +212,30 @@ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request,
|
||||
|
||||
router.get('/auto-settings', (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ settings: scheduler.loadSettings() });
|
||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
res.json({ settings: scheduler.loadSettings(), timezone: tz });
|
||||
} catch (err: unknown) {
|
||||
console.error('[backup] GET auto-settings:', err);
|
||||
res.status(500).json({ error: 'Could not load backup settings' });
|
||||
}
|
||||
});
|
||||
|
||||
function parseIntField(raw: unknown, fallback: number): number {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
|
||||
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseAutoBackupBody(body: Record<string, unknown>): {
|
||||
enabled: boolean;
|
||||
interval: string;
|
||||
keep_days: number;
|
||||
hour: number;
|
||||
day_of_week: number;
|
||||
day_of_month: number;
|
||||
} {
|
||||
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
|
||||
const rawInterval = body.interval;
|
||||
@@ -230,17 +243,11 @@ function parseAutoBackupBody(body: Record<string, unknown>): {
|
||||
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
|
||||
? rawInterval
|
||||
: 'daily';
|
||||
const rawKeep = body.keep_days;
|
||||
let keepNum: number;
|
||||
if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
|
||||
keepNum = Math.floor(rawKeep);
|
||||
} else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
|
||||
keepNum = parseInt(rawKeep, 10);
|
||||
} else {
|
||||
keepNum = NaN;
|
||||
}
|
||||
const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
|
||||
return { enabled, interval, keep_days };
|
||||
const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
|
||||
const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
|
||||
const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
|
||||
const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
|
||||
return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
|
||||
}
|
||||
|
||||
router.put('/auto-settings', (req: Request, res: Response) => {
|
||||
|
||||
@@ -8,30 +8,48 @@ const backupsDir = path.join(dataDir, 'backups');
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
const settingsFile = path.join(dataDir, 'backup-settings.json');
|
||||
|
||||
const CRON_EXPRESSIONS: Record<string, string> = {
|
||||
hourly: '0 * * * *',
|
||||
daily: '0 2 * * *',
|
||||
weekly: '0 2 * * 0',
|
||||
monthly: '0 2 1 * *',
|
||||
};
|
||||
|
||||
const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
|
||||
const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
|
||||
const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday
|
||||
const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
interface BackupSettings {
|
||||
enabled: boolean;
|
||||
interval: string;
|
||||
keep_days: number;
|
||||
hour: number;
|
||||
day_of_week: number;
|
||||
day_of_month: number;
|
||||
}
|
||||
|
||||
function buildCronExpression(settings: BackupSettings): string {
|
||||
const hour = VALID_HOURS.includes(settings.hour) ? settings.hour : 2;
|
||||
const dow = VALID_DAYS_OF_WEEK.includes(settings.day_of_week) ? settings.day_of_week : 0;
|
||||
const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
|
||||
|
||||
switch (settings.interval) {
|
||||
case 'hourly': return '0 * * * *';
|
||||
case 'daily': return `0 ${hour} * * *`;
|
||||
case 'weekly': return `0 ${hour} * * ${dow}`;
|
||||
case 'monthly': return `0 ${hour} ${dom} * *`;
|
||||
default: return `0 ${hour} * * *`;
|
||||
}
|
||||
}
|
||||
|
||||
let currentTask: ScheduledTask | null = null;
|
||||
|
||||
function getDefaults(): BackupSettings {
|
||||
return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 };
|
||||
}
|
||||
|
||||
function loadSettings(): BackupSettings {
|
||||
let settings = getDefaults();
|
||||
try {
|
||||
if (fs.existsSync(settingsFile)) {
|
||||
return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
||||
const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
||||
settings = { ...settings, ...saved };
|
||||
}
|
||||
} catch (e) {}
|
||||
return { enabled: false, interval: 'daily', keep_days: 7 };
|
||||
return settings;
|
||||
}
|
||||
|
||||
function saveSettings(settings: BackupSettings): void {
|
||||
@@ -104,9 +122,10 @@ function start(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
|
||||
currentTask = cron.schedule(expression, runBackup);
|
||||
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
|
||||
const expression = buildCronExpression(settings);
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
|
||||
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
|
||||
}
|
||||
|
||||
// Demo mode: hourly reset of demo user data
|
||||
|
||||
Reference in New Issue
Block a user