1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

refactor(settings/backup): migrate backup setting module [EE-5508] (#9076)

This commit is contained in:
Oscar Zhou 2023-06-19 09:57:33 +12:00 committed by GitHub
parent caf87bb0b5
commit 9f9cdf7d43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 689 additions and 289 deletions

View file

@ -14,7 +14,7 @@ export function EnableTelemetryField() {
<div className="form-group">
<div className="col-sm-12">
<SwitchField
labelClass="col-sm-2"
labelClass="col-sm-3 col-lg-2"
label="Allow the collection of anonymous statistics"
checked={value}
name="toggle_enableTelemetry"

View file

@ -23,7 +23,7 @@ export function LogoFieldset() {
label="Use custom logo"
checked={isEnabled}
name="toggle_logo"
labelClass="col-sm-2"
labelClass="col-sm-3 col-lg-2"
disabled={isDemoQuery.data}
onChange={(checked) => setIsEnabled(checked)}
/>

View file

@ -20,7 +20,7 @@ export function ScreenBannerFieldset() {
<div className="form-group">
<div className="col-sm-12">
<SwitchField
labelClass="col-sm-2"
labelClass="col-sm-3 col-lg-2"
label="Login screen banner"
checked={isEnabled}
name="toggle_login_banner"

View file

@ -0,0 +1,68 @@
import { Download } from 'lucide-react';
import { Formik, Form } from 'formik';
import { notifySuccess } from '@/portainer/services/notifications';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { DownloadBackupPayload } from './queries/useDownloadBackupMutation';
import { useDownloadBackupMutation } from './queries';
import { validationSchema } from './BackupFileForm.validation';
import { SecurityFieldset } from './SecurityFieldset';
import { BackupFileSettings } from './types';
export function BackupFileForm() {
const downloadMutate = useDownloadBackupMutation();
const settings: BackupFileSettings = {
password: '',
passwordProtect: false,
};
return (
<Formik<BackupFileSettings>
initialValues={settings}
validationSchema={validationSchema}
onSubmit={onSubmit}
validateOnMount
>
{({ isSubmitting, isValid }) => (
<Form className="form-horizontal">
<SecurityFieldset
switchDataCy="settings-passwordProtectLocal"
inputDataCy="settings-backupLocalPassword"
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
loadingText="Downloading settings..."
isLoading={isSubmitting}
disabled={!isValid}
className="!ml-0"
icon={Download}
>
Download backup
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
async function onSubmit(values: BackupFileSettings) {
const payload: DownloadBackupPayload = {
password: '',
};
if (values.passwordProtect) {
payload.password = values.password;
}
downloadMutate.mutate(payload, {
onSuccess() {
notifySuccess('Success', 'Downloaded backup successfully');
},
});
}
}

View file

@ -0,0 +1,15 @@
import { SchemaOf, object, string, boolean } from 'yup';
import { BackupFileSettings } from './types';
export function validationSchema(): SchemaOf<BackupFileSettings> {
return object({
passwordProtect: boolean().default(false),
password: string()
.default('')
.when('passwordProtect', {
is: true,
then: (schema) => schema.required('This field is required.'),
}),
});
}

View file

@ -0,0 +1,251 @@
import { Formik, Form, Field } from 'formik';
import { Upload } from 'lucide-react';
import clsx from 'clsx';
import {
isLimitedToBE,
isBE,
} from '@/react/portainer/feature-flags/feature-flags.service';
import { success as notifySuccess } from '@/portainer/services/notifications';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { FormControl } from '@@/form-components/FormControl';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { Input } from '@@/form-components/Input';
import { SwitchField } from '@@/form-components/SwitchField';
import {
useBackupS3Settings,
useExportS3BackupMutation,
useUpdateBackupS3SettingsMutation,
} from './queries';
import { BackupS3Model, BackupS3Settings } from './types';
import { validationSchema } from './BackupS3Form.validation';
import { SecurityFieldset } from './SecurityFieldset';
export function BackupS3Form() {
const limitedToBE = isLimitedToBE(FeatureId.S3_BACKUP_SETTING);
const exportS3Mutate = useExportS3BackupMutation();
const updateS3Mutate = useUpdateBackupS3SettingsMutation();
const settingsQuery = useBackupS3Settings({ enabled: isBE });
if (settingsQuery.isLoading) {
return null;
}
const settings = settingsQuery.data;
const backupS3Settings = {
password: settings?.password || '',
cronRule: settings?.cronRule || '',
accessKeyID: settings?.accessKeyID || '',
secretAccessKey: settings?.secretAccessKey || '',
region: settings?.region || '',
bucketName: settings?.bucketName || '',
s3CompatibleHost: settings?.s3CompatibleHost || '',
scheduleAutomaticBackup: !!settings?.cronRule,
passwordProtect: !!settings?.password,
};
return (
<Formik<BackupS3Settings>
initialValues={backupS3Settings}
validationSchema={validationSchema}
onSubmit={onSubmit}
validateOnMount
>
{({ values, errors, isSubmitting, setFieldValue, isValid }) => (
<Form className="form-horizontal">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="schedule-automatic-backup"
labelClass="col-sm-3 col-lg-2"
label="Schedule automatic backups"
checked={values.scheduleAutomaticBackup}
featureId={FeatureId.S3_BACKUP_SETTING}
onChange={(e) => setFieldValue('scheduleAutomaticBackup', e)}
/>
</div>
</div>
{values.scheduleAutomaticBackup && (
<FormControl
inputId="cron_rule"
label="Cron rule"
size="small"
errors={errors.cronRule}
required
>
<Field
id="cron_rule"
name="cronRule"
type="text"
as={Input}
placeholder="0 2 * * *"
data-cy="settings-backupCronRuleInput"
className={clsx({ 'limited-be': limitedToBE })}
disabled={limitedToBE}
/>
</FormControl>
)}
<FormControl
label="Access key ID"
inputId="access_key_id"
errors={errors.accessKeyID}
>
<Field
id="access_key_id"
name="accessKeyID"
type="text"
as={Input}
data-cy="settings-accessKeyIdInput"
className={clsx({ 'limited-be': limitedToBE })}
disabled={limitedToBE}
/>
</FormControl>
<FormControl
label="Secret access key"
inputId="secret_access_key"
errors={errors.secretAccessKey}
>
<Field
id="secret_access_key"
name="secretAccessKey"
type="password"
as={Input}
data-cy="settings-secretAccessKeyInput"
className={clsx({ 'limited-be': limitedToBE })}
disabled={limitedToBE}
/>
</FormControl>
<FormControl label="Region" inputId="region" errors={errors.region}>
<Field
id="region"
name="region"
type="text"
as={Input}
placeholder="default region is us-east-1 if left empty"
data-cy="settings-backupRegionInput"
className={clsx({ 'limited-be': limitedToBE })}
disabled={limitedToBE}
/>
</FormControl>
<FormControl
label="Bucket name"
inputId="bucket_name"
errors={errors.bucketName}
>
<Field
id="bucket_name"
name="bucketName"
type="text"
as={Input}
data-cy="settings-backupBucketNameInput"
className={clsx({ 'limited-be': limitedToBE })}
disabled={limitedToBE}
/>
</FormControl>
<FormControl
label="S3 compatible host"
inputId="s3_compatible_host"
tooltip="Hostname of a S3 service"
errors={errors.s3CompatibleHost}
>
<Field
id="s3_compatible_host"
name="s3CompatibleHost"
type="text"
as={Input}
placeholder="leave empty for AWS S3"
data-cy="settings-backupS3CompatibleHostInput"
className={clsx({ 'limited-be': limitedToBE })}
disabled={limitedToBE}
/>
</FormControl>
<SecurityFieldset
switchDataCy="settings-passwordProtectToggleS3"
inputDataCy="settings-backups3pw"
disabled={limitedToBE}
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
type="button"
loadingText="Exporting..."
isLoading={isSubmitting}
className={clsx('!ml-0', { 'limited-be': limitedToBE })}
disabled={!isValid || limitedToBE}
data-cy="settings-exportBackupS3Button"
icon={Upload}
onClick={() => {
handleExport(values);
}}
>
Export backup
</LoadingButton>
</div>
</div>
<div className="form-group">
<hr />
<div className="col-sm-12">
<LoadingButton
loadingText="Saving settings..."
isLoading={isSubmitting}
className={clsx('!ml-0', { 'limited-be': limitedToBE })}
disabled={!isValid || limitedToBE}
data-cy="settings-saveBackupSettingsButton"
>
Save backup settings
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
function handleExport(values: BackupS3Settings) {
const payload: BackupS3Model = {
password: values.passwordProtect ? values.password : '',
cronRule: values.scheduleAutomaticBackup ? values.cronRule : '',
accessKeyID: values.accessKeyID,
secretAccessKey: values.secretAccessKey,
region: values.region,
bucketName: values.bucketName,
s3CompatibleHost: values.s3CompatibleHost,
};
exportS3Mutate.mutate(payload, {
onSuccess() {
notifySuccess('Success', 'Exported backup to S3 successfully');
},
});
}
async function onSubmit(values: BackupS3Settings) {
const payload: BackupS3Model = {
password: values.passwordProtect ? values.password : '',
cronRule: values.scheduleAutomaticBackup ? values.cronRule : '',
accessKeyID: values.accessKeyID,
secretAccessKey: values.secretAccessKey,
region: values.region,
bucketName: values.bucketName,
s3CompatibleHost: values.s3CompatibleHost,
};
updateS3Mutate.mutate(payload, {
onSuccess() {
notifySuccess('Success', 'S3 backup settings saved successfully');
},
});
}
}

View file

@ -0,0 +1,59 @@
import { SchemaOf, object, string, boolean } from 'yup';
import { BackupS3Settings } from './types';
export function validationSchema(): SchemaOf<BackupS3Settings> {
return object({
passwordProtect: boolean().default(false),
password: string()
.default('')
.when('passwordProtect', {
is: true,
then: (schema) => schema.required('This field is required.'),
}),
scheduleAutomaticBackup: boolean().default(false),
cronRule: string()
.default('')
.when('scheduleAutomaticBackup', {
is: true,
then: (schema) =>
schema.required('This field is required.').when('cronRule', {
is: (val: string) => val !== '',
then: (schema) =>
schema.matches(
/^(\*(\/[1-9][0-9]*)?|([0-5]?[0-9]|6[0-9]|7[0-9])(-[0-5]?[0-9])?)(\s+(\*(\/[1-9][0-9]*)?|([0-5]?[0-9]|6[0-9]|7[0-9])(-[0-5]?[0-9])?)){4}$/,
'Please enter a valid cron rule.'
),
}),
}),
accessKeyID: string()
.default('')
.when('scheduleAutomaticBackup', {
is: true,
then: (schema) => schema.required('This field is required.'),
}),
secretAccessKey: string()
.default('')
.when('scheduleAutomaticBackup', {
is: true,
then: (schema) => schema.required('This field is required.'),
}),
region: string().default('').optional(),
bucketName: string()
.default('')
.when('scheduleAutomaticBackup', {
is: true,
then: (schema) => schema.required('This field is required.'),
}),
s3CompatibleHost: string()
.default('')
.when({
is: (val: string) => val !== '',
then: (schema) =>
schema.matches(
/^https?:\/\//,
'S3 host must begin with http:// or https://.'
),
}),
});
}

View file

@ -0,0 +1,47 @@
import { Download } from 'lucide-react';
import { useState } from 'react';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { FormSection } from '@@/form-components/FormSection';
import { BoxSelector } from '@@/BoxSelector';
import { BackupFormType, options } from './backup-options';
import { BackupFileForm } from './BackupFileForm';
import { BackupS3Form } from './BackupS3Form';
export function BackupSettingsPanel() {
const [backupType, setBackupType] = useState(options[0].value);
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetTitle icon={Download} title="Backup up Portainer" />
<WidgetBody>
<div className="form-horizontal">
<FormSection title="Backup configuration">
<div className="form-group col-sm-12 text-muted small">
This will back up your Portainer server configuration and does
not include containers.
</div>
<BoxSelector
slim
options={options}
value={backupType}
onChange={(v) => setBackupType(v)}
radioName="backup-type"
/>
{backupType === BackupFormType.S3 ? (
<BackupS3Form />
) : (
<BackupFileForm />
)}
</FormSection>
</div>
</WidgetBody>
</Widget>
</div>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { useField, Field } from 'formik';
import { FormSection } from '@@/form-components/FormSection';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { SwitchField } from '@@/form-components/SwitchField';
interface Props {
switchDataCy: string;
inputDataCy: string;
disabled?: boolean;
}
export function SecurityFieldset({
switchDataCy,
inputDataCy,
disabled,
}: Props) {
const [{ value: passwordProtect }, , { setValue: setPasswordProtect }] =
useField<boolean>('passwordProtect');
const [{ name }, { error }] = useField<string>('password');
return (
<FormSection title="Security settings">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="password-switch"
labelClass="col-sm-3 col-lg-2"
label="Password Protect"
checked={passwordProtect}
data-cy={switchDataCy}
onChange={(checked) => setPasswordProtect(checked)}
disabled={disabled}
/>
</div>
</div>
{passwordProtect && (
<FormControl
inputId="password"
label="Password"
size="small"
errors={error}
required
>
<Field
id="password"
name={name}
type="password"
as={Input}
data-cy={inputDataCy}
required
/>
</FormControl>
)}
</FormSection>
);
}

View file

@ -4,19 +4,24 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { BadgeIcon } from '@@/BadgeIcon';
export enum BackupFormType {
S3 = 's3',
File = 'file',
}
export const options = [
{
id: 'backup_file',
icon: <BadgeIcon icon={DownloadCloud} />,
label: 'Download backup file',
value: 'file',
value: BackupFormType.File,
},
{
id: 'backup_s3',
icon: <BadgeIcon icon={UploadCloud} />,
label: 'Store in S3',
description: 'Define a cron schedule',
value: 's3',
value: BackupFormType.S3,
feature: FeatureId.S3_BACKUP_SETTING,
},
];

View file

@ -0,0 +1 @@
export { BackupSettingsPanel } from './BackupSettingsPanel';

View file

@ -0,0 +1,12 @@
export function buildUrl(subResource?: string, action?: string) {
let url = 'backup';
if (subResource) {
url += `/${subResource}`;
}
if (action) {
url += `/${action}`;
}
return url;
}

View file

@ -0,0 +1,4 @@
export { useBackupS3Settings } from './useBackupS3Settings';
export { useUpdateBackupS3SettingsMutation } from './useUpdateBackupS3SettingsMutation';
export { useDownloadBackupMutation } from './useDownloadBackupMutation';
export { useExportS3BackupMutation } from './useExportS3BackupMutation';

View file

@ -0,0 +1,4 @@
export const queryKeys = {
base: () => ['settings'] as const,
backupS3Settings: () => [...queryKeys.base(), 'backupS3Settings'] as const,
};

View file

@ -0,0 +1,36 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { BackupS3Model } from '../types';
import { buildUrl } from './backupSettings.service';
import { queryKeys } from './queryKeys';
export function useBackupS3Settings<T = BackupS3Model>({
select,
enabled,
onSuccess,
}: {
select?: (settings: BackupS3Model) => T;
enabled?: boolean;
onSuccess?: (data: T) => void;
} = {}) {
return useQuery(queryKeys.backupS3Settings(), getBackupS3Settings, {
select,
enabled,
...withError('Unable to retrieve s3 backup settings'),
onSuccess,
});
}
async function getBackupS3Settings() {
try {
const { data } = await axios.get<BackupS3Model>(buildUrl('s3', 'settings'));
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve s3 backup settings');
}
}

View file

@ -0,0 +1,35 @@
import { useMutation } from 'react-query';
import { saveAs } from 'file-saver';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
import { buildUrl } from './backupSettings.service';
export interface DownloadBackupPayload {
password: string;
}
export function useDownloadBackupMutation() {
return useMutation(downloadBackup, {
...withGlobalError('Unable to download backup'),
});
}
async function downloadBackup(payload: DownloadBackupPayload) {
try {
const response = await axios.post(buildUrl(), payload, {
responseType: 'arraybuffer',
});
const file = response.data;
const filename = response.headers['content-disposition'].replace(
'attachment; filename=',
''
);
const blob = new Blob([file], { type: 'application/zip' });
return saveAs(blob, filename);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to download backup');
}
}

View file

@ -0,0 +1,24 @@
import { useMutation } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
import { BackupS3Model } from '../types';
import { buildUrl } from './backupSettings.service';
export function useExportS3BackupMutation() {
return useMutation(exportS3Backup, {
...withGlobalError('Unable to export backup to S3'),
});
}
async function exportS3Backup(payload: BackupS3Model) {
try {
const response = await axios.post(buildUrl('s3', 'execute'), payload, {});
return response.data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to export s3 backup');
}
}

View file

@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
import { BackupS3Model } from '../types';
import { buildUrl } from './backupSettings.service';
import { queryKeys } from './queryKeys';
export function useUpdateBackupS3SettingsMutation() {
const queryClient = useQueryClient();
return useMutation(updateBackupS3Settings, {
onSuccess: () =>
queryClient.invalidateQueries(queryKeys.backupS3Settings()),
...withGlobalError('Unable to save s3 backup settings'),
});
}
async function updateBackupS3Settings(payload: BackupS3Model) {
try {
const response = await axios.post(buildUrl('s3', 'settings'), payload);
return response.data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to save s3 backup settings');
}
}

View file

@ -0,0 +1,26 @@
export interface BackupS3Model {
cronRule: string;
accessKeyID: string;
secretAccessKey: string;
region: string;
bucketName: string;
password: string;
s3CompatibleHost: string;
}
export interface BackupS3Settings {
passwordProtect: boolean;
password: string;
scheduleAutomaticBackup: boolean;
cronRule: string;
accessKeyID: string;
secretAccessKey: string;
region: string;
bucketName: string;
s3CompatibleHost: string;
}
export interface BackupFileSettings {
passwordProtect: boolean;
password: string;
}