mirror of https://github.com/portainer/portainer
refactor(settings): move app settings to panel [EE-5503] (#9043)
parent
4f04fe54a7
commit
c7756f3018
|
@ -6,6 +6,7 @@ import { InternalAuth } from '@/react/portainer/settings/AuthenticationView/Inte
|
||||||
import { r2a } from '@/react-tools/react2angular';
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
import { ApplicationSettingsPanel } from '@/react/portainer/settings/SettingsView/ApplicationSettingsPanel';
|
||||||
|
|
||||||
export const settingsModule = angular
|
export const settingsModule = angular
|
||||||
.module('portainer.app.react.components.settings', [])
|
.module('portainer.app.react.components.settings', [])
|
||||||
|
@ -17,4 +18,8 @@ export const settingsModule = angular
|
||||||
.component(
|
.component(
|
||||||
'internalAuth',
|
'internalAuth',
|
||||||
r2a(InternalAuth, ['onSaveSettings', 'isLoading', 'value', 'onChange'])
|
r2a(InternalAuth, ['onSaveSettings', 'isLoading', 'value', 'onChange'])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'applicationSettingsPanel',
|
||||||
|
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess'])
|
||||||
).name;
|
).name;
|
||||||
|
|
|
@ -1,137 +1,6 @@
|
||||||
<page-header title="'Settings'" breadcrumbs="['Settings']"> </page-header>
|
<page-header title="'Settings'" breadcrumbs="['Settings']"> </page-header>
|
||||||
|
|
||||||
<div class="row">
|
<application-settings-panel on-success="(handleSuccess)"></application-settings-panel>
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-header icon="settings" title-text="Application settings"></rd-widget-header>
|
|
||||||
<rd-widget-body>
|
|
||||||
<form class="form-horizontal">
|
|
||||||
<!-- snapshot-interval -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="snapshot_interval" class="col-sm-2 control-label text-left">Snapshot interval</label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<input type="text" class="form-control" ng-model="settings.SnapshotInterval" id="snapshot_interval" placeholder="e.g. 15m" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !snapshot-interval -->
|
|
||||||
|
|
||||||
<!-- checkin-interval -->
|
|
||||||
<edge-checkin-interval-field
|
|
||||||
size="'xsmall'"
|
|
||||||
value="settings.EdgeAgentCheckinInterval"
|
|
||||||
label="'Edge agent default poll frequency'"
|
|
||||||
is-default-hidden="true"
|
|
||||||
on-change="(onChangeCheckInInterval)"
|
|
||||||
></edge-checkin-interval-field>
|
|
||||||
<!-- !checkin-interval -->
|
|
||||||
|
|
||||||
<!-- logo -->
|
|
||||||
<div class="form-group">
|
|
||||||
<por-switch-field
|
|
||||||
label="'Use custom logo'"
|
|
||||||
checked="formValues.customLogo"
|
|
||||||
name="'toggle_logo'"
|
|
||||||
disabled="state.isDemo"
|
|
||||||
on-change="(onToggleCustomLogo)"
|
|
||||||
field-class="'col-sm-12'"
|
|
||||||
label-class="'col-sm-2'"
|
|
||||||
></por-switch-field>
|
|
||||||
<div class="col-sm-12" ng-if="state.isDemo" style="margin-top: 10px">
|
|
||||||
<span class="small text-muted">You cannot use this feature in the demo version of Portainer.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ng-if="formValues.customLogo">
|
|
||||||
<div class="form-group">
|
|
||||||
<span class="col-sm-12 text-muted small"> You can specify the URL to your logo here. For an optimal display, logo dimensions should be 155px by 55px. </span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="logo_url" class="col-sm-2 control-label text-left"> URL </label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<input type="text" class="form-control" ng-model="settings.LogoURL" id="logo_url" placeholder="https://mycompany.com/logo.png" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !logo -->
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<por-switch-field
|
|
||||||
label="'Allow the collection of anonymous statistics'"
|
|
||||||
checked="formValues.enableTelemetry"
|
|
||||||
name="'toggle_enableTelemetry'"
|
|
||||||
on-change="(onToggleEnableTelemetry)"
|
|
||||||
disabled="state.isDemo"
|
|
||||||
field-class="'col-sm-12'"
|
|
||||||
label-class="'col-sm-2'"
|
|
||||||
></por-switch-field>
|
|
||||||
<div class="col-sm-12" ng-if="state.isDemo" style="margin-top: 10px">
|
|
||||||
<span class="small text-muted">You cannot use this feature in the demo version of Portainer.</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12 text-muted small" style="margin-top: 10px">
|
|
||||||
You can find more information about this in our
|
|
||||||
<a href="https://www.portainer.io/documentation/in-app-analytics-and-privacy-policy/" target="_blank">privacy policy</a>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- login screen banner -->
|
|
||||||
<div class="form-group">
|
|
||||||
<por-switch-field
|
|
||||||
label="'Login screen banner'"
|
|
||||||
name="'toggle_login_banner'"
|
|
||||||
feature-id="customBannerFeatureId"
|
|
||||||
checked="$formValues.customLoginBanner"
|
|
||||||
on-change="(onToggleCustomLoginBanner)"
|
|
||||||
field-class="'col-sm-12'"
|
|
||||||
label-class="'col-sm-2'"
|
|
||||||
></por-switch-field>
|
|
||||||
</div>
|
|
||||||
<!-- !login screen banner -->
|
|
||||||
<!-- templates -->
|
|
||||||
<div class="col-sm-12 form-section-title"> App Templates </div>
|
|
||||||
<div>
|
|
||||||
<div class="form-group">
|
|
||||||
<span class="col-sm-12 text-muted small">
|
|
||||||
You can specify the URL to your own template definitions file here. See
|
|
||||||
<a href="https://docs.portainer.io/advanced/app-templates/build" target="_blank">Portainer documentation</a> for more details.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="templates_url" class="col-sm-2 control-label text-left"> URL </label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="settings.TemplatesURL"
|
|
||||||
id="templates_url"
|
|
||||||
placeholder="https://myserver.mydomain/templates.json"
|
|
||||||
required
|
|
||||||
data-cy="settings-templateUrl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !templates -->
|
|
||||||
<!-- actions -->
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
ng-click="saveApplicationSettings()"
|
|
||||||
ng-disabled="state.actionInProgress || !settings.TemplatesURL"
|
|
||||||
button-spinner="state.actionInProgress"
|
|
||||||
data-cy="settings-saveSettingsButton"
|
|
||||||
>
|
|
||||||
<span ng-hide="state.actionInProgress">Save application settings</span>
|
|
||||||
<span ng-show="state.actionInProgress">Saving...</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !actions -->
|
|
||||||
</form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
|
|
|
@ -11,14 +11,14 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||||
'BackupService',
|
'BackupService',
|
||||||
'FileSaver',
|
'FileSaver',
|
||||||
function ($scope, Notifications, SettingsService, StateManager, BackupService, FileSaver) {
|
function ($scope, Notifications, SettingsService, StateManager, BackupService, FileSaver) {
|
||||||
$scope.customBannerFeatureId = FeatureId.CUSTOM_LOGIN_BANNER;
|
|
||||||
$scope.s3BackupFeatureId = FeatureId.S3_BACKUP_SETTING;
|
$scope.s3BackupFeatureId = FeatureId.S3_BACKUP_SETTING;
|
||||||
$scope.enforceDeploymentOptions = FeatureId.ENFORCE_DEPLOYMENT_OPTIONS;
|
$scope.enforceDeploymentOptions = FeatureId.ENFORCE_DEPLOYMENT_OPTIONS;
|
||||||
|
$scope.updateSettings = updateSettings;
|
||||||
|
$scope.handleSuccess = handleSuccess;
|
||||||
|
|
||||||
$scope.backupOptions = options;
|
$scope.backupOptions = options;
|
||||||
|
|
||||||
$scope.state = {
|
$scope.state = {
|
||||||
isDemo: false,
|
|
||||||
actionInProgress: false,
|
actionInProgress: false,
|
||||||
availableKubeconfigExpiryOptions: [
|
availableKubeconfigExpiryOptions: [
|
||||||
{
|
{
|
||||||
|
@ -50,32 +50,16 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||||
$scope.BACKUP_FORM_TYPES = { S3: 's3', FILE: 'file' };
|
$scope.BACKUP_FORM_TYPES = { S3: 's3', FILE: 'file' };
|
||||||
|
|
||||||
$scope.formValues = {
|
$scope.formValues = {
|
||||||
customLogo: false,
|
|
||||||
KubeconfigExpiry: undefined,
|
KubeconfigExpiry: undefined,
|
||||||
HelmRepositoryURL: undefined,
|
HelmRepositoryURL: undefined,
|
||||||
BlackListedLabels: [],
|
BlackListedLabels: [],
|
||||||
labelName: '',
|
labelName: '',
|
||||||
labelValue: '',
|
labelValue: '',
|
||||||
enableTelemetry: false,
|
|
||||||
passwordProtect: false,
|
passwordProtect: false,
|
||||||
password: '',
|
password: '',
|
||||||
backupFormType: $scope.BACKUP_FORM_TYPES.FILE,
|
backupFormType: $scope.BACKUP_FORM_TYPES.FILE,
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.initialFormValues = {};
|
|
||||||
|
|
||||||
$scope.onToggleEnableTelemetry = function onToggleEnableTelemetry(checked) {
|
|
||||||
$scope.$evalAsync(() => {
|
|
||||||
$scope.formValues.enableTelemetry = checked;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.onToggleCustomLogo = function onToggleCustomLogo(checked) {
|
|
||||||
$scope.$evalAsync(() => {
|
|
||||||
$scope.formValues.customLogo = checked;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.onToggleAutoBackups = function onToggleAutoBackups(checked) {
|
$scope.onToggleAutoBackups = function onToggleAutoBackups(checked) {
|
||||||
$scope.$evalAsync(() => {
|
$scope.$evalAsync(() => {
|
||||||
$scope.formValues.scheduleAutomaticBackups = checked;
|
$scope.formValues.scheduleAutomaticBackups = checked;
|
||||||
|
@ -87,13 +71,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||||
$scope.state.featureLimited = limited;
|
$scope.state.featureLimited = limited;
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.onChangeCheckInInterval = function (interval) {
|
|
||||||
$scope.$evalAsync(() => {
|
|
||||||
var settings = $scope.settings;
|
|
||||||
settings.EdgeAgentCheckinInterval = interval;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.removeFilteredContainerLabel = function (index) {
|
$scope.removeFilteredContainerLabel = function (index) {
|
||||||
const filteredSettings = $scope.formValues.BlackListedLabels.filter((_, i) => i !== index);
|
const filteredSettings = $scope.formValues.BlackListedLabels.filter((_, i) => i !== index);
|
||||||
const filteredSettingsPayload = { BlackListedLabels: filteredSettings };
|
const filteredSettingsPayload = { BlackListedLabels: filteredSettings };
|
||||||
|
@ -133,20 +110,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// only update the values from the app settings widget. In future separate the api endpoints
|
|
||||||
$scope.saveApplicationSettings = function () {
|
|
||||||
const appSettingsPayload = {
|
|
||||||
SnapshotInterval: $scope.settings.SnapshotInterval,
|
|
||||||
LogoURL: $scope.formValues.customLogo ? $scope.settings.LogoURL : '',
|
|
||||||
EnableTelemetry: $scope.formValues.enableTelemetry,
|
|
||||||
TemplatesURL: $scope.settings.TemplatesURL,
|
|
||||||
EdgeAgentCheckinInterval: $scope.settings.EdgeAgentCheckinInterval,
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.state.actionInProgress = true;
|
|
||||||
updateSettings(appSettingsPayload, 'Application settings updated');
|
|
||||||
};
|
|
||||||
|
|
||||||
// only update the values from the kube settings widget. In future separate the api endpoints
|
// only update the values from the kube settings widget. In future separate the api endpoints
|
||||||
$scope.saveKubernetesSettings = function () {
|
$scope.saveKubernetesSettings = function () {
|
||||||
const kubeSettingsPayload = {
|
const kubeSettingsPayload = {
|
||||||
|
@ -160,14 +123,10 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateSettings(settings, successMessage = 'Settings updated') {
|
function updateSettings(settings, successMessage = 'Settings updated') {
|
||||||
SettingsService.update(settings)
|
return SettingsService.update(settings)
|
||||||
.then(function success(response) {
|
.then(function success(settings) {
|
||||||
Notifications.success('Success', successMessage);
|
Notifications.success('Success', successMessage);
|
||||||
StateManager.updateLogo(settings.LogoURL);
|
handleSuccess(settings);
|
||||||
StateManager.updateSnapshotInterval(settings.SnapshotInterval);
|
|
||||||
StateManager.updateEnableTelemetry(settings.EnableTelemetry);
|
|
||||||
$scope.initialFormValues.enableTelemetry = response.EnableTelemetry;
|
|
||||||
$scope.formValues.BlackListedLabels = response.BlackListedLabels;
|
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function error(err) {
|
||||||
Notifications.error('Failure', err, 'Unable to update settings');
|
Notifications.error('Failure', err, 'Unable to update settings');
|
||||||
|
@ -178,24 +137,28 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initView() {
|
function handleSuccess(settings) {
|
||||||
const state = StateManager.getState();
|
if (settings) {
|
||||||
$scope.state.isDemo = state.application.demoEnvironment.enabled;
|
StateManager.updateLogo(settings.LogoURL);
|
||||||
|
StateManager.updateSnapshotInterval(settings.SnapshotInterval);
|
||||||
|
StateManager.updateEnableTelemetry(settings.EnableTelemetry);
|
||||||
|
$scope.formValues.BlackListedLabels = settings.BlackListedLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// trigger an event to update the deployment options for the react based sidebar
|
||||||
|
const event = new CustomEvent('portainer:deploymentOptionsUpdated');
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initView() {
|
||||||
SettingsService.settings()
|
SettingsService.settings()
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
var settings = data;
|
var settings = data;
|
||||||
$scope.settings = settings;
|
$scope.settings = settings;
|
||||||
|
|
||||||
if (settings.LogoURL !== '') {
|
|
||||||
$scope.formValues.customLogo = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.formValues.enableTelemetry = settings.EnableTelemetry;
|
|
||||||
$scope.formValues.KubeconfigExpiry = settings.KubeconfigExpiry;
|
$scope.formValues.KubeconfigExpiry = settings.KubeconfigExpiry;
|
||||||
$scope.formValues.HelmRepositoryURL = settings.HelmRepositoryURL;
|
$scope.formValues.HelmRepositoryURL = settings.HelmRepositoryURL;
|
||||||
$scope.formValues.BlackListedLabels = settings.BlackListedLabels;
|
$scope.formValues.BlackListedLabels = settings.BlackListedLabels;
|
||||||
$scope.initialFormValues.enableTelemetry = settings.EnableTelemetry;
|
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function error(err) {
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
export function isValidUrl(
|
||||||
|
value: string | undefined,
|
||||||
|
additionalCheck: (url: URL) => boolean = () => true
|
||||||
|
) {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return additionalCheck(url);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -61,6 +61,7 @@ export function EdgeCheckinIntervalField({
|
||||||
}}
|
}}
|
||||||
options={options}
|
options={options}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
|
id="edge_checkin"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { string } from 'yup';
|
||||||
|
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
import { Input } from '@@/form-components/Input';
|
import { Input } from '@@/form-components/Input';
|
||||||
|
import { isValidUrl } from '@@/form-components/validate-url';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
|
@ -47,18 +48,11 @@ export function validation() {
|
||||||
.test(
|
.test(
|
||||||
'valid API server URL',
|
'valid API server URL',
|
||||||
'The API server URL must be a valid URL (localhost cannot be used)',
|
'The API server URL must be a valid URL (localhost cannot be used)',
|
||||||
(value) => {
|
(value) =>
|
||||||
if (!value) {
|
isValidUrl(
|
||||||
return false;
|
value,
|
||||||
}
|
(url) => !!url.hostname && url.hostname !== 'localhost'
|
||||||
|
)
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
return !!url.hostname && url.hostname !== 'localhost';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { Settings as SettingsIcon } from 'lucide-react';
|
||||||
|
import { Field, Form, Formik, useFormikContext } from 'formik';
|
||||||
|
|
||||||
|
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
|
||||||
|
import {
|
||||||
|
useSettings,
|
||||||
|
useUpdateSettingsMutation,
|
||||||
|
} from '@/react/portainer/settings/queries';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
|
import { type Settings } from '../../types';
|
||||||
|
|
||||||
|
import { validation } from './validation';
|
||||||
|
import { Values } from './types';
|
||||||
|
import { LogoFieldset } from './LogoFieldset';
|
||||||
|
import { ScreenBannerFieldset } from './ScreenBannerFieldset';
|
||||||
|
import { TemplatesUrlSection } from './TemplatesUrlSection';
|
||||||
|
import { EnableTelemetryField } from './EnableTelemetryField';
|
||||||
|
|
||||||
|
export function ApplicationSettingsPanel({
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
onSuccess(settings: Settings): void;
|
||||||
|
}) {
|
||||||
|
const settingsQuery = useSettings();
|
||||||
|
const mutation = useUpdateSettingsMutation();
|
||||||
|
|
||||||
|
if (!settingsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = settingsQuery.data;
|
||||||
|
const initialValues: Values = {
|
||||||
|
edgeAgentCheckinInterval: settings.EdgeAgentCheckinInterval,
|
||||||
|
enableTelemetry: settings.EnableTelemetry,
|
||||||
|
loginBannerEnabled: !!settings.CustomLoginBanner,
|
||||||
|
loginBanner: settings.CustomLoginBanner,
|
||||||
|
logoEnabled: !!settings.LogoURL,
|
||||||
|
logo: settings.LogoURL,
|
||||||
|
snapshotInterval: settings.SnapshotInterval,
|
||||||
|
templatesUrl: settings.TemplatesURL,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<Widget.Title icon={SettingsIcon} title="Application settings" />
|
||||||
|
<Widget.Body>
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
validationSchema={validation}
|
||||||
|
validateOnMount
|
||||||
|
>
|
||||||
|
<InnerForm isLoading={mutation.isLoading} />
|
||||||
|
</Formik>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSubmit(values: Values) {
|
||||||
|
mutation.mutate(
|
||||||
|
{
|
||||||
|
SnapshotInterval: values.snapshotInterval,
|
||||||
|
LogoURL: values.logo,
|
||||||
|
EnableTelemetry: values.enableTelemetry,
|
||||||
|
CustomLoginBanner: values.loginBanner,
|
||||||
|
TemplatesURL: values.templatesUrl,
|
||||||
|
EdgeAgentCheckinInterval: values.edgeAgentCheckinInterval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(settings) {
|
||||||
|
notifySuccess('Success', 'Application settings updated');
|
||||||
|
onSuccess(settings);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerForm({ isLoading }: { isLoading: boolean }) {
|
||||||
|
const { values, setFieldValue, isValid, errors } = useFormikContext<Values>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<FormControl
|
||||||
|
label="Snapshot interval"
|
||||||
|
inputId="snapshot_interval"
|
||||||
|
errors={errors.snapshotInterval}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
as={Input}
|
||||||
|
id="snapshot_interval"
|
||||||
|
placeholder="e.g. 15m"
|
||||||
|
name="snapshotInterval"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<EdgeCheckinIntervalField
|
||||||
|
size="xsmall"
|
||||||
|
value={values.edgeAgentCheckinInterval}
|
||||||
|
label="Edge agent default poll frequency"
|
||||||
|
isDefaultHidden
|
||||||
|
onChange={(value) => setFieldValue('edgeAgentCheckinInterval', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LogoFieldset />
|
||||||
|
|
||||||
|
<EnableTelemetryField />
|
||||||
|
|
||||||
|
<ScreenBannerFieldset />
|
||||||
|
|
||||||
|
<TemplatesUrlSection />
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<LoadingButton
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={!isValid}
|
||||||
|
data-cy="settings-saveSettingsButton"
|
||||||
|
loadingText="Saving..."
|
||||||
|
>
|
||||||
|
Save application settings
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { useIsDemo } from '@/react/portainer/system/useSystemStatus';
|
||||||
|
|
||||||
|
export function DemoAlert() {
|
||||||
|
const isDemoQuery = useIsDemo();
|
||||||
|
if (!isDemoQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-sm-12 mt-2">
|
||||||
|
<span className="small text-muted">
|
||||||
|
You cannot use this feature in the demo version of Portainer.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { useField } from 'formik';
|
||||||
|
|
||||||
|
import { useIsDemo } from '@/react/portainer/system/useSystemStatus';
|
||||||
|
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
|
||||||
|
import { DemoAlert } from './DemoAlert';
|
||||||
|
|
||||||
|
export function EnableTelemetryField() {
|
||||||
|
const isDemoQuery = useIsDemo();
|
||||||
|
const [{ value }, , { setValue }] = useField<boolean>('enableTelemetry');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
labelClass="col-sm-2"
|
||||||
|
label="Allow the collection of anonymous statistics"
|
||||||
|
checked={value}
|
||||||
|
name="toggle_enableTelemetry"
|
||||||
|
onChange={(checked) => setValue(checked)}
|
||||||
|
disabled={isDemoQuery.data}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DemoAlert />
|
||||||
|
|
||||||
|
<div className="col-sm-12 text-muted small mt-2">
|
||||||
|
You can find more information about this in our{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.portainer.io/documentation/in-app-analytics-and-privacy-policy/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
privacy policy
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { useField, Field } from 'formik';
|
||||||
|
|
||||||
|
import { useIsDemo } from '@/react/portainer/system/useSystemStatus';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
|
||||||
|
import { useToggledValue } from './useToggledValue';
|
||||||
|
import { DemoAlert } from './DemoAlert';
|
||||||
|
|
||||||
|
export function LogoFieldset() {
|
||||||
|
const [{ name }, { error }] = useField<string>('logo');
|
||||||
|
const isDemoQuery = useIsDemo();
|
||||||
|
|
||||||
|
const [isEnabled, setIsEnabled] = useToggledValue('logo');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
label="Use custom logo"
|
||||||
|
checked={isEnabled}
|
||||||
|
name="toggle_logo"
|
||||||
|
labelClass="col-sm-2"
|
||||||
|
disabled={isDemoQuery.data}
|
||||||
|
onChange={(checked) => setIsEnabled(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DemoAlert />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEnabled && (
|
||||||
|
<div>
|
||||||
|
<div className="form-group">
|
||||||
|
<span className="col-sm-12 text-muted small">
|
||||||
|
You can specify the URL to your logo here. For an optimal display,
|
||||||
|
logo dimensions should be 155px by 55px.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<FormControl label="URL" inputId="logo_url" errors={error} required>
|
||||||
|
<Field
|
||||||
|
as={Input}
|
||||||
|
name={name}
|
||||||
|
id="logo_url"
|
||||||
|
placeholder="https://mycompany.com/logo.png"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useField, Field } from 'formik';
|
||||||
|
|
||||||
|
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||||
|
import { useIsDemo } from '@/react/portainer/system/useSystemStatus';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { TextArea } from '@@/form-components/Input/Textarea';
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
|
||||||
|
import { useToggledValue } from './useToggledValue';
|
||||||
|
import { DemoAlert } from './DemoAlert';
|
||||||
|
|
||||||
|
export function ScreenBannerFieldset() {
|
||||||
|
const isDemoQuery = useIsDemo();
|
||||||
|
const [{ name }, { error }] = useField<string>('loginBanner');
|
||||||
|
const [isEnabled, setIsEnabled] = useToggledValue('loginBanner');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
labelClass="col-sm-2"
|
||||||
|
label="Login screen banner"
|
||||||
|
checked={isEnabled}
|
||||||
|
name="toggle_login_banner"
|
||||||
|
disabled={isDemoQuery.data}
|
||||||
|
onChange={(checked) => setIsEnabled(checked)}
|
||||||
|
featureId={FeatureId.CUSTOM_LOGIN_BANNER}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DemoAlert />
|
||||||
|
|
||||||
|
<div className="col-sm-12 text-muted small mt-2">
|
||||||
|
You can set a custom banner that will be shown to all users during
|
||||||
|
login.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEnabled && (
|
||||||
|
<FormControl
|
||||||
|
label="Details"
|
||||||
|
inputId="custom_login_banner"
|
||||||
|
errors={error}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
as={TextArea}
|
||||||
|
name={name}
|
||||||
|
rows="5"
|
||||||
|
id="custom_login_banner"
|
||||||
|
placeholder="Banner details"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useField, Field } from 'formik';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
|
export function TemplatesUrlSection() {
|
||||||
|
const [{ name }, { error }] = useField<string>('templatesUrl');
|
||||||
|
return (
|
||||||
|
<FormSection title="App Templates">
|
||||||
|
<div className="form-group">
|
||||||
|
<span className="col-sm-12 text-muted small">
|
||||||
|
You can specify the URL to your own template definitions file here.
|
||||||
|
See{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.portainer.io/advanced/app-templates/build"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Portainer documentation
|
||||||
|
</a>{' '}
|
||||||
|
for more details.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormControl label="URL" inputId="templates_url" required errors={error}>
|
||||||
|
<Field
|
||||||
|
as={Input}
|
||||||
|
id="templates_url"
|
||||||
|
placeholder="https://myserver.mydomain/templates.json"
|
||||||
|
required
|
||||||
|
data-cy="settings-templateUrl"
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { ApplicationSettingsPanel } from './ApplicationSettingsPanel';
|
|
@ -0,0 +1,10 @@
|
||||||
|
export interface Values {
|
||||||
|
snapshotInterval: string;
|
||||||
|
edgeAgentCheckinInterval: number;
|
||||||
|
enableTelemetry: boolean;
|
||||||
|
loginBanner: string;
|
||||||
|
loginBannerEnabled: boolean;
|
||||||
|
logo: string;
|
||||||
|
logoEnabled: boolean;
|
||||||
|
templatesUrl: string;
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useField } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function useToggledValue(fieldName: string) {
|
||||||
|
const [, { value }, { setValue }] = useField<string>(fieldName);
|
||||||
|
const [, { value: isEnabled }, { setValue: setIsEnabled }] =
|
||||||
|
useField<boolean>(`${fieldName}Enabled`);
|
||||||
|
const [oldValue, setOldValue] = useState(value);
|
||||||
|
|
||||||
|
async function handleIsEnabledChange(enabled: boolean) {
|
||||||
|
setOldValue(enabled ? '' : value);
|
||||||
|
// `setValue` is async, formik types are wrong for this version
|
||||||
|
await setIsEnabled(enabled);
|
||||||
|
await setValue(enabled ? oldValue : '', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [isEnabled, handleIsEnabledChange] as const;
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { SchemaOf, bool, boolean, number, object, string } from 'yup';
|
||||||
|
|
||||||
|
import { isValidUrl } from '@@/form-components/validate-url';
|
||||||
|
|
||||||
|
import { Values } from './types';
|
||||||
|
|
||||||
|
export function validation(): SchemaOf<Values> {
|
||||||
|
return object({
|
||||||
|
edgeAgentCheckinInterval: number().required(),
|
||||||
|
enableTelemetry: bool().required(),
|
||||||
|
loginBannerEnabled: boolean().default(false),
|
||||||
|
loginBanner: string()
|
||||||
|
.default('')
|
||||||
|
.when('loginBannerEnabled', {
|
||||||
|
is: true,
|
||||||
|
then: (schema) =>
|
||||||
|
schema.required('Login banner is required when enabled'),
|
||||||
|
}),
|
||||||
|
logoEnabled: boolean().default(false),
|
||||||
|
logo: string()
|
||||||
|
.default('')
|
||||||
|
.when('logoEnabled', {
|
||||||
|
is: true,
|
||||||
|
then: (schema) =>
|
||||||
|
schema
|
||||||
|
.required('Logo url is required when enabled')
|
||||||
|
.test('valid-url', 'Must be a valid URL', (value) =>
|
||||||
|
isValidUrl(value)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
snapshotInterval: string().required('Snapshot interval is required'),
|
||||||
|
templatesUrl: string()
|
||||||
|
.required('Templates URL is required')
|
||||||
|
.test('valid-url', 'Must be a valid URL', (value) => isValidUrl(value)),
|
||||||
|
});
|
||||||
|
}
|
|
@ -34,7 +34,8 @@ type OptionalSettings = Omit<Partial<Settings>, 'Edge'> & {
|
||||||
|
|
||||||
export async function updateSettings(settings: OptionalSettings) {
|
export async function updateSettings(settings: OptionalSettings) {
|
||||||
try {
|
try {
|
||||||
await axios.put(buildUrl(), settings);
|
const { data } = await axios.put<Settings>(buildUrl(), settings);
|
||||||
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseAxiosError(e as Error, 'Unable to update application settings');
|
throw parseAxiosError(e as Error, 'Unable to update application settings');
|
||||||
}
|
}
|
||||||
|
|
|
@ -95,6 +95,7 @@ export interface DefaultRegistry {
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
LogoURL: string;
|
LogoURL: string;
|
||||||
|
CustomLoginBanner: string;
|
||||||
BlackListedLabels: Pair[];
|
BlackListedLabels: Pair[];
|
||||||
AuthenticationMethod: AuthenticationMethod;
|
AuthenticationMethod: AuthenticationMethod;
|
||||||
InternalAuthSettings: { RequiredPasswordLength: number };
|
InternalAuthSettings: { RequiredPasswordLength: number };
|
||||||
|
|
|
@ -2,6 +2,10 @@ import { useQuery } from 'react-query';
|
||||||
import { RetryValue } from 'react-query/types/core/retryer';
|
import { RetryValue } from 'react-query/types/core/retryer';
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
import { isBE } from '../feature-flags/feature-flags.service';
|
||||||
|
import { EnvironmentId } from '../environments/types';
|
||||||
|
|
||||||
import { buildUrl } from './build-url';
|
import { buildUrl } from './build-url';
|
||||||
import { queryKeys } from './query-keys';
|
import { queryKeys } from './query-keys';
|
||||||
|
@ -12,13 +16,18 @@ export interface StatusResponse {
|
||||||
Edition: string;
|
Edition: string;
|
||||||
Version: string;
|
Version: string;
|
||||||
InstanceID: string;
|
InstanceID: string;
|
||||||
|
DemoEnvironment: {
|
||||||
|
Enabled: boolean;
|
||||||
|
Users: Array<UserId>;
|
||||||
|
Environments: Array<EnvironmentId>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSystemStatus() {
|
export async function getSystemStatus() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<StatusResponse>(buildUrl('status'));
|
const { data } = await axios.get<StatusResponse>(buildUrl('status'));
|
||||||
|
|
||||||
data.Edition = 'Community Edition';
|
data.Edition = isBE ? 'Business Edition' : 'Community Edition';
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -45,3 +54,9 @@ export function useSystemStatus<T = StatusResponse>({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useIsDemo() {
|
||||||
|
return useSystemStatus({
|
||||||
|
select: (status) => status.DemoEnvironment.Enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue