mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: refactor backup scheduling to use interval hours and update UI components
This commit is contained in:
+3
-10
@@ -1,11 +1,10 @@
|
|||||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||||
export const BACKUP_DEFAULT_SCHEDULE_TIME = '03:00';
|
|
||||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||||
export const BACKUP_DEFAULT_E3_REGION = 'auto';
|
export const BACKUP_DEFAULT_E3_REGION = 'auto';
|
||||||
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||||
|
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||||
|
|
||||||
export type BackupDestinationType = 'e3' | 'webdav';
|
export type BackupDestinationType = 'e3' | 'webdav';
|
||||||
export type BackupScheduleFrequency = 'daily' | 'weekly' | 'monthly';
|
|
||||||
|
|
||||||
export interface E3BackupDestination {
|
export interface E3BackupDestination {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -40,11 +39,8 @@ export interface BackupRuntimeState {
|
|||||||
|
|
||||||
export interface BackupScheduleConfig {
|
export interface BackupScheduleConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
frequency: BackupScheduleFrequency;
|
intervalHours: number;
|
||||||
scheduleTime: string;
|
|
||||||
timezone: string;
|
timezone: string;
|
||||||
dayOfWeek: number;
|
|
||||||
dayOfMonth: number;
|
|
||||||
retentionCount: number | null;
|
retentionCount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,11 +81,8 @@ export function createDefaultBackupRuntimeState(): BackupRuntimeState {
|
|||||||
export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFAULT_TIMEZONE): BackupScheduleConfig {
|
export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFAULT_TIMEZONE): BackupScheduleConfig {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
frequency: 'daily',
|
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||||
scheduleTime: BACKUP_DEFAULT_SCHEDULE_TIME,
|
|
||||||
timezone,
|
timezone,
|
||||||
dayOfWeek: 1,
|
|
||||||
dayOfMonth: 1,
|
|
||||||
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
|
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ import {
|
|||||||
parseBackupSettingsEnvelope,
|
parseBackupSettingsEnvelope,
|
||||||
} from './backup-settings-crypto';
|
} from './backup-settings-crypto';
|
||||||
import {
|
import {
|
||||||
BACKUP_DEFAULT_SCHEDULE_TIME,
|
BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||||
BACKUP_DEFAULT_TIMEZONE,
|
BACKUP_DEFAULT_TIMEZONE,
|
||||||
type BackupDestinationConfig,
|
type BackupDestinationConfig,
|
||||||
type BackupDestinationRecord,
|
type BackupDestinationRecord,
|
||||||
type BackupDestinationType,
|
type BackupDestinationType,
|
||||||
type BackupRuntimeState,
|
type BackupRuntimeState,
|
||||||
type BackupScheduleConfig,
|
type BackupScheduleConfig,
|
||||||
type BackupScheduleFrequency,
|
|
||||||
type BackupSettings,
|
type BackupSettings,
|
||||||
type E3BackupDestination,
|
type E3BackupDestination,
|
||||||
type WebDavBackupDestination,
|
type WebDavBackupDestination,
|
||||||
@@ -73,19 +72,6 @@ function assertValidTimeZone(timezone: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertValidScheduleTime(value: string): string {
|
|
||||||
if (!/^\d{2}:\d{2}$/.test(value)) {
|
|
||||||
throw new Error('Backup time must use HH:MM format');
|
|
||||||
}
|
|
||||||
const [hoursRaw, minutesRaw] = value.split(':');
|
|
||||||
const hours = Number(hoursRaw);
|
|
||||||
const minutes = Number(minutesRaw);
|
|
||||||
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
|
||||||
throw new Error('Backup time is invalid');
|
|
||||||
}
|
|
||||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRetentionCount(value: unknown, fallback: number | null = 30): number | null {
|
function normalizeRetentionCount(value: unknown, fallback: number | null = 30): number | null {
|
||||||
if (value === undefined) return fallback;
|
if (value === undefined) return fallback;
|
||||||
if (value === null || String(value).trim() === '') return null;
|
if (value === null || String(value).trim() === '') return null;
|
||||||
@@ -96,33 +82,12 @@ function normalizeRetentionCount(value: unknown, fallback: number | null = 30):
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeScheduleFrequency(
|
function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAULT_INTERVAL_HOURS): number {
|
||||||
value: unknown,
|
const raw = value === undefined || value === null || value === '' ? fallback : Number(value);
|
||||||
fallback: BackupScheduleFrequency = 'daily'
|
if (!Number.isInteger(raw) || raw < 1 || raw > 99) {
|
||||||
): BackupScheduleFrequency {
|
throw new Error('Backup interval hours must be between 1 and 99');
|
||||||
const frequency = asTrimmedString(value) || fallback;
|
|
||||||
if (frequency !== 'daily' && frequency !== 'weekly' && frequency !== 'monthly') {
|
|
||||||
throw new Error('Backup frequency is invalid');
|
|
||||||
}
|
}
|
||||||
return frequency;
|
return raw;
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDayOfWeek(value: unknown, fallback: number = 1): number {
|
|
||||||
if (value === undefined || value === null || value === '') return fallback;
|
|
||||||
const day = Number(value);
|
|
||||||
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
|
||||||
throw new Error('Backup day of week is invalid');
|
|
||||||
}
|
|
||||||
return day;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDayOfMonth(value: unknown, fallback: number = 1): number {
|
|
||||||
if (value === undefined || value === null || value === '') return fallback;
|
|
||||||
const day = Number(value);
|
|
||||||
if (!Number.isInteger(day) || day < 1 || day > 31) {
|
|
||||||
throw new Error('Backup day of month must be between 1 and 31');
|
|
||||||
}
|
|
||||||
return day;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
||||||
@@ -250,11 +215,11 @@ function normalizeDestinationRecord(
|
|||||||
: previousSchedule.retentionCount;
|
: previousSchedule.retentionCount;
|
||||||
const schedule: BackupScheduleConfig = {
|
const schedule: BackupScheduleConfig = {
|
||||||
enabled: !!(scheduleSource.enabled ?? previousSchedule.enabled),
|
enabled: !!(scheduleSource.enabled ?? previousSchedule.enabled),
|
||||||
frequency: normalizeScheduleFrequency(scheduleSource.frequency ?? previousSchedule.frequency, previousSchedule.frequency),
|
intervalHours: normalizeIntervalHours(
|
||||||
scheduleTime: assertValidScheduleTime(asTrimmedString(scheduleSource.scheduleTime ?? previousSchedule.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME),
|
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
|
||||||
|
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
|
||||||
|
),
|
||||||
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
dayOfWeek: normalizeDayOfWeek(scheduleSource.dayOfWeek ?? previousSchedule.dayOfWeek, previousSchedule.dayOfWeek),
|
|
||||||
dayOfMonth: normalizeDayOfMonth(scheduleSource.dayOfMonth ?? previousSchedule.dayOfMonth, previousSchedule.dayOfMonth),
|
|
||||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,6 +239,12 @@ function normalizeDestinationRecord(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
|
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
|
||||||
|
const legacyFrequency = asTrimmedString(rawValue.frequency).toLowerCase();
|
||||||
|
const intervalHours = legacyFrequency === 'weekly'
|
||||||
|
? 24 * 7
|
||||||
|
: legacyFrequency === 'monthly'
|
||||||
|
? 24 * 30
|
||||||
|
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
||||||
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
||||||
const destinationType: BackupDestinationType =
|
const destinationType: BackupDestinationType =
|
||||||
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
|
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
|
||||||
@@ -287,11 +258,8 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
|
|||||||
destination: normalizeDestination(destinationType, rawValue.destination),
|
destination: normalizeDestination(destinationType, rawValue.destination),
|
||||||
schedule: {
|
schedule: {
|
||||||
enabled: !!rawValue.enabled,
|
enabled: !!rawValue.enabled,
|
||||||
frequency: 'daily',
|
intervalHours,
|
||||||
scheduleTime: assertValidScheduleTime(asTrimmedString(rawValue.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME),
|
|
||||||
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
dayOfWeek: 1,
|
|
||||||
dayOfMonth: 1,
|
|
||||||
retentionCount: 30,
|
retentionCount: 30,
|
||||||
},
|
},
|
||||||
runtime: normalizeRuntime(rawValue.runtime),
|
runtime: normalizeRuntime(rawValue.runtime),
|
||||||
@@ -338,10 +306,15 @@ export function parseBackupSettings(raw: string | null, fallbackTimezone: string
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
if (Array.isArray(parsed.destinations)) {
|
if (Array.isArray(parsed.destinations)) {
|
||||||
const globalScheduleTime = assertValidScheduleTime(asTrimmedString(parsed.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME);
|
|
||||||
const globalTimezone = assertValidTimeZone(asTrimmedString(parsed.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE);
|
const globalTimezone = assertValidTimeZone(asTrimmedString(parsed.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE);
|
||||||
const globalEnabled = !!parsed.enabled;
|
const globalEnabled = !!parsed.enabled;
|
||||||
const activeDestinationIdRaw = asTrimmedString(parsed.activeDestinationId);
|
const activeDestinationIdRaw = asTrimmedString(parsed.activeDestinationId);
|
||||||
|
const globalFrequency = asTrimmedString(parsed.frequency).toLowerCase();
|
||||||
|
const globalIntervalHours = globalFrequency === 'weekly'
|
||||||
|
? 24 * 7
|
||||||
|
: globalFrequency === 'monthly'
|
||||||
|
? 24 * 30
|
||||||
|
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
||||||
const previousById = new Map<string, BackupDestinationRecord>();
|
const previousById = new Map<string, BackupDestinationRecord>();
|
||||||
const normalizedEntries = (parsed.destinations as unknown[]).map((entry) => {
|
const normalizedEntries = (parsed.destinations as unknown[]).map((entry) => {
|
||||||
if (!isPlainObject(entry)) return entry;
|
if (!isPlainObject(entry)) return entry;
|
||||||
@@ -352,11 +325,8 @@ export function parseBackupSettings(raw: string | null, fallbackTimezone: string
|
|||||||
...entry,
|
...entry,
|
||||||
schedule: {
|
schedule: {
|
||||||
enabled: scheduleEnabled,
|
enabled: scheduleEnabled,
|
||||||
frequency: 'daily',
|
intervalHours: globalIntervalHours,
|
||||||
scheduleTime: globalScheduleTime,
|
|
||||||
timezone: globalTimezone,
|
timezone: globalTimezone,
|
||||||
dayOfWeek: 1,
|
|
||||||
dayOfMonth: 1,
|
|
||||||
retentionCount: 30,
|
retentionCount: 30,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -515,29 +485,6 @@ function getDateTimeParts(date: Date, timezone: string): { year: string; month:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocalWeekday(date: Date, timezone: string): number {
|
|
||||||
const value = new Intl.DateTimeFormat('en-US', {
|
|
||||||
timeZone: timezone,
|
|
||||||
weekday: 'short',
|
|
||||||
}).format(date);
|
|
||||||
const map: Record<string, number> = {
|
|
||||||
Sun: 0,
|
|
||||||
Mon: 1,
|
|
||||||
Tue: 2,
|
|
||||||
Wed: 3,
|
|
||||||
Thu: 4,
|
|
||||||
Fri: 5,
|
|
||||||
Sat: 6,
|
|
||||||
};
|
|
||||||
return map[value] ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMonthLastDay(date: Date, timezone: string): number {
|
|
||||||
const { year, month } = getDateTimeParts(date, timezone);
|
|
||||||
const utcDate = new Date(Date.UTC(Number(year), Number(month), 0));
|
|
||||||
return utcDate.getUTCDate();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBackupLocalDateKey(date: Date, timezone: string): string {
|
export function getBackupLocalDateKey(date: Date, timezone: string): string {
|
||||||
const parts = getDateTimeParts(date, timezone);
|
const parts = getDateTimeParts(date, timezone);
|
||||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
@@ -548,32 +495,15 @@ export function getBackupLocalTime(date: Date, timezone: string): string {
|
|||||||
return `${parts.hour}:${parts.minute}`;
|
return `${parts.hour}:${parts.minute}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMinutes(value: string): number {
|
|
||||||
const [hoursRaw, minutesRaw] = value.split(':');
|
|
||||||
return Number(hoursRaw) * 60 + Number(minutesRaw);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isBackupDueNow(
|
export function isBackupDueNow(
|
||||||
destination: BackupDestinationRecord,
|
destination: BackupDestinationRecord,
|
||||||
now: Date,
|
now: Date,
|
||||||
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!destination.schedule.enabled) return false;
|
if (!destination.schedule.enabled) return false;
|
||||||
|
const intervalMs = destination.schedule.intervalHours * 60 * 60 * 1000;
|
||||||
const currentMinutes = toMinutes(getBackupLocalTime(now, destination.schedule.timezone));
|
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||||
const scheduledMinutes = toMinutes(destination.schedule.scheduleTime);
|
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||||
const delta = currentMinutes - scheduledMinutes;
|
if (!lastAttemptAt || !Number.isFinite(lastAttemptAt.getTime())) return true;
|
||||||
if (delta < 0 || delta >= windowMinutes) return false;
|
return now.getTime() - lastAttemptAt.getTime() >= Math.max(0, intervalMs - toleranceMs);
|
||||||
|
|
||||||
if (destination.schedule.frequency === 'weekly') {
|
|
||||||
return getLocalWeekday(now, destination.schedule.timezone) === destination.schedule.dayOfWeek;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (destination.schedule.frequency === 'monthly') {
|
|
||||||
const currentDay = Number(getDateTimeParts(now, destination.schedule.timezone).day);
|
|
||||||
const scheduledDay = Math.min(destination.schedule.dayOfMonth, getMonthLastDay(now, destination.schedule.timezone));
|
|
||||||
return currentDay === scheduledDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import type {
|
|||||||
RemoteBackupBrowserResponse,
|
RemoteBackupBrowserResponse,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '@/lib/api/backup';
|
} from '@/lib/api/backup';
|
||||||
import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/lib/backup-center';
|
import { COMMON_TIME_ZONES, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||||
import { RemoteBackupBrowser } from './RemoteBackupBrowser';
|
import { RemoteBackupBrowser } from './RemoteBackupBrowser';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
||||||
|
|
||||||
|
const INTERVAL_HOUR_PRESETS = [1, 6, 12, 24];
|
||||||
|
|
||||||
interface BackupDestinationDetailProps {
|
interface BackupDestinationDetailProps {
|
||||||
selectedRecommendedProvider: RecommendedProvider | null;
|
selectedRecommendedProvider: RecommendedProvider | null;
|
||||||
selectedDestination: BackupDestinationRecord | null;
|
selectedDestination: BackupDestinationRecord | null;
|
||||||
@@ -206,41 +208,53 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
|
|
||||||
<div className="field-grid backup-detail-schedule-grid">
|
<div className="field-grid backup-detail-schedule-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_frequency')}</span>
|
<span>{t('txt_backup_interval_hours')}</span>
|
||||||
<select
|
<div className="backup-interval-row">
|
||||||
className="input"
|
<div className="backup-inline-suffix-wrap">
|
||||||
value={props.selectedDestination.schedule.frequency}
|
<input
|
||||||
|
className="input backup-inline-suffix-input"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
value={String(props.selectedDestination.schedule.intervalHours || 24)}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
onChange={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => {
|
||||||
|
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
||||||
|
const value = Math.min(99, Math.max(1, Number(raw || 1)));
|
||||||
|
props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
schedule: {
|
schedule: {
|
||||||
...destination.schedule,
|
...destination.schedule,
|
||||||
frequency: (event.currentTarget as HTMLSelectElement).value as 'daily' | 'weekly' | 'monthly',
|
intervalHours: value,
|
||||||
dayOfWeek: destination.schedule.dayOfWeek ?? 1,
|
},
|
||||||
dayOfMonth: destination.schedule.dayOfMonth ?? 1,
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="backup-inline-suffix">{t('txt_backup_interval_hours_suffix')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
||||||
|
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
||||||
|
const active = preset === props.selectedDestination.schedule.intervalHours;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
className={`backup-interval-preset${active ? ' active' : ''}`}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onClick={() => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
intervalHours: preset,
|
||||||
},
|
},
|
||||||
}))}
|
}))}
|
||||||
>
|
>
|
||||||
<option value="daily">{t('txt_backup_frequency_daily')}</option>
|
{preset}
|
||||||
<option value="weekly">{t('txt_backup_frequency_weekly')}</option>
|
</button>
|
||||||
<option value="monthly">{t('txt_backup_frequency_monthly')}</option>
|
);
|
||||||
</select>
|
})}
|
||||||
</label>
|
</div>
|
||||||
<label className="field">
|
</div>
|
||||||
<span>{t('txt_backup_time')}</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="time"
|
|
||||||
value={props.selectedDestination.schedule.scheduleTime}
|
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
|
||||||
...destination,
|
|
||||||
schedule: {
|
|
||||||
...destination.schedule,
|
|
||||||
scheduleTime: (event.currentTarget as HTMLInputElement).value,
|
|
||||||
},
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_timezone')}</span>
|
<span>{t('txt_backup_timezone')}</span>
|
||||||
@@ -263,17 +277,17 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_retention_count')}</span>
|
<span>{t('txt_backup_retention_count')}</span>
|
||||||
<div className="backup-retention-input">
|
<div className="backup-inline-suffix-wrap">
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input backup-inline-suffix-input"
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
inputMode="numeric"
|
||||||
step="1"
|
pattern="[0-9]*"
|
||||||
value={props.selectedDestination.schedule.retentionCount === null ? '' : String(props.selectedDestination.schedule.retentionCount)}
|
value={props.selectedDestination.schedule.retentionCount === null ? '' : String(props.selectedDestination.schedule.retentionCount)}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
placeholder="30"
|
placeholder="30"
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
const nextValue = (event.currentTarget as HTMLInputElement).value.trim();
|
const nextValue = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '').trim();
|
||||||
props.onUpdateDestination((destination) => ({
|
props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
schedule: {
|
schedule: {
|
||||||
@@ -283,11 +297,12 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="backup-retention-suffix">{t('txt_backup_retention_count_suffix')}</span>
|
<span className="backup-inline-suffix">{t('txt_backup_retention_count_suffix')}</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-schedule-attachments-row">
|
||||||
<BackupIncludeAttachmentsField
|
<BackupIncludeAttachmentsField
|
||||||
checked={props.selectedDestination.includeAttachments}
|
checked={props.selectedDestination.includeAttachments}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
@@ -296,54 +311,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
includeAttachments: checked,
|
includeAttachments: checked,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{props.selectedDestination.schedule.frequency === 'weekly' ? (
|
|
||||||
<div className="field-grid backup-detail-schedule-extra-grid">
|
|
||||||
<label className="field">
|
|
||||||
<span>{t('txt_backup_day_of_week')}</span>
|
|
||||||
<select
|
|
||||||
className="input"
|
|
||||||
value={String(props.selectedDestination.schedule.dayOfWeek)}
|
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
|
||||||
onChange={(event) => props.onUpdateDestination((destination) => ({
|
|
||||||
...destination,
|
|
||||||
schedule: {
|
|
||||||
...destination.schedule,
|
|
||||||
dayOfWeek: Number((event.currentTarget as HTMLSelectElement).value),
|
|
||||||
},
|
|
||||||
}))}
|
|
||||||
>
|
|
||||||
{WEEKDAY_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={String(option.value)}>{t(option.label)}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{props.selectedDestination.schedule.frequency === 'monthly' ? (
|
|
||||||
<div className="field-grid backup-detail-schedule-extra-grid">
|
|
||||||
<label className="field">
|
|
||||||
<span>{t('txt_backup_day_of_month')}</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="31"
|
|
||||||
step="1"
|
|
||||||
value={String(props.selectedDestination.schedule.dayOfMonth || 1)}
|
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
|
||||||
...destination,
|
|
||||||
schedule: {
|
|
||||||
...destination.schedule,
|
|
||||||
dayOfMonth: Math.min(31, Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1)),
|
|
||||||
},
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{props.selectedDestination.type === 'webdav' ? (
|
{props.selectedDestination.type === 'webdav' ? (
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface BackupIncludeAttachmentsFieldProps {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
showHelp?: boolean;
|
showHelp?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export function BackupIncludeAttachmentsField(props: BackupIncludeAttachmentsFie
|
|||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
onInput={(event) => props.onChange((event.currentTarget as HTMLInputElement).checked)}
|
onInput={(event) => props.onChange((event.currentTarget as HTMLInputElement).checked)}
|
||||||
/>
|
/>
|
||||||
<span>{t('txt_backup_include_attachments')}</span>
|
{props.showLabel !== false ? <span>{t('txt_backup_include_attachments')}</span> : null}
|
||||||
</label>
|
</label>
|
||||||
{props.showHelp !== false ? (
|
{props.showHelp !== false ? (
|
||||||
<div ref={wrapRef} className={`backup-help-wrap ${open ? 'open' : ''}`}>
|
<div ref={wrapRef} className={`backup-help-wrap ${open ? 'open' : ''}`}>
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_backup_destination_reserved: "Reserved Slot",
|
txt_backup_destination_reserved: "Reserved Slot",
|
||||||
txt_backup_time: "Backup Time",
|
txt_backup_time: "Backup Time",
|
||||||
txt_backup_timezone: "Timezone",
|
txt_backup_timezone: "Timezone",
|
||||||
|
txt_backup_interval_hours: "Every",
|
||||||
|
txt_backup_interval_hours_suffix: "hours",
|
||||||
|
txt_backup_interval_hours_presets: "Quick interval presets",
|
||||||
txt_backup_frequency: "Frequency",
|
txt_backup_frequency: "Frequency",
|
||||||
txt_backup_frequency_daily: "Daily",
|
txt_backup_frequency_daily: "Daily",
|
||||||
txt_backup_frequency_weekly: "Weekly",
|
txt_backup_frequency_weekly: "Weekly",
|
||||||
@@ -759,6 +762,9 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_backup_destination_reserved: '预留位置',
|
txt_backup_destination_reserved: '预留位置',
|
||||||
txt_backup_time: '备份时间',
|
txt_backup_time: '备份时间',
|
||||||
txt_backup_timezone: '时区',
|
txt_backup_timezone: '时区',
|
||||||
|
txt_backup_interval_hours: '每隔',
|
||||||
|
txt_backup_interval_hours_suffix: '小时',
|
||||||
|
txt_backup_interval_hours_presets: '快捷时间预设',
|
||||||
txt_backup_frequency: '备份频率',
|
txt_backup_frequency: '备份频率',
|
||||||
txt_backup_frequency_daily: '每天',
|
txt_backup_frequency_daily: '每天',
|
||||||
txt_backup_frequency_weekly: '每周',
|
txt_backup_frequency_weekly: '每周',
|
||||||
|
|||||||
+95
-21
@@ -1443,7 +1443,6 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-option-label {
|
.backup-option-label {
|
||||||
@@ -1749,7 +1748,49 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-detail-schedule-grid {
|
.backup-detail-schedule-grid {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-interval-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 86px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-interval-presets {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-interval-preset {
|
||||||
|
height: 22px;
|
||||||
|
border: 1px solid #cdd7e6;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-interval-preset:hover:not(:disabled) {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-interval-preset.active {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-interval-preset:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-retention-input {
|
.backup-retention-input {
|
||||||
@@ -1765,6 +1806,31 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-inline-suffix-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-inline-suffix-input {
|
||||||
|
padding-right: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-inline-suffix {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-schedule-attachments-row {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.backup-retention-suffix {
|
.backup-retention-suffix {
|
||||||
color: #475467;
|
color: #475467;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -2772,47 +2838,51 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-head {
|
.list-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-count {
|
.list-count {
|
||||||
order: 10;
|
grid-column: auto;
|
||||||
width: 100%;
|
width: auto;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head .search-input {
|
.list-head .search-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-icon-btn {
|
.list-icon-btn {
|
||||||
width: 38px;
|
width: auto;
|
||||||
min-width: 38px;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0 12px;
|
||||||
font-size: 0;
|
font-size: 13px;
|
||||||
gap: 0;
|
gap: 6px;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
|
||||||
.list-icon-btn .btn-icon {
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar.actions {
|
.toolbar.actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-end;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
overflow-x: auto;
|
overflow: visible;
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar.actions::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar.actions .btn.small {
|
.toolbar.actions .btn.small {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-fab-wrap {
|
.mobile-fab-wrap {
|
||||||
@@ -3136,6 +3206,10 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.backup-interval-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-status-grid,
|
.backup-status-grid,
|
||||||
.backup-browser-row,
|
.backup-browser-row,
|
||||||
.field-grid {
|
.field-grid {
|
||||||
|
|||||||
Reference in New Issue
Block a user