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:
@@ -11,9 +11,9 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
|
||||
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache python3 make g++ && \
|
||||
RUN apk add --no-cache tzdata python3 make g++ && \
|
||||
npm ci --production && \
|
||||
apk del python3 make g++
|
||||
|
||||
|
||||
@@ -62,28 +62,26 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
|
||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadUser()
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string }) => {
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
|
||||
// Version-based cache invalidation: clear all caches on version change
|
||||
if (config?.version) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
if (storedVersion && storedVersion !== config.version) {
|
||||
try {
|
||||
// Clear all Service Worker caches
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.map(n => caches.delete(n)))
|
||||
}
|
||||
// Unregister all service workers
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
|
||||
@@ -3,6 +3,7 @@ import { backupApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
@@ -21,19 +22,35 @@ const KEEP_OPTIONS = [
|
||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||
]
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: 0, labelKey: 'backup.dow.sunday' },
|
||||
{ value: 1, labelKey: 'backup.dow.monday' },
|
||||
{ value: 2, labelKey: 'backup.dow.tuesday' },
|
||||
{ value: 3, labelKey: 'backup.dow.wednesday' },
|
||||
{ value: 4, labelKey: 'backup.dow.thursday' },
|
||||
{ value: 5, labelKey: 'backup.dow.friday' },
|
||||
{ value: 6, labelKey: 'backup.dow.saturday' },
|
||||
]
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||
|
||||
export default function BackupPanel() {
|
||||
const [backups, setBackups] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [restoringFile, setRestoringFile] = useState(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||
const [serverTimezone, setServerTimezone] = useState('')
|
||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||
const fileInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const loadBackups = async () => {
|
||||
setIsLoading(true)
|
||||
@@ -51,6 +68,7 @@ export default function BackupPanel() {
|
||||
try {
|
||||
const data = await backupApi.getAutoSettings()
|
||||
setAutoSettings(data.settings)
|
||||
if (data.timezone) setServerTimezone(data.timezone)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -147,10 +165,12 @@ export default function BackupPanel() {
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString(locale, {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
if (serverTimezone) opts.timeZone = serverTimezone
|
||||
return new Date(dateStr).toLocaleString(locale, opts)
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
@@ -331,6 +351,76 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hour picker (for daily, weekly, monthly) */}
|
||||
{autoSettings.interval !== 'hourly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<select
|
||||
value={autoSettings.hour}
|
||||
onChange={e => handleAutoSettingsChange('hour', parseInt(e.target.value, 10))}
|
||||
className="w-full sm:w-auto px-3 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-700 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
>
|
||||
{HOURS.map(h => {
|
||||
let label: string
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
label = `${h12}:00 ${period}`
|
||||
} else {
|
||||
label = `${String(h).padStart(2, '0')}:00`
|
||||
}
|
||||
return (
|
||||
<option key={h} value={h}>
|
||||
{label}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day of week (for weekly) */}
|
||||
{autoSettings.interval === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.day_of_week === opt.value
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day of month (for monthly) */}
|
||||
{autoSettings.interval === 'monthly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<select
|
||||
value={autoSettings.day_of_month}
|
||||
onChange={e => handleAutoSettingsChange('day_of_month', parseInt(e.target.value, 10))}
|
||||
className="w-full sm:w-auto px-3 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-700 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
>
|
||||
{DAYS_OF_MONTH.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keep duration */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||
|
||||
@@ -1007,7 +1007,27 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'backup.auto.enable': 'Auto-Backup aktivieren',
|
||||
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
|
||||
'backup.auto.interval': 'Intervall',
|
||||
'backup.auto.hour': 'Ausführung um',
|
||||
'backup.auto.hourHint': 'Lokale Serverzeit ({format}-Format)',
|
||||
'backup.auto.dayOfWeek': 'Wochentag',
|
||||
'backup.auto.dayOfMonth': 'Tag des Monats',
|
||||
'backup.auto.dayOfMonthHint': 'Auf 1–28 beschränkt, um mit allen Monaten kompatibel zu sein',
|
||||
'backup.auto.scheduleSummary': 'Zeitplan',
|
||||
'backup.auto.summaryDaily': 'Täglich um {hour}:00',
|
||||
'backup.auto.summaryWeekly': 'Jeden {day} um {hour}:00',
|
||||
'backup.auto.summaryMonthly': 'Am {day}. jedes Monats um {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': 'Auto-Backup wird über Docker-Umgebungsvariablen konfiguriert. Ändern Sie Ihre docker-compose.yml und starten Sie den Container neu.',
|
||||
'backup.auto.copyEnv': 'Docker-Umgebungsvariablen kopieren',
|
||||
'backup.auto.envCopied': 'Docker-Umgebungsvariablen in die Zwischenablage kopiert',
|
||||
'backup.auto.keepLabel': 'Alte Backups löschen nach',
|
||||
'backup.dow.sunday': 'So',
|
||||
'backup.dow.monday': 'Mo',
|
||||
'backup.dow.tuesday': 'Di',
|
||||
'backup.dow.wednesday': 'Mi',
|
||||
'backup.dow.thursday': 'Do',
|
||||
'backup.dow.friday': 'Fr',
|
||||
'backup.dow.saturday': 'Sa',
|
||||
'backup.interval.hourly': 'Stündlich',
|
||||
'backup.interval.daily': 'Täglich',
|
||||
'backup.interval.weekly': 'Wöchentlich',
|
||||
|
||||
@@ -1007,7 +1007,27 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'backup.auto.enable': 'Enable auto-backup',
|
||||
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
|
||||
'backup.auto.interval': 'Interval',
|
||||
'backup.auto.hour': 'Run at hour',
|
||||
'backup.auto.hourHint': 'Server local time ({format} format)',
|
||||
'backup.auto.dayOfWeek': 'Day of week',
|
||||
'backup.auto.dayOfMonth': 'Day of month',
|
||||
'backup.auto.dayOfMonthHint': 'Limited to 1–28 for compatibility with all months',
|
||||
'backup.auto.scheduleSummary': 'Schedule',
|
||||
'backup.auto.summaryDaily': 'Every day at {hour}:00',
|
||||
'backup.auto.summaryWeekly': 'Every {day} at {hour}:00',
|
||||
'backup.auto.summaryMonthly': 'Day {day} of every month at {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': 'Auto-backup is configured via Docker environment variables. To change these settings, update your docker-compose.yml and restart the container.',
|
||||
'backup.auto.copyEnv': 'Copy Docker env vars',
|
||||
'backup.auto.envCopied': 'Docker env vars copied to clipboard',
|
||||
'backup.auto.keepLabel': 'Delete old backups after',
|
||||
'backup.dow.sunday': 'Sun',
|
||||
'backup.dow.monday': 'Mon',
|
||||
'backup.dow.tuesday': 'Tue',
|
||||
'backup.dow.wednesday': 'Wed',
|
||||
'backup.dow.thursday': 'Thu',
|
||||
'backup.dow.friday': 'Fri',
|
||||
'backup.dow.saturday': 'Sat',
|
||||
'backup.interval.hourly': 'Hourly',
|
||||
'backup.interval.daily': 'Daily',
|
||||
'backup.interval.weekly': 'Weekly',
|
||||
|
||||
@@ -52,7 +52,7 @@ interface UpdateInfo {
|
||||
}
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
const { demoMode } = useAuthStore()
|
||||
const { demoMode, serverTimezone } = useAuthStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const TABS = [
|
||||
@@ -512,10 +512,10 @@ export default function AdminPage(): React.ReactElement {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{new Date(u.created_at).toLocaleDateString(locale)}
|
||||
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
@@ -584,7 +584,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">
|
||||
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
|
||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
|
||||
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface AuthState {
|
||||
error: string | null
|
||||
demoMode: boolean
|
||||
hasMapsKey: boolean
|
||||
serverTimezone: string
|
||||
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
@@ -36,6 +37,7 @@ interface AuthState {
|
||||
deleteAvatar: () => Promise<void>
|
||||
setDemoMode: (val: boolean) => void
|
||||
setHasMapsKey: (val: boolean) => void
|
||||
setServerTimezone: (tz: string) => void
|
||||
demoLogin: () => Promise<AuthResponse>
|
||||
}
|
||||
|
||||
@@ -47,6 +49,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
error: null,
|
||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||
hasMapsKey: false,
|
||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
@@ -201,6 +204,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||
|
||||
demoLogin: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
@@ -279,6 +279,7 @@ export interface AppConfig {
|
||||
oidc_display_name?: string
|
||||
has_maps_key?: boolean
|
||||
allowed_file_types?: string
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
// Translation function type
|
||||
|
||||
@@ -6,11 +6,13 @@ export function currencyDecimals(currency: string): number {
|
||||
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string | null | undefined, locale: string): string | null {
|
||||
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
})
|
||||
}
|
||||
if (timeZone) opts.timeZone = timeZone
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
|
||||
}
|
||||
|
||||
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
- JWT_SECRET=${JWT_SECRET:-}
|
||||
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
||||
- PORT=3000
|
||||
- TZ=${TZ:-UTC}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
@@ -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