mirror of
https://github.com/portainer/portainer.git
synced 2025-08-04 21:35:23 +02:00
refactor(templates): migrate list view to react [EE-2296] (#10999)
This commit is contained in:
parent
d38085a560
commit
6ff4fd3db2
103 changed files with 2628 additions and 1315 deletions
|
@ -11,9 +11,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
|||
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { ImageConfigFieldset, ImageConfigValues } from '@@/ImageConfigFieldset';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
|
@ -23,6 +21,7 @@ import {
|
|||
PortsMappingField,
|
||||
Values as PortMappingValue,
|
||||
} from './PortsMappingField';
|
||||
import { NameField } from './NameField';
|
||||
|
||||
export interface Values {
|
||||
name: string;
|
||||
|
@ -74,19 +73,14 @@ export function BaseForm({
|
|||
return (
|
||||
<Widget>
|
||||
<Widget.Body>
|
||||
<FormControl label="Name" inputId="name-input" errors={errors?.name}>
|
||||
<Input
|
||||
id="name-input"
|
||||
value={values.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value;
|
||||
onChangeName(name);
|
||||
setFieldValue('name', name);
|
||||
}}
|
||||
placeholder="e.g. myContainer"
|
||||
data-cy="container-name-input"
|
||||
/>
|
||||
</FormControl>
|
||||
<NameField
|
||||
value={values.name}
|
||||
onChange={(name) => {
|
||||
setFieldValue('name', name);
|
||||
onChangeName(name);
|
||||
}}
|
||||
error={errors?.name}
|
||||
/>
|
||||
|
||||
<FormSection title="Image Configuration">
|
||||
<ImageConfigFieldset
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import { string } from 'yup';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
||||
export function NameField({
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
error?: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<FormControl label="Name" inputId="name-input" errors={error}>
|
||||
<Input
|
||||
id="name-input"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. myContainer"
|
||||
data-cy="container-name-input"
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
export function nameValidation() {
|
||||
return string().default('');
|
||||
}
|
|
@ -9,7 +9,7 @@ type PortKey = `${string}/${Protocol}`;
|
|||
export function parsePortBindingRequest(portBindings: Values): PortMap {
|
||||
const bindings: Record<
|
||||
PortKey,
|
||||
Array<{ HostIp: string; HostPort: string }>
|
||||
Array<{ HostIp?: string; HostPort?: string }>
|
||||
> = {};
|
||||
_.forEach(portBindings, (portBinding) => {
|
||||
if (!portBinding.containerPort) {
|
||||
|
@ -17,9 +17,6 @@ export function parsePortBindingRequest(portBindings: Values): PortMap {
|
|||
}
|
||||
|
||||
const portInfo = extractPortInfo(portBinding);
|
||||
if (!portInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
let { hostPort } = portBinding;
|
||||
const { endHostPort, endPort, hostIp, startHostPort, startPort } = portInfo;
|
||||
|
@ -36,7 +33,9 @@ export function parsePortBindingRequest(portBindings: Values): PortMap {
|
|||
hostPort += `-${endHostPort.toString()}`;
|
||||
}
|
||||
|
||||
bindings[bindKey].push({ HostIp: hostIp, HostPort: hostPort });
|
||||
bindings[bindKey].push(
|
||||
hostIp || hostPort ? { HostIp: hostIp, HostPort: hostPort } : {}
|
||||
);
|
||||
});
|
||||
});
|
||||
return bindings;
|
||||
|
@ -71,7 +70,13 @@ function parsePort(port: string) {
|
|||
return 0;
|
||||
}
|
||||
|
||||
function extractPortInfo(portBinding: PortMapping) {
|
||||
function extractPortInfo(portBinding: PortMapping): {
|
||||
startPort: number;
|
||||
endPort: number;
|
||||
hostIp: string;
|
||||
startHostPort: number;
|
||||
endHostPort: number;
|
||||
} {
|
||||
const containerPortRange = parsePortRange(portBinding.containerPort);
|
||||
if (!isValidPortRange(containerPortRange)) {
|
||||
throw new Error(`Invalid port specification: ${portBinding.containerPort}`);
|
||||
|
@ -82,7 +87,13 @@ function extractPortInfo(portBinding: PortMapping) {
|
|||
let hostIp = '';
|
||||
let { hostPort } = portBinding;
|
||||
if (!hostPort) {
|
||||
return null;
|
||||
return {
|
||||
startPort,
|
||||
endPort,
|
||||
hostIp: '',
|
||||
startHostPort: 0,
|
||||
endHostPort: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (hostPort.includes('[')) {
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Values } from './PortsMappingField';
|
|||
export function validationSchema(): SchemaOf<Values> {
|
||||
return array(
|
||||
object({
|
||||
hostPort: string().required('host is required'),
|
||||
hostPort: string().default(''),
|
||||
containerPort: string().required('container is required'),
|
||||
protocol: mixed().oneOf(['tcp', 'udp']),
|
||||
})
|
||||
|
|
|
@ -6,6 +6,7 @@ import { imageConfigValidation } from '@@/ImageConfigFieldset';
|
|||
|
||||
import { Values } from './BaseForm';
|
||||
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
||||
import { nameValidation } from './NameField';
|
||||
|
||||
export function validation(
|
||||
{
|
||||
|
@ -26,9 +27,10 @@ export function validation(
|
|||
}
|
||||
): SchemaOf<Values> {
|
||||
return object({
|
||||
name: string()
|
||||
.default('')
|
||||
.test('not-duplicate-portainer', () => !isDuplicatingPortainer),
|
||||
name: nameValidation().test(
|
||||
'not-duplicate-portainer',
|
||||
() => !isDuplicatingPortainer
|
||||
),
|
||||
alwaysPull: boolean()
|
||||
.default(true)
|
||||
.test('rate-limits', 'Rate limit exceeded', (alwaysPull: boolean) =>
|
||||
|
|
|
@ -40,30 +40,30 @@ export function toRequest(
|
|||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function getConsoleConfig(value: ConsoleSetting): ConsoleConfig {
|
||||
switch (value) {
|
||||
case 'both':
|
||||
return { OpenStdin: true, Tty: true };
|
||||
case 'interactive':
|
||||
return { OpenStdin: true, Tty: false };
|
||||
case 'tty':
|
||||
return { OpenStdin: false, Tty: true };
|
||||
case 'none':
|
||||
default:
|
||||
return { OpenStdin: false, Tty: false };
|
||||
}
|
||||
}
|
||||
|
||||
function getLogConfig(
|
||||
value: LogConfig
|
||||
): CreateContainerRequest['HostConfig']['LogConfig'] {
|
||||
return {
|
||||
Type: value.type,
|
||||
Config: Object.fromEntries(
|
||||
value.options.map(({ option, value }) => [option, value])
|
||||
),
|
||||
// docker types - requires union while it should allow also custom string for custom plugins
|
||||
} as CreateContainerRequest['HostConfig']['LogConfig'];
|
||||
export function getConsoleConfig(value: ConsoleSetting): ConsoleConfig {
|
||||
switch (value) {
|
||||
case 'both':
|
||||
return { OpenStdin: true, Tty: true };
|
||||
case 'interactive':
|
||||
return { OpenStdin: true, Tty: false };
|
||||
case 'tty':
|
||||
return { OpenStdin: false, Tty: true };
|
||||
case 'none':
|
||||
default:
|
||||
return { OpenStdin: false, Tty: false };
|
||||
}
|
||||
}
|
||||
|
||||
function getLogConfig(
|
||||
value: LogConfig
|
||||
): CreateContainerRequest['HostConfig']['LogConfig'] {
|
||||
return {
|
||||
Type: value.type,
|
||||
Config: Object.fromEntries(
|
||||
value.options.map(({ option, value }) => [option, value])
|
||||
),
|
||||
// docker types - requires union while it should allow also custom string for custom plugins
|
||||
} as CreateContainerRequest['HostConfig']['LogConfig'];
|
||||
}
|
||||
|
|
|
@ -145,7 +145,21 @@ function CreateForm() {
|
|||
const config = toRequest(values, registry, hideCapabilities);
|
||||
|
||||
return mutation.mutate(
|
||||
{ config, environment, values, registry, oldContainer, extraNetworks },
|
||||
{
|
||||
config,
|
||||
environment,
|
||||
values: {
|
||||
accessControl: values.accessControl,
|
||||
imageName: values.image.image,
|
||||
name: values.name,
|
||||
alwaysPull: values.alwaysPull,
|
||||
enableWebhook: values.enableWebhook,
|
||||
nodeName: values.nodeName,
|
||||
},
|
||||
registry,
|
||||
oldContainer,
|
||||
extraNetworks,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
sendAnalytics(values, registry);
|
||||
|
|
|
@ -101,11 +101,6 @@ export function InnerForm({
|
|||
setFieldValue('volumes', value)
|
||||
}
|
||||
errors={errors.volumes}
|
||||
allowBindMounts={
|
||||
isEnvironmentAdminQuery.authorized ||
|
||||
environment.SecuritySettings
|
||||
.allowBindMountsForRegularUsers
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import { string } from 'yup';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
||||
export function HostnameField({
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
error?: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<FormControl label="Hostname" errors={error}>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="e.g. web01"
|
||||
data-cy="docker-container-hostname-input"
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
export const hostnameSchema = string().default('');
|
|
@ -0,0 +1,56 @@
|
|||
import { array, string } from 'yup';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
|
||||
import { ItemProps } from '@@/form-components/InputList';
|
||||
import { ArrayError, InputList } from '@@/form-components/InputList/InputList';
|
||||
|
||||
export const hostFileSchema = array(
|
||||
string().required('Entry is required')
|
||||
).default([]);
|
||||
|
||||
export function HostsFileEntries({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
}: {
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
errors?: ArrayError<string>;
|
||||
}) {
|
||||
return (
|
||||
<InputList
|
||||
label="Hosts file entries"
|
||||
value={values}
|
||||
onChange={(hostsFileEntries) => onChange(hostsFileEntries)}
|
||||
errors={errors}
|
||||
item={HostsFileEntryItem}
|
||||
itemBuilder={() => ''}
|
||||
data-cy="hosts-file-entries"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostsFileEntryItem({
|
||||
item,
|
||||
onChange,
|
||||
disabled,
|
||||
error,
|
||||
readOnly,
|
||||
index,
|
||||
}: ItemProps<string>) {
|
||||
return (
|
||||
<div>
|
||||
<InputLabeled
|
||||
label="value"
|
||||
value={item}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
data-cy={`hosts-file-entry_${index}`}
|
||||
/>
|
||||
|
||||
{error && <FormError>{error}</FormError>}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -2,14 +2,13 @@ import { FormikErrors } from 'formik';
|
|||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { InputList, ItemProps } from '@@/form-components/InputList';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { NetworkSelector } from '../../components/NetworkSelector';
|
||||
|
||||
import { CONTAINER_MODE, Values } from './types';
|
||||
import { ContainerSelector } from './ContainerSelector';
|
||||
import { HostsFileEntries } from './HostsFileEntries';
|
||||
import { HostnameField } from './HostnameField';
|
||||
|
||||
export function NetworkTab({
|
||||
values,
|
||||
|
@ -39,14 +38,10 @@ export function NetworkTab({
|
|||
</FormControl>
|
||||
)}
|
||||
|
||||
<FormControl label="Hostname" errors={errors?.hostname}>
|
||||
<Input
|
||||
value={values.hostname}
|
||||
onChange={(e) => setFieldValue('hostname', e.target.value)}
|
||||
placeholder="e.g. web01"
|
||||
data-cy="docker-container-hostname-input"
|
||||
/>
|
||||
</FormControl>
|
||||
<HostnameField
|
||||
value={values.hostname}
|
||||
onChange={(value) => setFieldValue('hostname', value)}
|
||||
/>
|
||||
|
||||
<FormControl label="Domain Name" errors={errors?.domain}>
|
||||
<Input
|
||||
|
@ -102,43 +97,11 @@ export function NetworkTab({
|
|||
/>
|
||||
</FormControl>
|
||||
|
||||
<InputList
|
||||
label="Hosts file entries"
|
||||
value={values.hostsFileEntries}
|
||||
onChange={(hostsFileEntries) =>
|
||||
setFieldValue('hostsFileEntries', hostsFileEntries)
|
||||
}
|
||||
<HostsFileEntries
|
||||
values={values.hostsFileEntries}
|
||||
onChange={(v) => setFieldValue('hostsFileEntries', v)}
|
||||
errors={errors?.hostsFileEntries}
|
||||
item={HostsFileEntryItem}
|
||||
itemBuilder={() => ''}
|
||||
data-cy="docker-container-hosts-file-entries"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostsFileEntryItem({
|
||||
item,
|
||||
onChange,
|
||||
disabled,
|
||||
error,
|
||||
readOnly,
|
||||
index,
|
||||
}: ItemProps<string>) {
|
||||
return (
|
||||
<div>
|
||||
<InputGroup>
|
||||
<InputGroup.Addon>value</InputGroup.Addon>
|
||||
<Input
|
||||
value={item}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
data-cy={`docker-container-hosts-file-entry_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{error && <FormError>{error}</FormError>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
import { array, object, SchemaOf, string } from 'yup';
|
||||
import { object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { Values } from './types';
|
||||
import { hostnameSchema } from './HostnameField';
|
||||
import { hostFileSchema } from './HostsFileEntries';
|
||||
|
||||
export function validation(): SchemaOf<Values> {
|
||||
return object({
|
||||
networkMode: string().default(''),
|
||||
hostname: string().default(''),
|
||||
hostname: hostnameSchema,
|
||||
domain: string().default(''),
|
||||
macAddress: string().default(''),
|
||||
ipv4Address: string().default(''),
|
||||
ipv6Address: string().default(''),
|
||||
primaryDns: string().default(''),
|
||||
secondaryDns: string().default(''),
|
||||
hostsFileEntries: array(string().required('Entry is required')).default([]),
|
||||
hostsFileEntries: hostFileSchema,
|
||||
container: string()
|
||||
.default('')
|
||||
.when('network', {
|
||||
|
|
|
@ -11,13 +11,15 @@ export function RuntimeSelector({
|
|||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const infoQuery = useInfo(environmentId, (info) => [
|
||||
{ label: 'Default', value: '' },
|
||||
...Object.keys(info?.Runtimes || {}).map((runtime) => ({
|
||||
label: runtime,
|
||||
value: runtime,
|
||||
})),
|
||||
]);
|
||||
const infoQuery = useInfo(environmentId, {
|
||||
select: (info) => [
|
||||
{ label: 'Default', value: '' },
|
||||
...Object.keys(info?.Runtimes || {}).map((runtime) => ({
|
||||
label: runtime,
|
||||
value: runtime,
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<PortainerSelect
|
||||
|
|
|
@ -18,7 +18,7 @@ export function Item({
|
|||
error,
|
||||
index,
|
||||
}: ItemProps<Volume>) {
|
||||
const allowBindMounts = useInputContext();
|
||||
const { allowBindMounts, allowAuto } = useInputContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -61,6 +61,7 @@ export function Item({
|
|||
value={volume.name}
|
||||
onChange={(name) => setValue({ name })}
|
||||
inputId={`volume-${index}`}
|
||||
allowAuto={allowAuto}
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
|
|
@ -8,10 +8,12 @@ export function VolumeSelector({
|
|||
value,
|
||||
onChange,
|
||||
inputId,
|
||||
allowAuto,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value?: string) => void;
|
||||
inputId?: string;
|
||||
allowAuto: boolean;
|
||||
}) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const volumesQuery = useVolumes(environmentId, {
|
||||
|
@ -24,7 +26,9 @@ export function VolumeSelector({
|
|||
return null;
|
||||
}
|
||||
|
||||
const volumes = volumesQuery.data;
|
||||
const volumes = allowAuto
|
||||
? [...volumesQuery.data, { Name: 'auto', Driver: '' }]
|
||||
: volumesQuery.data;
|
||||
|
||||
const selectedValue = volumes.find((vol) => vol.Name === value);
|
||||
|
||||
|
@ -33,7 +37,9 @@ export function VolumeSelector({
|
|||
placeholder="Select a volume"
|
||||
options={volumes}
|
||||
getOptionLabel={(vol) =>
|
||||
`${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
|
||||
vol.Name !== 'auto'
|
||||
? `${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
|
||||
: 'auto'
|
||||
}
|
||||
getOptionValue={(vol) => vol.Name}
|
||||
isMulti={false}
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useIsEnvironmentAdmin } from '@/react/hooks/useUser';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||
|
||||
|
@ -8,16 +13,29 @@ import { Item } from './Item';
|
|||
export function VolumesTab({
|
||||
onChange,
|
||||
values,
|
||||
allowBindMounts,
|
||||
errors,
|
||||
allowAuto = false,
|
||||
}: {
|
||||
onChange: (values: Values) => void;
|
||||
values: Values;
|
||||
allowBindMounts: boolean;
|
||||
errors?: ArrayError<Values>;
|
||||
allowAuto?: boolean;
|
||||
}) {
|
||||
const isEnvironmentAdminQuery = useIsEnvironmentAdmin({ adminOnlyCE: true });
|
||||
const envQuery = useCurrentEnvironment();
|
||||
|
||||
const allowBindMounts = !!(
|
||||
isEnvironmentAdminQuery.authorized ||
|
||||
envQuery.data?.SecuritySettings.allowBindMountsForRegularUsers
|
||||
);
|
||||
|
||||
const inputContext = useMemo(
|
||||
() => ({ allowBindMounts, allowAuto }),
|
||||
[allowAuto, allowBindMounts]
|
||||
);
|
||||
|
||||
return (
|
||||
<InputContext.Provider value={allowBindMounts}>
|
||||
<InputContext.Provider value={inputContext}>
|
||||
<InputList<Volume>
|
||||
errors={Array.isArray(errors) ? errors : []}
|
||||
label="Volume mapping"
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const InputContext = createContext<boolean | null>(null);
|
||||
export const InputContext = createContext<{
|
||||
allowAuto: boolean;
|
||||
allowBindMounts: boolean;
|
||||
} | null>(null);
|
||||
|
||||
export function useInputContext() {
|
||||
const value = useContext(InputContext);
|
||||
|
|
|
@ -62,7 +62,14 @@ export function useCreateOrReplaceMutation() {
|
|||
|
||||
interface CreateOptions {
|
||||
config: CreateContainerRequest;
|
||||
values: Values;
|
||||
values: {
|
||||
name: Values['name'];
|
||||
imageName: string;
|
||||
accessControl: Values['accessControl'];
|
||||
nodeName?: Values['nodeName'];
|
||||
alwaysPull?: Values['alwaysPull'];
|
||||
enableWebhook?: Values['enableWebhook'];
|
||||
};
|
||||
registry?: Registry;
|
||||
environment: Environment;
|
||||
}
|
||||
|
@ -90,14 +97,14 @@ async function create({
|
|||
}: CreateOptions) {
|
||||
await pullImageIfNeeded(
|
||||
environment.Id,
|
||||
values.alwaysPull || false,
|
||||
values.imageName,
|
||||
values.nodeName,
|
||||
values.alwaysPull,
|
||||
values.image.image,
|
||||
registry
|
||||
);
|
||||
|
||||
const containerResponse = await createAndStart(
|
||||
environment,
|
||||
environment.Id,
|
||||
config,
|
||||
values.name,
|
||||
values.nodeName
|
||||
|
@ -106,8 +113,8 @@ async function create({
|
|||
await applyContainerSettings(
|
||||
containerResponse.Id,
|
||||
environment,
|
||||
values.enableWebhook,
|
||||
values.accessControl,
|
||||
values.enableWebhook,
|
||||
containerResponse.Portainer?.ResourceControl,
|
||||
registry
|
||||
);
|
||||
|
@ -123,33 +130,34 @@ async function replace({
|
|||
}: ReplaceOptions) {
|
||||
await pullImageIfNeeded(
|
||||
environment.Id,
|
||||
values.alwaysPull || false,
|
||||
values.imageName,
|
||||
values.nodeName,
|
||||
values.alwaysPull,
|
||||
values.image.image,
|
||||
registry
|
||||
);
|
||||
|
||||
const containerResponse = await renameAndCreate(
|
||||
environment,
|
||||
values,
|
||||
environment.Id,
|
||||
values.name,
|
||||
oldContainer,
|
||||
config
|
||||
config,
|
||||
values.nodeName
|
||||
);
|
||||
|
||||
await applyContainerSettings(
|
||||
containerResponse.Id,
|
||||
environment,
|
||||
values.enableWebhook,
|
||||
values.accessControl,
|
||||
values.enableWebhook,
|
||||
containerResponse.Portainer?.ResourceControl,
|
||||
registry
|
||||
);
|
||||
|
||||
await connectToExtraNetworks(
|
||||
environment.Id,
|
||||
values.nodeName,
|
||||
containerResponse.Id,
|
||||
extraNetworks
|
||||
extraNetworks,
|
||||
values.nodeName
|
||||
);
|
||||
|
||||
await removeContainer(environment.Id, oldContainer.Id, {
|
||||
|
@ -162,33 +170,29 @@ async function replace({
|
|||
* on any failure, it will rename the old container to its original name
|
||||
*/
|
||||
async function renameAndCreate(
|
||||
environment: Environment,
|
||||
values: Values,
|
||||
environmentId: EnvironmentId,
|
||||
name: string,
|
||||
oldContainer: DockerContainer,
|
||||
config: CreateContainerRequest
|
||||
config: CreateContainerRequest,
|
||||
nodeName?: string
|
||||
) {
|
||||
let renamed = false;
|
||||
try {
|
||||
await stopContainerIfNeeded(environment.Id, values.nodeName, oldContainer);
|
||||
await stopContainerIfNeeded(environmentId, oldContainer, nodeName);
|
||||
|
||||
await renameContainer(
|
||||
environment.Id,
|
||||
environmentId,
|
||||
oldContainer.Id,
|
||||
`${oldContainer.Names[0]}-old`,
|
||||
{ nodeName: values.nodeName }
|
||||
{ nodeName }
|
||||
);
|
||||
renamed = true;
|
||||
|
||||
return await createAndStart(
|
||||
environment,
|
||||
config,
|
||||
values.name,
|
||||
values.nodeName
|
||||
);
|
||||
return await createAndStart(environmentId, config, name, nodeName);
|
||||
} catch (e) {
|
||||
if (renamed) {
|
||||
await renameContainer(environment.Id, oldContainer.Id, values.name, {
|
||||
nodeName: values.nodeName,
|
||||
await renameContainer(environmentId, oldContainer.Id, name, {
|
||||
nodeName,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
|
@ -201,8 +205,8 @@ async function renameAndCreate(
|
|||
async function applyContainerSettings(
|
||||
containerId: string,
|
||||
environment: Environment,
|
||||
enableWebhook: boolean,
|
||||
accessControl: AccessControlFormData,
|
||||
enableWebhook?: boolean,
|
||||
resourceControl?: ResourceControlResponse,
|
||||
registry?: Registry
|
||||
) {
|
||||
|
@ -224,15 +228,15 @@ async function applyContainerSettings(
|
|||
* on failure, it will remove the new container
|
||||
*/
|
||||
async function createAndStart(
|
||||
environment: Environment,
|
||||
environmentId: EnvironmentId,
|
||||
config: CreateContainerRequest,
|
||||
name: string,
|
||||
nodeName: string
|
||||
nodeName?: string
|
||||
) {
|
||||
let containerId = '';
|
||||
try {
|
||||
const containerResponse = await createContainer(
|
||||
environment.Id,
|
||||
environmentId,
|
||||
config,
|
||||
name,
|
||||
{
|
||||
|
@ -242,11 +246,11 @@ async function createAndStart(
|
|||
|
||||
containerId = containerResponse.Id;
|
||||
|
||||
await startContainer(environment.Id, containerResponse.Id, { nodeName });
|
||||
await startContainer(environmentId, containerResponse.Id, { nodeName });
|
||||
return containerResponse;
|
||||
} catch (e) {
|
||||
if (containerId) {
|
||||
await removeContainer(environment.Id, containerId, {
|
||||
await removeContainer(environmentId, containerId, {
|
||||
nodeName,
|
||||
});
|
||||
}
|
||||
|
@ -257,9 +261,9 @@ async function createAndStart(
|
|||
|
||||
async function pullImageIfNeeded(
|
||||
environmentId: EnvironmentId,
|
||||
nodeName: string,
|
||||
pull: boolean,
|
||||
image: string,
|
||||
nodeName?: string,
|
||||
registry?: Registry
|
||||
) {
|
||||
if (!pull) {
|
||||
|
@ -322,9 +326,9 @@ async function createContainerWebhook(
|
|||
|
||||
function connectToExtraNetworks(
|
||||
environmentId: EnvironmentId,
|
||||
nodeName: string,
|
||||
containerId: string,
|
||||
extraNetworks: Array<ExtraNetwork>
|
||||
extraNetworks: Array<ExtraNetwork>,
|
||||
nodeName?: string
|
||||
) {
|
||||
if (!extraNetworks) {
|
||||
return null;
|
||||
|
@ -345,8 +349,8 @@ function connectToExtraNetworks(
|
|||
|
||||
function stopContainerIfNeeded(
|
||||
environmentId: EnvironmentId,
|
||||
nodeName: string,
|
||||
container: DockerContainer
|
||||
container: DockerContainer,
|
||||
nodeName?: string
|
||||
) {
|
||||
if (container.State !== 'running' || !container.Id) {
|
||||
return null;
|
||||
|
|
|
@ -13,7 +13,9 @@ interface Props {
|
|||
export function ListView({ endpoint: environment }: Props) {
|
||||
const isAgent = isAgentEnvironment(environment.Type);
|
||||
|
||||
const envInfoQuery = useInfo(environment.Id, (info) => !!info.Swarm?.NodeID);
|
||||
const envInfoQuery = useInfo(environment.Id, {
|
||||
select: (info) => !!info.Swarm?.NodeID,
|
||||
});
|
||||
|
||||
const isSwarmManager = !!envInfoQuery.data;
|
||||
const isHostColumnVisible = isAgent && isSwarmManager;
|
||||
|
|
|
@ -2,7 +2,7 @@ import _ from 'lodash';
|
|||
|
||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useInfo } from '@/react/docker/proxy/queries/useInfo';
|
||||
import { useIsStandAlone } from '@/react/docker/proxy/queries/useInfo';
|
||||
import { useEnvironment } from '@/react/portainer/environments/queries';
|
||||
|
||||
import { DockerContainer, ContainerStatus } from './types';
|
||||
|
@ -95,14 +95,11 @@ function createStatus(statusText = ''): ContainerStatus {
|
|||
return ContainerStatus.Running;
|
||||
}
|
||||
|
||||
export function useShowGPUsColumn(environmentID: EnvironmentId) {
|
||||
const isDockerStandaloneQuery = useInfo(
|
||||
environmentID,
|
||||
(info) => !(!!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable) // is not a swarm environment, therefore docker standalone
|
||||
);
|
||||
export function useShowGPUsColumn(environmentId: EnvironmentId) {
|
||||
const isDockerStandalone = useIsStandAlone(environmentId);
|
||||
const enableGPUManagementQuery = useEnvironment(
|
||||
environmentID,
|
||||
environmentId,
|
||||
(env) => env?.EnableGPUManagement
|
||||
);
|
||||
return isDockerStandaloneQuery.data && enableGPUManagementQuery.data;
|
||||
return isDockerStandalone && enableGPUManagementQuery.data;
|
||||
}
|
||||
|
|
|
@ -18,26 +18,38 @@ export async function getInfo(environmentId: EnvironmentId) {
|
|||
}
|
||||
|
||||
export function useInfo<TSelect = SystemInfo>(
|
||||
environmentId: EnvironmentId,
|
||||
select?: (info: SystemInfo) => TSelect
|
||||
environmentId?: EnvironmentId,
|
||||
{
|
||||
enabled,
|
||||
select,
|
||||
}: { select?: (info: SystemInfo) => TSelect; enabled?: boolean } = {}
|
||||
) {
|
||||
return useQuery(
|
||||
['environment', environmentId, 'docker', 'info'],
|
||||
() => getInfo(environmentId),
|
||||
() => getInfo(environmentId!),
|
||||
{
|
||||
select,
|
||||
enabled: !!environmentId && enabled,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsStandAlone(environmentId: EnvironmentId) {
|
||||
const query = useInfo(environmentId, (info) => !info.Swarm?.NodeID);
|
||||
const query = useInfo(environmentId, {
|
||||
select: (info) => !info.Swarm?.NodeID,
|
||||
});
|
||||
|
||||
return !!query.data;
|
||||
}
|
||||
|
||||
export function useIsSwarm(environmentId: EnvironmentId) {
|
||||
const query = useInfo(environmentId, (info) => !!info.Swarm?.NodeID);
|
||||
export function useIsSwarm(
|
||||
environmentId?: EnvironmentId,
|
||||
{ enabled }: { enabled?: boolean } = {}
|
||||
) {
|
||||
const query = useInfo(environmentId, {
|
||||
select: (info) => !!info.Swarm?.NodeID,
|
||||
enabled,
|
||||
});
|
||||
|
||||
return !!query.data;
|
||||
}
|
||||
|
|
|
@ -41,7 +41,9 @@ export function useServicePlugins(
|
|||
pluginType: keyof PluginsInfo,
|
||||
pluginVersion: string
|
||||
) {
|
||||
const systemPluginsQuery = useInfo(environmentId, (info) => info.Plugins);
|
||||
const systemPluginsQuery = useInfo(environmentId, {
|
||||
select: (info) => info.Plugins,
|
||||
});
|
||||
const pluginsQuery = usePlugins(environmentId, { enabled: !systemOnly });
|
||||
|
||||
return {
|
||||
|
|
38
app/react/docker/proxy/queries/useSwarm.ts
Normal file
38
app/react/docker/proxy/queries/useSwarm.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import { Swarm } from 'docker-types/generated/1.41';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './build-url';
|
||||
import { useIsSwarm } from './useInfo';
|
||||
|
||||
export function useSwarm<T = Swarm>(
|
||||
environmentId: EnvironmentId,
|
||||
{ select }: { select?(value: Swarm): T } = {}
|
||||
) {
|
||||
const isSwarm = useIsSwarm(environmentId);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.base(environmentId), 'swarm'] as const,
|
||||
queryFn: () => getSwarm(environmentId),
|
||||
select,
|
||||
enabled: isSwarm,
|
||||
});
|
||||
}
|
||||
|
||||
async function getSwarm(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data } = await axios.get<Swarm>(buildUrl(environmentId, 'swarm'));
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Unable to retrieve swarm information');
|
||||
}
|
||||
}
|
||||
|
||||
export function useSwarmId(environmentId: EnvironmentId) {
|
||||
return useSwarm(environmentId, {
|
||||
select: (swarm) => swarm.ID,
|
||||
});
|
||||
}
|
|
@ -18,19 +18,20 @@ export async function getVersion(environmentId: EnvironmentId) {
|
|||
}
|
||||
|
||||
export function useVersion<TSelect = SystemVersion>(
|
||||
environmentId: EnvironmentId,
|
||||
environmentId?: EnvironmentId,
|
||||
select?: (info: SystemVersion) => TSelect
|
||||
) {
|
||||
return useQuery(
|
||||
['environment', environmentId, 'docker', 'version'],
|
||||
() => getVersion(environmentId),
|
||||
['environment', environmentId!, 'docker', 'version'],
|
||||
() => getVersion(environmentId!),
|
||||
{
|
||||
select,
|
||||
enabled: !!environmentId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useApiVersion(environmentId: EnvironmentId) {
|
||||
export function useApiVersion(environmentId?: EnvironmentId) {
|
||||
const query = useVersion(environmentId, (info) => info.ApiVersion);
|
||||
return query.data ? parseFloat(query.data) : 0;
|
||||
}
|
||||
|
|
|
@ -67,8 +67,8 @@ export class StackViewModel implements IResource {
|
|||
this.Id = stack.Id;
|
||||
this.Type = stack.Type;
|
||||
this.Name = stack.Name;
|
||||
this.EndpointId = stack.EndpointID;
|
||||
this.SwarmId = stack.SwarmID;
|
||||
this.EndpointId = stack.EndpointId;
|
||||
this.SwarmId = stack.SwarmId;
|
||||
this.Env = stack.Env ? stack.Env : [];
|
||||
this.Option = stack.Option;
|
||||
this.IsComposeFormat = stack.IsComposeFormat;
|
||||
|
|
|
@ -0,0 +1,243 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
import { Formik, Form } from 'formik';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import {
|
||||
CreateStackPayload,
|
||||
useCreateStack,
|
||||
} from '@/react/common/stacks/queries/useCreateStack/useCreateStack';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
import { AccessControlForm } from '@/react/portainer/access-control';
|
||||
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||
import { NameField } from '@/react/common/stacks/CreateView/NameField';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import {
|
||||
isTemplateVariablesEnabled,
|
||||
renderTemplate,
|
||||
} from '@/react/portainer/custom-templates/components/utils';
|
||||
import {
|
||||
CustomTemplatesVariablesField,
|
||||
getVariablesFieldDefaultValues,
|
||||
} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { toGitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { AdvancedSettings } from '@/react/portainer/templates/app-templates/DeployFormWidget/AdvancedSettings';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { WebEditorForm } from '@@/WebEditorForm';
|
||||
|
||||
import { useSwarmId } from '../../proxy/queries/useSwarm';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { useValidation } from './useValidation';
|
||||
|
||||
export function DeployForm({
|
||||
template,
|
||||
unselect,
|
||||
templateFile,
|
||||
isDeployable,
|
||||
}: {
|
||||
template: CustomTemplate;
|
||||
templateFile: string;
|
||||
unselect: () => void;
|
||||
isDeployable: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { user } = useCurrentUser();
|
||||
const isEdgeAdminQuery = useIsEdgeAdmin();
|
||||
const environmentId = useEnvironmentId();
|
||||
const swarmIdQuery = useSwarmId(environmentId);
|
||||
const mutation = useCreateStack();
|
||||
const validation = useValidation({
|
||||
isDeployable,
|
||||
variableDefs: template.Variables,
|
||||
isAdmin: isEdgeAdminQuery.isAdmin,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (isEdgeAdminQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isGit = !!template.GitConfig;
|
||||
|
||||
const initialValues: FormValues = {
|
||||
name: template.Title || '',
|
||||
variables: getVariablesFieldDefaultValues(template.Variables),
|
||||
accessControl: parseAccessControlFormData(
|
||||
isEdgeAdminQuery.isAdmin,
|
||||
user.Id
|
||||
),
|
||||
fileContent: templateFile,
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
>
|
||||
{({ values, errors, setFieldValue, isValid }) => (
|
||||
<Form className="form-horizontal">
|
||||
<FormSection title="Configuration">
|
||||
<NameField
|
||||
value={values.name}
|
||||
onChange={(v) => setFieldValue('name', v)}
|
||||
errors={errors.name}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{isTemplateVariablesEnabled && (
|
||||
<CustomTemplatesVariablesField
|
||||
definitions={template.Variables}
|
||||
onChange={(v) => {
|
||||
setFieldValue('variables', v);
|
||||
const newFile = renderTemplate(
|
||||
templateFile,
|
||||
v,
|
||||
template.Variables
|
||||
);
|
||||
setFieldValue('fileContent', newFile);
|
||||
}}
|
||||
value={values.variables}
|
||||
errors={errors.variables}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdvancedSettings
|
||||
label={(isOpen) => advancedSettingsLabel(isOpen, isGit)}
|
||||
>
|
||||
<WebEditorForm
|
||||
id="custom-template-creation-editor"
|
||||
value={values.fileContent}
|
||||
onChange={(value) => {
|
||||
if (isGit) {
|
||||
return;
|
||||
}
|
||||
setFieldValue('fileContent', value);
|
||||
}}
|
||||
yaml
|
||||
error={errors.fileContent}
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
readonly={isGit}
|
||||
data-cy="custom-template-creation-editor"
|
||||
>
|
||||
<p>
|
||||
You can get more information about Compose file format in the{' '}
|
||||
<a
|
||||
href="https://docs.docker.com/compose/compose-file/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</WebEditorForm>
|
||||
</AdvancedSettings>
|
||||
|
||||
<AccessControlForm
|
||||
formNamespace="accessControl"
|
||||
onChange={(values) => setFieldValue('accessControl', values)}
|
||||
values={values.accessControl}
|
||||
errors={errors.accessControl}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
|
||||
<FormActions
|
||||
isLoading={mutation.isLoading}
|
||||
isValid={isValid}
|
||||
loadingText="Deployment in progress..."
|
||||
submitLabel="Deploy the stack"
|
||||
data-cy="deploy-stack-button"
|
||||
>
|
||||
<Button
|
||||
type="reset"
|
||||
onClick={() => unselect()}
|
||||
color="default"
|
||||
data-cy="cancel-stack-creation"
|
||||
>
|
||||
Hide
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
const payload = getPayload(values);
|
||||
|
||||
return mutation.mutate(payload, {
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Stack created');
|
||||
router.stateService.go('docker.stacks');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getPayload(values: FormValues): CreateStackPayload {
|
||||
const type =
|
||||
template.Type === StackType.DockerCompose ? 'standalone' : 'swarm';
|
||||
const isGit = !!template.GitConfig;
|
||||
if (isGit) {
|
||||
return type === 'standalone'
|
||||
? {
|
||||
type,
|
||||
method: 'git',
|
||||
payload: {
|
||||
name: values.name,
|
||||
environmentId,
|
||||
git: toGitFormModel(template.GitConfig),
|
||||
accessControl: values.accessControl,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type,
|
||||
method: 'git',
|
||||
payload: {
|
||||
name: values.name,
|
||||
environmentId,
|
||||
swarmId: swarmIdQuery.data || '',
|
||||
git: toGitFormModel(template.GitConfig),
|
||||
accessControl: values.accessControl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return type === 'standalone'
|
||||
? {
|
||||
type,
|
||||
method: 'string',
|
||||
payload: {
|
||||
name: values.name,
|
||||
environmentId,
|
||||
fileContent: values.fileContent,
|
||||
accessControl: values.accessControl,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type,
|
||||
method: 'string',
|
||||
payload: {
|
||||
name: values.name,
|
||||
environmentId,
|
||||
swarmId: swarmIdQuery.data || '',
|
||||
fileContent: values.fileContent,
|
||||
accessControl: values.accessControl,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function advancedSettingsLabel(isOpen: boolean, isGit: boolean) {
|
||||
if (isGit) {
|
||||
return isOpen ? 'Hide stack' : 'View stack';
|
||||
}
|
||||
|
||||
return isOpen ? 'Hide custom stack' : 'Customize stack';
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import { DeployWidget } from '@/react/portainer/templates/components/DeployWidget';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { useIsDeployable } from './useIsDeployable';
|
||||
import { DeployForm } from './DeployForm';
|
||||
import { TemplateLoadError } from './TemplateLoadError';
|
||||
|
||||
export function StackFromCustomTemplateFormWidget({
|
||||
template,
|
||||
unselect,
|
||||
}: {
|
||||
template: CustomTemplate;
|
||||
unselect: () => void;
|
||||
}) {
|
||||
const isDeployable = useIsDeployable(template.Type);
|
||||
const fileQuery = useCustomTemplateFile(template.Id);
|
||||
|
||||
if (fileQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DeployWidget
|
||||
logo={template.Logo}
|
||||
note={template.Note}
|
||||
title={template.Title}
|
||||
>
|
||||
{fileQuery.isError && (
|
||||
<TemplateLoadError
|
||||
creatorId={template.CreatedByUserId}
|
||||
templateId={template.Id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isDeployable && (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip>
|
||||
This template type cannot be deployed on this environment.
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{fileQuery.isSuccess && isDeployable && (
|
||||
<DeployForm
|
||||
template={template}
|
||||
unselect={unselect}
|
||||
templateFile={fileQuery.data}
|
||||
isDeployable={isDeployable}
|
||||
/>
|
||||
)}
|
||||
</DeployWidget>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import { UserId } from '@/portainer/users/types';
|
||||
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
export function TemplateLoadError({
|
||||
templateId,
|
||||
creatorId,
|
||||
}: {
|
||||
templateId: CustomTemplate['Id'];
|
||||
creatorId: UserId;
|
||||
}) {
|
||||
const { user } = useCurrentUser();
|
||||
const isEdgeAdminQuery = useIsEdgeAdmin();
|
||||
|
||||
if (isEdgeAdminQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAdminOrWriter = isEdgeAdminQuery.isAdmin || user.Id === creatorId;
|
||||
|
||||
return (
|
||||
<FormError>
|
||||
{isAdminOrWriter ? (
|
||||
<>
|
||||
Custom template could not be loaded, please{' '}
|
||||
<Link
|
||||
to=".edit"
|
||||
params={{ id: templateId }}
|
||||
data-cy="edit-custom-template-link"
|
||||
>
|
||||
click here
|
||||
</Link>{' '}
|
||||
for configuration
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Custom template could not be loaded, please contact your
|
||||
administrator.
|
||||
</>
|
||||
)}
|
||||
</FormError>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { StackFromCustomTemplateFormWidget } from './StackFromCustomTemplateFormWidget';
|
|
@ -0,0 +1,9 @@
|
|||
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
variables: VariablesFieldValue;
|
||||
accessControl: AccessControlFormData;
|
||||
fileContent: string;
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
|
||||
import { useIsSwarm } from '../../proxy/queries/useInfo';
|
||||
|
||||
export function useIsDeployable(type: StackType) {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const isSwarm = useIsSwarm(environmentId);
|
||||
|
||||
switch (type) {
|
||||
case StackType.DockerCompose:
|
||||
return !isSwarm;
|
||||
case StackType.DockerSwarm:
|
||||
return isSwarm;
|
||||
case StackType.Kubernetes:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { useMemo } from 'react';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
|
||||
import { useNameValidation } from '@/react/common/stacks/CreateView/NameField';
|
||||
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export function useValidation({
|
||||
environmentId,
|
||||
isAdmin,
|
||||
variableDefs,
|
||||
isDeployable,
|
||||
}: {
|
||||
variableDefs: Array<VariableDefinition>;
|
||||
isAdmin: boolean;
|
||||
environmentId: EnvironmentId;
|
||||
isDeployable: boolean;
|
||||
}) {
|
||||
const name = useNameValidation(environmentId);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
object({
|
||||
name: name.test({
|
||||
name: 'is-deployable',
|
||||
message: 'This template cannot be deployed on this environment',
|
||||
test: () => isDeployable,
|
||||
}),
|
||||
accessControl: accessControlFormValidation(isAdmin),
|
||||
fileContent: string().required('Required'),
|
||||
variables: variablesFieldValidation(variableDefs),
|
||||
}),
|
||||
[isAdmin, isDeployable, name, variableDefs]
|
||||
);
|
||||
}
|
19
app/react/docker/volumes/queries/build-url.ts
Normal file
19
app/react/docker/volumes/queries/build-url.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { buildUrl as buildProxyUrl } from '@/react/docker/proxy/queries/build-url';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export function buildUrl(
|
||||
environmentId: EnvironmentId,
|
||||
{ action, id }: { id?: string; action?: string } = {}
|
||||
) {
|
||||
let url = buildProxyUrl(environmentId, 'volumes');
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
url += `/${action}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
26
app/react/docker/volumes/queries/useCreateVolume.ts
Normal file
26
app/react/docker/volumes/queries/useCreateVolume.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { Volume, VolumeCreateOptions } from 'docker-types/generated/1.41';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { buildUrl } from './build-url';
|
||||
|
||||
export async function createVolume(
|
||||
environmentId: EnvironmentId,
|
||||
volume: VolumeCreateOptions
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.post<Volume>(
|
||||
buildUrl(environmentId, { action: 'create' }),
|
||||
volume,
|
||||
{
|
||||
headers: {
|
||||
'X-Portainer-VolumeName': volume.Name || '',
|
||||
},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error);
|
||||
}
|
||||
}
|
|
@ -2,10 +2,10 @@ import { useQuery } from 'react-query';
|
|||
import { Volume } from 'docker-types/generated/1.41';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { buildUrl as buildDockerUrl } from '@/react/docker/proxy/queries/build-url';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './build-url';
|
||||
|
||||
export function useVolumes<T = Volume[]>(
|
||||
environmentId: EnvironmentId,
|
||||
|
@ -24,22 +24,10 @@ interface VolumesResponse {
|
|||
|
||||
export async function getVolumes(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data } = await axios.get<VolumesResponse>(
|
||||
buildUrl(environmentId, 'volumes')
|
||||
);
|
||||
const { data } = await axios.get<VolumesResponse>(buildUrl(environmentId));
|
||||
|
||||
return data.Volumes;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error, 'Unable to retrieve volumes');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId, action: string, id?: string) {
|
||||
let url = buildDockerUrl(environmentId, action);
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue