mirror of https://github.com/portainer/portainer
refactor(settings/backup): migrate backup setting module [EE-5508] (#9076)
parent
caf87bb0b5
commit
9f9cdf7d43
|
@ -10,6 +10,7 @@ import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeV
|
|||
import { withI18nSuspense } from '@/react-tools/withI18nSuspense';
|
||||
import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView';
|
||||
import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView';
|
||||
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
|
||||
|
||||
import { wizardModule } from './wizard';
|
||||
import { teamsModule } from './teams';
|
||||
|
@ -49,4 +50,8 @@ export const viewsModule = angular
|
|||
.component(
|
||||
'environmentsListView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(EnvironmentsListView))), [])
|
||||
)
|
||||
.component(
|
||||
'backupSettingsPanel',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(BackupSettingsPanel))), [])
|
||||
).name;
|
||||
|
|
|
@ -152,241 +152,5 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="download" title-text="Back up Portainer"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" ng-submit="backupPortainer()" name="backupPortainerForm">
|
||||
<div class="col-sm-12 form-section-title"> Backup configuration </div>
|
||||
<div class="text-muted small mb-3">This will back up your Portainer server configuration and does not include containers.</div>
|
||||
|
||||
<box-selector slim="true" options="backupOptions" value="formValues.backupFormType" on-change="(onBackupOptionsChange)" radio-name="'backupOptions'"></box-selector>
|
||||
|
||||
<div ng-if="formValues.backupFormType === BACKUP_FORM_TYPES.S3">
|
||||
<!-- Schedule automatic backups -->
|
||||
<div class="form-group mt-3">
|
||||
<por-switch-field
|
||||
label="'Schedule automatic backups'"
|
||||
name="'s3-backup-setting'"
|
||||
feature-id="s3BackupFeatureId"
|
||||
checked="formValues.scheduleAutomaticBackups"
|
||||
field-class="'col-sm-10'"
|
||||
label-class="'col-sm-2'"
|
||||
on-change="(onToggleAutoBackups)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
<!-- !Schedule automatic backups -->
|
||||
<!-- Cron rule -->
|
||||
<div class="form-group" ng-if="formValues.scheduleAutomaticBackups">
|
||||
<label for="cron_rule" class="col-sm-2 control-label text-left">Cron rule</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="0 2 * * *"
|
||||
id="cron_rule"
|
||||
name="cron_rule"
|
||||
ng-model="formValues.cronRule"
|
||||
limited-feature-dir="{{::s3BackupFeatureId}}"
|
||||
limited-feature-disabled
|
||||
limited-feature-class="limited-be"
|
||||
required
|
||||
cronRule
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Cron rule -->
|
||||
<!-- Access key id -->
|
||||
<div class="form-group">
|
||||
<label for="access_key_id" class="col-sm-2 control-label text-left">Access Key ID</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="access_key_id"
|
||||
name="access_key_id"
|
||||
ng-model="formValues.accessKeyId"
|
||||
ng-required="formValues.scheduleAutomaticBackups"
|
||||
limited-feature-dir="{{::s3BackupFeatureId}}"
|
||||
limited-feature-disabled
|
||||
limited-feature-class="limited-be"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Access key id -->
|
||||
<!-- Secret access key -->
|
||||
<div class="form-group">
|
||||
<label for="secret_access_key" class="col-sm-2 control-label text-left">Secret Access Key</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="secret_access_key"
|
||||
name="secret_access_key"
|
||||
ng-model="formValues.secretAccessKey"
|
||||
ng-required="formValues.scheduleAutomaticBackups"
|
||||
limited-feature-dir="{{::s3BackupFeatureId}}"
|
||||
limited-feature-disabled
|
||||
limited-feature-class="limited-be"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Secret access key -->
|
||||
<!-- Region -->
|
||||
<div class="form-group">
|
||||
<label for="region" class="col-sm-2 control-label text-left">Region</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="default region is us-east-1 if left empty"
|
||||
id="region"
|
||||
name="region"
|
||||
ng-model="formValues.region"
|
||||
ng-required="formValues.scheduleAutomaticBackups"
|
||||
limited-feature-dir="{{::s3BackupFeatureId}}"
|
||||
limited-feature-disabled
|
||||
limited-feature-class="limited-be"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Region -->
|
||||
<!-- Bucket name -->
|
||||
<div class="form-group">
|
||||
<label for="bucket_name" class="col-sm-2 control-label text-left">Bucket name</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="bucket_name"
|
||||
name="bucket_name"
|
||||
ng-model="formValues.bucketName"
|
||||
ng-required="formValues.scheduleAutomaticBackups"
|
||||
limited-feature-dir="{{::s3BackupFeatureId}}"
|
||||
limited-feature-disabled
|
||||
limited-feature-class="limited-be"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Bucket name -->
|
||||
<div class="col-sm-12 form-section-title"> Security settings </div>
|
||||
<!-- Password protect S3 -->
|
||||
<div class="form-group">
|
||||
<label for="password_protect" class="col-sm-2 control-label text-left">Password protect</label>
|
||||
<div class="col-sm-10">
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="password_protect_s3"
|
||||
name="password_protect_s3"
|
||||
ng-model="formValues.passwordProtectS3"
|
||||
data-cy="settings-passwordProtectToggleS3"
|
||||
disabled
|
||||
/><span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Password protect S3 -->
|
||||
<!-- Password S3 -->
|
||||
<div class="form-group" ng-if="formValues.passwordProtectS3">
|
||||
<label for="password" class="col-sm-2 control-label text-left">Password</label>
|
||||
<div class="col-sm-3">
|
||||
<input type="password" class="form-control" ng-model="formValues.passwordS3" id="password_S3" name="password_S3" required data-cy="settings-backups3pw" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="backupPortainerForm.password_S3.$invalid">
|
||||
<div class="small text-warning">
|
||||
<div ng-messages="backupPortainerForm.password_S3.$error">
|
||||
<p ng-message="required"> <pr-icon icon="'alert-triangle'"></pr-icon> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Password S3 -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="backupPortainerForm.$invalid"
|
||||
ng-click="exportBackup()"
|
||||
limited-feature-dir="{{::s3BackupFeatureId}}"
|
||||
limited-feature-disabled
|
||||
limited-feature-class="limited-be"
|
||||
>
|
||||
<span>
|
||||
<pr-icon icon="'upload'" class-name="'mr-1'"></pr-icon>
|
||||
Export backup
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<hr />
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="backupPortainerForm.$invalid ||state.backupInProgress"
|
||||
ng-click="saveS3BackupSettings()"
|
||||
limited-feature-dir="{{::s3BackupFeatureId}}"
|
||||
limited-feature-disabled
|
||||
limited-feature-class="limited-be"
|
||||
>
|
||||
<span>Save backup settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="formValues.backupFormType === BACKUP_FORM_TYPES.FILE">
|
||||
<div class="col-sm-12 form-section-title"> Security settings </div>
|
||||
<!-- Password protect -->
|
||||
<div class="form-group">
|
||||
<label for="password_protect" class="col-sm-2 control-label text-left">Password protect</label>
|
||||
<div class="col-sm-2">
|
||||
<label class="switch" data-cy="settings-passwordProtectLocal">
|
||||
<input type="checkbox" id="password_protect" name="password_protect" ng-model="formValues.passwordProtect" /><span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Password protect -->
|
||||
|
||||
<!-- Password -->
|
||||
<div class="form-group" ng-if="formValues.passwordProtect">
|
||||
<label for="password" class="col-sm-2 control-label text-left">Password</label>
|
||||
<div class="col-sm-3">
|
||||
<input type="password" class="form-control" ng-model="formValues.password" id="password" name="password" required data-cy="settings-backupLocalPassword" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="backupPortainerForm.password.$invalid">
|
||||
<div class="small text-warning">
|
||||
<div ng-messages="backupPortainerForm.password.$error">
|
||||
<p ng-message="required"> <pr-icon icon="'alert-triangle'"></pr-icon> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Password -->
|
||||
|
||||
<!-- actions -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-click="downloadBackup()"
|
||||
ng-disabled="backupPortainerForm.$invalid || state.backupInProgress || state.featureLimited"
|
||||
button-spinner="state.backupInProgress"
|
||||
data-cy="settings-downloadLocalBackup"
|
||||
>
|
||||
<span ng-hide="state.backupInProgress">Download backup</span>
|
||||
<span ng-show="state.backupInProgress">Downloading backup</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
<!-- backup -->
|
||||
<backup-settings-panel></backup-settings-panel>
|
||||
|
|
|
@ -1,24 +1,17 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { options } from '@/react/portainer/settings/SettingsView/backup-options';
|
||||
|
||||
angular.module('portainer.app').controller('SettingsController', [
|
||||
'$scope',
|
||||
'Notifications',
|
||||
'SettingsService',
|
||||
'StateManager',
|
||||
'BackupService',
|
||||
'FileSaver',
|
||||
function ($scope, Notifications, SettingsService, StateManager, BackupService, FileSaver) {
|
||||
$scope.s3BackupFeatureId = FeatureId.S3_BACKUP_SETTING;
|
||||
$scope.enforceDeploymentOptions = FeatureId.ENFORCE_DEPLOYMENT_OPTIONS;
|
||||
function ($scope, Notifications, SettingsService, StateManager) {
|
||||
$scope.updateSettings = updateSettings;
|
||||
$scope.handleSuccess = handleSuccess;
|
||||
$scope.requireNoteOnApplications = FeatureId.K8S_REQUIRE_NOTE_ON_APPLICATIONS;
|
||||
|
||||
$scope.backupOptions = options;
|
||||
|
||||
$scope.state = {
|
||||
actionInProgress: false,
|
||||
availableKubeconfigExpiryOptions: [
|
||||
|
@ -48,28 +41,12 @@ angular.module('portainer.app').controller('SettingsController', [
|
|||
showHTTPS: !window.ddExtension,
|
||||
};
|
||||
|
||||
$scope.BACKUP_FORM_TYPES = { S3: 's3', FILE: 'file' };
|
||||
|
||||
$scope.formValues = {
|
||||
KubeconfigExpiry: undefined,
|
||||
HelmRepositoryURL: undefined,
|
||||
BlackListedLabels: [],
|
||||
labelName: '',
|
||||
labelValue: '',
|
||||
passwordProtect: false,
|
||||
password: '',
|
||||
backupFormType: $scope.BACKUP_FORM_TYPES.FILE,
|
||||
};
|
||||
|
||||
$scope.onToggleAutoBackups = function onToggleAutoBackups(checked) {
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.formValues.scheduleAutomaticBackups = checked;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.onBackupOptionsChange = function (type, limited) {
|
||||
$scope.formValues.backupFormType = type;
|
||||
$scope.state.featureLimited = limited;
|
||||
};
|
||||
|
||||
$scope.removeFilteredContainerLabel = function (index) {
|
||||
|
@ -89,28 +66,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
|||
updateSettings(filteredSettingsPayload, 'Hidden container settings updated');
|
||||
};
|
||||
|
||||
$scope.downloadBackup = function () {
|
||||
const payload = {};
|
||||
if ($scope.formValues.passwordProtect) {
|
||||
payload.password = $scope.formValues.password;
|
||||
}
|
||||
|
||||
$scope.state.backupInProgress = true;
|
||||
|
||||
BackupService.downloadBackup(payload)
|
||||
.then(function success(data) {
|
||||
const downloadData = new Blob([data.file], { type: 'application/gzip' });
|
||||
FileSaver.saveAs(downloadData, data.name);
|
||||
Notifications.success('Success', 'Backup successfully downloaded');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to download backup');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.backupInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
// only update the values from the kube settings widget. In future separate the api endpoints
|
||||
$scope.saveKubernetesSettings = function () {
|
||||
const kubeSettingsPayload = {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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.'),
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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://.'
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
];
|
|
@ -0,0 +1 @@
|
|||
export { BackupSettingsPanel } from './BackupSettingsPanel';
|
|
@ -0,0 +1,12 @@
|
|||
export function buildUrl(subResource?: string, action?: string) {
|
||||
let url = 'backup';
|
||||
if (subResource) {
|
||||
url += `/${subResource}`;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
url += `/${action}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export { useBackupS3Settings } from './useBackupS3Settings';
|
||||
export { useUpdateBackupS3SettingsMutation } from './useUpdateBackupS3SettingsMutation';
|
||||
export { useDownloadBackupMutation } from './useDownloadBackupMutation';
|
||||
export { useExportS3BackupMutation } from './useExportS3BackupMutation';
|
|
@ -0,0 +1,4 @@
|
|||
export const queryKeys = {
|
||||
base: () => ['settings'] as const,
|
||||
backupS3Settings: () => [...queryKeys.base(), 'backupS3Settings'] as const,
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue