Files
nodewarden/webapp/src/components/backup-center/BackupDestinationDetail.tsx
T
shuaiplus f0ace28bf2 feat: add shared API utilities for handling requests and responses
- Introduced `shared.ts` with utility functions for API interactions, including JSON parsing, error handling, and content disposition parsing.
- Added `vault.ts` to manage vault-related operations such as folder and cipher management, including creation, deletion, and bulk operations.
- Implemented encryption and decryption methods for secure data handling within the vault.
- Created `backup-settings-repair.ts` to automatically repair backup settings for admin profiles if needed.
2026-03-15 04:17:09 +08:00

528 lines
24 KiB
TypeScript

import { CloudUpload, Save, Trash2 } from 'lucide-preact';
import type {
BackupDestinationRecord,
E3BackupDestination,
RemoteBackupBrowserResponse,
WebDavBackupDestination,
} from '@/lib/api/backup';
import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/lib/backup-center';
import type { RecommendedProvider } from '@/lib/backup-recommendations';
import { RemoteBackupBrowser } from './RemoteBackupBrowser';
import { t } from '@/lib/i18n';
interface BackupDestinationDetailProps {
selectedRecommendedProvider: RecommendedProvider | null;
selectedDestination: BackupDestinationRecord | null;
selectedDestinationIsSaved: boolean;
canRunSelectedDestination: boolean;
canBrowseSelectedDestination: boolean;
disableWhileBusy: boolean;
loadingSettings: boolean;
savingSettings: boolean;
runningRemoteBackup: boolean;
availableTimeZones: string[];
remoteBrowser: RemoteBackupBrowserResponse | null;
remoteBrowserVisibleItems: RemoteBackupBrowserResponse['items'];
remoteBrowserCurrentPage: number;
remoteBrowserTotalPages: number;
loadingRemoteBrowser: boolean;
downloadingRemotePath: string;
restoringRemotePath: string;
deletingRemotePath: string;
onSaveSettings: () => void;
onToggleSchedule: () => void;
onRunRemoteBackup: () => void;
onPromptDeleteDestination: () => void;
onUpdateDestination: (mutator: (destination: BackupDestinationRecord) => BackupDestinationRecord) => void;
onRefreshRemoteBrowser: () => void;
onShowRemoteBrowserPath: (path: string) => void;
onDownloadRemoteBackup: (path: string) => void;
onRestoreRemoteBackup: (path: string) => void;
onPromptDeleteRemoteBackup: (path: string) => void;
onChangeRemoteBrowserPage: (page: number) => void;
}
function renderRecommendedProviderDetails(provider: RecommendedProvider) {
switch (provider.id) {
case 'koofr':
return (
<>
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
{t('txt_backup_recommend_koofr_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
{t('txt_backup_recommend_koofr_step_5_suffix')}
</div>
</div>
<div className="backup-recommendation-inline-note">{t('txt_backup_recommend_koofr_dav_intro')}</div>
<div className="backup-recommendation-dav-list">
<div className="backup-recommendation-dav-item">
<strong>{t('txt_backup_recommend_koofr_dav_self')}</strong>
<code>https://app.koofr.net/dav/Koofr</code>
</div>
<div className="backup-recommendation-dav-item">
<strong>Google Drive</strong>
<code>https://app.koofr.net/dav/Google Drive</code>
</div>
<div className="backup-recommendation-dav-item">
<strong>OneDrive</strong>
<code>https://app.koofr.net/dav/OneDrive</code>
</div>
<div className="backup-recommendation-dav-item">
<strong>Dropbox</strong>
<code>https://app.koofr.net/dav/Dropbox</code>
</div>
</div>
</>
);
case 'pcloud':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
</div>
</div>
);
case 'infinicloud':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
<a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a>
{t('txt_backup_recommend_infinicloud_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
</div>
</div>
);
}
}
export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
const timeZones = Array.from(new Set([
...COMMON_TIME_ZONES,
...props.availableTimeZones,
]));
if (props.selectedRecommendedProvider) {
return (
<section className="backup-detail-panel">
<div className="backup-recommendation-card">
<div className="backup-recommendation-header">
<div>
<strong>{props.selectedRecommendedProvider.name}</strong>
<div className="backup-inline-note">
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
: t('txt_backup_recommend_pcloud_summary')}
</div>
</div>
<span className="backup-destination-type">{props.selectedRecommendedProvider.capacity}</span>
</div>
<div className="backup-recommendation-actions">
<a className="btn btn-primary small" href={props.selectedRecommendedProvider.signupUrl} target="_blank" rel="noreferrer">
{props.selectedRecommendedProvider.hasAffiliateLink ? t('txt_backup_recommend_open_signup_aff') : t('txt_backup_recommend_open_signup')}
</a>
</div>
{renderRecommendedProviderDetails(props.selectedRecommendedProvider)}
</div>
</section>
);
}
return (
<section className="backup-detail-panel">
<div className="section-head">
<h3>{t('txt_backup_destination_detail_title')}</h3>
{props.selectedDestination ? (
<div className="actions">
<button type="button" className="btn btn-primary small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onSaveSettings}>
<Save size={14} className="btn-icon" />
{props.savingSettings ? t('txt_backup_saving') : t('txt_backup_save_settings')}
</button>
<button type="button" className="btn btn-secondary small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onToggleSchedule}>
{props.selectedDestination.schedule.enabled ? t('txt_backup_disable_action') : t('txt_backup_enable_action')}
</button>
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || !props.canRunSelectedDestination} onClick={props.onRunRemoteBackup}>
<CloudUpload size={14} className="btn-icon" />
{props.runningRemoteBackup ? t('txt_backup_running_now') : t('txt_backup_run_manual')}
</button>
<button type="button" className="btn btn-danger small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onPromptDeleteDestination}>
<Trash2 size={14} className="btn-icon" />
{t('txt_backup_delete_destination')}
</button>
</div>
) : null}
</div>
{!props.selectedDestination ? (
<div className="backup-browser-empty">{t('txt_backup_select_destination')}</div>
) : (
<>
<div className="backup-name-row">
<label className="field backup-name-field">
<span>{t('txt_backup_destination_name')}</span>
<input
className="input"
value={props.selectedDestination.name}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({ ...destination, name: (event.currentTarget as HTMLInputElement).value }))}
/>
</label>
<label className="field backup-type-field">
<span>{t('txt_backup_type')}</span>
<input className="input" value={getDestinationTypeLabel(props.selectedDestination.type)} disabled />
</label>
</div>
<div className="field-grid backup-detail-schedule-grid">
<label className="field">
<span>{t('txt_backup_frequency')}</span>
<select
className="input"
value={props.selectedDestination.schedule.frequency}
disabled={props.loadingSettings || props.disableWhileBusy}
onChange={(event) => props.onUpdateDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
frequency: (event.currentTarget as HTMLSelectElement).value as 'daily' | 'weekly' | 'monthly',
dayOfWeek: destination.schedule.dayOfWeek ?? 1,
dayOfMonth: destination.schedule.dayOfMonth ?? 1,
},
}))}
>
<option value="daily">{t('txt_backup_frequency_daily')}</option>
<option value="weekly">{t('txt_backup_frequency_weekly')}</option>
<option value="monthly">{t('txt_backup_frequency_monthly')}</option>
</select>
</label>
<label className="field">
<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 className="field">
<span>{t('txt_backup_timezone')}</span>
<select
className="input"
value={props.selectedDestination.schedule.timezone}
disabled={props.loadingSettings || props.disableWhileBusy}
onChange={(event) => props.onUpdateDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
timezone: (event.currentTarget as HTMLSelectElement).value,
},
}))}
>
{timeZones.map((timezone) => (
<option key={timezone} value={timezone}>{timezone}</option>
))}
</select>
</label>
<label className="field">
<span>{t('txt_backup_retention_count')}</span>
<div className="backup-retention-input">
<input
className="input"
type="number"
min="1"
step="1"
value={props.selectedDestination.schedule.retentionCount === null ? '' : String(props.selectedDestination.schedule.retentionCount)}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="30"
onInput={(event) => {
const nextValue = (event.currentTarget as HTMLInputElement).value.trim();
props.onUpdateDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
retentionCount: nextValue ? Number(nextValue) : null,
},
}));
}}
/>
<span className="backup-retention-suffix">{t('txt_backup_retention_count_suffix')}</span>
</div>
</label>
</div>
{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>
) : 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' ? (
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_backup_webdav_url')}</span>
<input
className="input"
value={(props.selectedDestination.destination as WebDavBackupDestination).baseUrl}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="https://dav.example.com/remote.php/dav/files/admin"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as WebDavBackupDestination),
baseUrl: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_webdav_username')}</span>
<input
className="input"
value={(props.selectedDestination.destination as WebDavBackupDestination).username}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as WebDavBackupDestination),
username: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_webdav_password')}</span>
<input
className="input"
type="password"
value={(props.selectedDestination.destination as WebDavBackupDestination).password}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as WebDavBackupDestination),
password: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field field-span-2">
<span>{t('txt_backup_webdav_path')}</span>
<input
className="input"
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as WebDavBackupDestination),
remotePath: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
</div>
) : null}
{props.selectedDestination.type === 'e3' ? (
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_backup_e3_endpoint')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).endpoint}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="https://s3.example.com"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
endpoint: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_bucket')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).bucket}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
bucket: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_region')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).region}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="auto"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
region: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_access_key')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).accessKeyId}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
accessKeyId: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_secret_key')}</span>
<input
className="input"
type="password"
value={(props.selectedDestination.destination as E3BackupDestination).secretAccessKey}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
secretAccessKey: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field field-span-2">
<span>{t('txt_backup_e3_path')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).rootPath}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
rootPath: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
</div>
) : null}
<RemoteBackupBrowser
canBrowse={props.canBrowseSelectedDestination}
destinationIsSaved={props.selectedDestinationIsSaved}
disableWhileBusy={props.disableWhileBusy}
loadingRemoteBrowser={props.loadingRemoteBrowser}
remoteBrowser={props.remoteBrowser}
visibleItems={props.remoteBrowserVisibleItems}
currentPage={props.remoteBrowserCurrentPage}
totalPages={props.remoteBrowserTotalPages}
downloadingRemotePath={props.downloadingRemotePath}
restoringRemotePath={props.restoringRemotePath}
deletingRemotePath={props.deletingRemotePath}
onRefresh={props.onRefreshRemoteBrowser}
onShowPath={props.onShowRemoteBrowserPath}
onDownload={props.onDownloadRemoteBackup}
onRestore={props.onRestoreRemoteBackup}
onPromptDelete={props.onPromptDeleteRemoteBackup}
onChangePage={props.onChangeRemoteBrowserPage}
/>
</>
)}
</section>
);
}