1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

refactor(settings): move app settings to panel [EE-5503] (#9043)

This commit is contained in:
Chaim Lev-Ari 2023-06-07 12:16:47 +07:00 committed by GitHub
parent 4f04fe54a7
commit c7756f3018
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 477 additions and 201 deletions

View file

@ -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;
}
}

View file

@ -61,6 +61,7 @@ export function EdgeCheckinIntervalField({
}}
options={options}
disabled={readonly}
id="edge_checkin"
/>
</FormControl>
);

View file

@ -3,6 +3,7 @@ import { string } from 'yup';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { isValidUrl } from '@@/form-components/validate-url';
interface Props {
fieldName: string;
@ -47,18 +48,11 @@ export function validation() {
.test(
'valid API server URL',
'The API server URL must be a valid URL (localhost cannot be used)',
(value) => {
if (!value) {
return false;
}
try {
const url = new URL(value);
return !!url.hostname && url.hostname !== 'localhost';
} catch {
return false;
}
}
(value) =>
isValidUrl(
value,
(url) => !!url.hostname && url.hostname !== 'localhost'
)
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
)}
</>
);
}

View file

@ -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>
)}
</>
);
}

View file

@ -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>
);
}

View file

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

View file

@ -0,0 +1,10 @@
export interface Values {
snapshotInterval: string;
edgeAgentCheckinInterval: number;
enableTelemetry: boolean;
loginBanner: string;
loginBannerEnabled: boolean;
logo: string;
logoEnabled: boolean;
templatesUrl: string;
}

View file

@ -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;
}

View file

@ -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)),
});
}

View file

@ -34,7 +34,8 @@ type OptionalSettings = Omit<Partial<Settings>, 'Edge'> & {
export async function updateSettings(settings: OptionalSettings) {
try {
await axios.put(buildUrl(), settings);
const { data } = await axios.put<Settings>(buildUrl(), settings);
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update application settings');
}

View file

@ -95,6 +95,7 @@ export interface DefaultRegistry {
export interface Settings {
LogoURL: string;
CustomLoginBanner: string;
BlackListedLabels: Pair[];
AuthenticationMethod: AuthenticationMethod;
InternalAuthSettings: { RequiredPasswordLength: number };

View file

@ -2,6 +2,10 @@ import { useQuery } from 'react-query';
import { RetryValue } from 'react-query/types/core/retryer';
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 { queryKeys } from './query-keys';
@ -12,13 +16,18 @@ export interface StatusResponse {
Edition: string;
Version: string;
InstanceID: string;
DemoEnvironment: {
Enabled: boolean;
Users: Array<UserId>;
Environments: Array<EnvironmentId>;
};
}
export async function getSystemStatus() {
try {
const { data } = await axios.get<StatusResponse>(buildUrl('status'));
data.Edition = 'Community Edition';
data.Edition = isBE ? 'Business Edition' : 'Community Edition';
return data;
} catch (error) {
@ -45,3 +54,9 @@ export function useSystemStatus<T = StatusResponse>({
onSuccess,
});
}
export function useIsDemo() {
return useSystemStatus({
select: (status) => status.DemoEnvironment.Enabled,
});
}