mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
feature(helm): move helm charts inside advance deployments (create from manifest) [EE-5999] (#10395)
This commit is contained in:
parent
9885694df6
commit
b468070945
49 changed files with 877 additions and 388 deletions
|
@ -8,7 +8,7 @@ export interface Props extends IconProps {
|
|||
size?: BadgeSize;
|
||||
}
|
||||
|
||||
export function BadgeIcon({ icon, size = '3xl' }: Props) {
|
||||
export function BadgeIcon({ icon, size = '3xl', iconClass }: Props) {
|
||||
const sizeClasses = iconSizeToClasses(size);
|
||||
return (
|
||||
<div
|
||||
|
@ -22,7 +22,7 @@ export function BadgeIcon({ icon, size = '3xl' }: Props) {
|
|||
`
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} className="!flex" />
|
||||
<Icon icon={icon} className={clsx('!flex', iconClass)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -92,18 +92,18 @@ export function BoxSelectorItem<T extends Value>({
|
|||
}
|
||||
|
||||
if (option.iconType === 'badge') {
|
||||
return <BadgeIcon icon={option.icon} />;
|
||||
return <BadgeIcon icon={option.icon} iconClass={option.iconClass} />;
|
||||
}
|
||||
|
||||
if (option.iconType === 'raw') {
|
||||
return (
|
||||
<Icon
|
||||
icon={option.icon}
|
||||
className={clsx(styles.icon, '!flex items-center')}
|
||||
className={clsx(styles.icon, option.iconClass, '!flex items-center')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <LogoIcon icon={option.icon} />;
|
||||
return <LogoIcon icon={option.icon} iconClass={option.iconClass} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { Icon, IconProps } from '@@/Icon';
|
||||
|
||||
type Props = IconProps;
|
||||
|
||||
export function LogoIcon({ icon }: Props) {
|
||||
export function LogoIcon({ icon, iconClass }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
|
@ -10,7 +12,7 @@ export function LogoIcon({ icon }: Props) {
|
|||
items-center justify-center text-7xl
|
||||
`}
|
||||
>
|
||||
<Icon icon={icon} className="!flex" />
|
||||
<Icon icon={icon} className={clsx('!flex', iconClass)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Edit, FileText, Globe, UploadCloud } from 'lucide-react';
|
||||
|
||||
import GitIcon from '@/assets/ico/git.svg?c';
|
||||
import Helm from '@/assets/ico/helm.svg?c';
|
||||
|
||||
import { BoxSelectorOption } from '../types';
|
||||
|
||||
|
@ -49,6 +50,15 @@ export const customTemplate: BoxSelectorOption<'template'> = {
|
|||
value: 'template',
|
||||
};
|
||||
|
||||
export const helm: BoxSelectorOption<'helm'> = {
|
||||
id: 'method_helm',
|
||||
icon: Helm,
|
||||
label: 'Helm chart',
|
||||
description: 'Use a Helm chart',
|
||||
value: 'helm',
|
||||
iconClass: '!text-[#0f1689] th-dark:!text-white th-highcontrast:!text-white',
|
||||
};
|
||||
|
||||
export const url: BoxSelectorOption<'url'> = {
|
||||
id: 'method_url',
|
||||
icon: Globe,
|
||||
|
|
|
@ -17,4 +17,5 @@ export interface BoxSelectorOption<T extends Value> extends IconProps {
|
|||
readonly disabledWhenLimited?: boolean;
|
||||
readonly hide?: boolean;
|
||||
readonly iconType?: 'raw' | 'badge' | 'logo';
|
||||
readonly iconClass?: string;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import Svg, { SvgIcons } from './Svg';
|
|||
|
||||
export interface IconProps {
|
||||
icon: ReactNode | ComponentType<unknown>;
|
||||
iconClass?: string;
|
||||
}
|
||||
|
||||
export type IconMode =
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import helm from '@/assets/ico/helm.svg?c';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { HelmRepositoryDatatableActions } from './HelmRepositoryDatatableActions';
|
||||
import { useHelmRepositories } from './helm-repositories.service';
|
||||
import { HelmRepository } from './types';
|
||||
|
||||
const storageKey = 'helmRepository';
|
||||
|
||||
const settingsStore = createPersistedStore(storageKey);
|
||||
|
||||
export function HelmRepositoryDatatable() {
|
||||
const { user } = useCurrentUser();
|
||||
const helmReposQuery = useHelmRepositories(user.Id);
|
||||
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
|
||||
const helmRepos = useMemo(() => {
|
||||
const helmRepos = [];
|
||||
if (helmReposQuery.data?.GlobalRepository) {
|
||||
const helmrepository: HelmRepository = {
|
||||
Global: true,
|
||||
URL: helmReposQuery.data.GlobalRepository,
|
||||
Id: 0,
|
||||
UserId: 0,
|
||||
};
|
||||
helmRepos.push(helmrepository);
|
||||
}
|
||||
return [...helmRepos, ...(helmReposQuery.data?.UserRepositories ?? [])];
|
||||
}, [
|
||||
helmReposQuery.data?.GlobalRepository,
|
||||
helmReposQuery.data?.UserRepositories,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
dataset={helmRepos}
|
||||
settingsManager={tableState}
|
||||
columns={columns}
|
||||
title="Helm Repositories"
|
||||
titleIcon={helm}
|
||||
renderTableActions={(selectedRows) => (
|
||||
<HelmRepositoryDatatableActions selectedItems={selectedRows} />
|
||||
)}
|
||||
emptyContentLabel="No Helm repository found"
|
||||
isLoading={helmReposQuery.isLoading}
|
||||
isRowSelectable={(row) => !row.original.Global}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
|
||||
import { confirmDestructive } from '@@/modals/confirm';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { HelmRepository } from './types';
|
||||
import { useDeleteHelmRepositoriesMutation } from './helm-repositories.service';
|
||||
|
||||
interface Props {
|
||||
selectedItems: HelmRepository[];
|
||||
}
|
||||
|
||||
export function HelmRepositoryDatatableActions({ selectedItems }: Props) {
|
||||
const router = useRouter();
|
||||
const deleteHelmRepoMutation = useDeleteHelmRepositoriesMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={selectedItems.length < 1}
|
||||
color="dangerlight"
|
||||
onClick={() => onDeleteClick(selectedItems)}
|
||||
data-cy="credentials-deleteButton"
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.stateService.go('portainer.account.createHelmRepository')
|
||||
}
|
||||
data-cy="credentials-addButton"
|
||||
icon={Plus}
|
||||
>
|
||||
Add Helm Repository
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
async function onDeleteClick(selectedItems: HelmRepository[]) {
|
||||
const confirmed = await confirmDestructive({
|
||||
title: 'Confirm action',
|
||||
message: `Are you sure you want to remove the selected Helm ${pluralize(
|
||||
selectedItems.length,
|
||||
'repository',
|
||||
'repositories'
|
||||
)}?`,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteHelmRepoMutation.mutate(selectedItems);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { HelmRepository } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<HelmRepository>();
|
|
@ -0,0 +1,3 @@
|
|||
import { url } from './url';
|
||||
|
||||
export const columns = [url];
|
|
@ -0,0 +1,3 @@
|
|||
import { columnHelper } from './helper';
|
||||
|
||||
export const url = columnHelper.accessor('URL', { id: 'url' });
|
|
@ -0,0 +1,108 @@
|
|||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { success as notifySuccess } from '@/portainer/services/notifications';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
|
||||
import {
|
||||
CreateHelmRepositoryPayload,
|
||||
HelmRepository,
|
||||
HelmRepositories,
|
||||
} from './types';
|
||||
|
||||
export async function createHelmRepository(
|
||||
helmRepository: CreateHelmRepositoryPayload
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.post<{ helmRepository: HelmRepository }>(
|
||||
buildUrl(helmRepository.UserId),
|
||||
helmRepository
|
||||
);
|
||||
return data.helmRepository;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to create Helm repository');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHelmRepositories(userId: number) {
|
||||
try {
|
||||
const { data } = await axios.get<HelmRepositories>(buildUrl(userId));
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to get Helm repositories');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHelmRepository(repo: HelmRepository) {
|
||||
try {
|
||||
await axios.delete<HelmRepository[]>(buildUrl(repo.UserId, repo.Id));
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to delete Helm repository');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHelmRepositories(repos: HelmRepository[]) {
|
||||
try {
|
||||
await Promise.all(repos.map((repo) => deleteHelmRepository(repo)));
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to delete Helm repositories');
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeleteHelmRepositoryMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(deleteHelmRepository, {
|
||||
onSuccess: (_, helmRepository) => {
|
||||
notifySuccess('Helm repository deleted successfully', helmRepository.URL);
|
||||
return queryClient.invalidateQueries(['helmrepositories']);
|
||||
},
|
||||
...withError('Unable to delete Helm repository'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteHelmRepositoriesMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(deleteHelmRepositories, {
|
||||
onSuccess: () => {
|
||||
notifySuccess(
|
||||
'Success',
|
||||
`Helm ${pluralize(
|
||||
deleteHelmRepositories.length,
|
||||
'repository',
|
||||
'repositories'
|
||||
)} deleted successfully`
|
||||
);
|
||||
return queryClient.invalidateQueries(['helmrepositories']);
|
||||
},
|
||||
...withError('Unable to delete Helm repositories'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useHelmRepositories(userId: number) {
|
||||
return useQuery('helmrepositories', () => getHelmRepositories(userId), {
|
||||
staleTime: 20,
|
||||
...withError('Unable to retrieve Helm repositories'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateHelmRepositoryMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(createHelmRepository, {
|
||||
onSuccess: (_, payload) => {
|
||||
notifySuccess('Helm repository created successfully', payload.URL);
|
||||
return queryClient.invalidateQueries(['helmrepositories']);
|
||||
},
|
||||
...withError('Unable to create Helm repository'),
|
||||
});
|
||||
}
|
||||
|
||||
function buildUrl(userId: number, helmRepositoryId?: number) {
|
||||
if (helmRepositoryId) {
|
||||
return `/users/${userId}/helm/repositories/${helmRepositoryId}`;
|
||||
}
|
||||
return `/users/${userId}/helm/repositories`;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { HelmRepositoryDatatable } from './HelmRepositoryDatatable';
|
|
@ -0,0 +1,20 @@
|
|||
export interface CreateHelmRepositoryPayload {
|
||||
UserId: number;
|
||||
URL: string;
|
||||
}
|
||||
|
||||
export interface HelmRepositoryFormValues {
|
||||
URL: string;
|
||||
}
|
||||
|
||||
export interface HelmRepository {
|
||||
Id: number;
|
||||
UserId: number;
|
||||
URL: string;
|
||||
Global: boolean;
|
||||
}
|
||||
|
||||
export interface HelmRepositories {
|
||||
UserRepositories: HelmRepository[];
|
||||
GlobalRepository: string;
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
import { CreateHelmRepositoryForm } from './CreateHelmRespositoriesForm';
|
||||
|
||||
export function CreateHelmRepositoriesView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create Helm repository"
|
||||
breadcrumbs={[
|
||||
{ label: 'My account', link: 'portainer.account' },
|
||||
{ label: 'Create Helm repository' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<CreateHelmRepositoryForm />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
|
||||
import {
|
||||
CreateHelmRepositoryPayload,
|
||||
HelmRepositoryFormValues,
|
||||
} from '../../AccountView/HelmRepositoryDatatable/types';
|
||||
import {
|
||||
useHelmRepositories,
|
||||
useCreateHelmRepositoryMutation,
|
||||
} from '../../AccountView/HelmRepositoryDatatable/helm-repositories.service';
|
||||
import { HelmRepositoryForm } from '../components/HelmRepositoryForm';
|
||||
|
||||
export function CreateHelmRepositoryForm() {
|
||||
const router = useRouter();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
const createHelmRepositoryMutation = useCreateHelmRepositoryMutation();
|
||||
const helmReposQuery = useHelmRepositories(currentUser.user.Id);
|
||||
|
||||
return (
|
||||
<HelmRepositoryForm
|
||||
isLoading={createHelmRepositoryMutation.isLoading}
|
||||
onSubmit={onSubmit}
|
||||
URLs={helmReposQuery.data?.UserRepositories.map((x) => x.URL) || []}
|
||||
/>
|
||||
);
|
||||
|
||||
function onSubmit(values: HelmRepositoryFormValues) {
|
||||
const payload: CreateHelmRepositoryPayload = {
|
||||
...values,
|
||||
UserId: currentUser.user.Id,
|
||||
};
|
||||
createHelmRepositoryMutation.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
router.stateService.go('portainer.account');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateHelmRepositoriesView } from './CreateHelmRepositoriesView';
|
|
@ -0,0 +1,19 @@
|
|||
import { object, string } from 'yup';
|
||||
|
||||
import { isValidUrl } from '@@/form-components/validate-url';
|
||||
|
||||
export function noDuplicateURLsSchema(urls: string[]) {
|
||||
return string()
|
||||
.required('URL is required')
|
||||
.test('not existing name', 'URL is already added', (newName) =>
|
||||
urls.every((name) => name !== newName)
|
||||
);
|
||||
}
|
||||
|
||||
export function validationSchema(urls: string[]) {
|
||||
return object().shape({
|
||||
URL: noDuplicateURLsSchema(urls)
|
||||
.test('valid-url', 'Invalid URL', (value) => !value || isValidUrl(value))
|
||||
.required('URL is required'),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import { Field, Form, Formik } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { HelmRepositoryFormValues } from '../../AccountView/HelmRepositoryDatatable/types';
|
||||
|
||||
import { validationSchema } from './CreateHelmRepositoryForm.validation';
|
||||
|
||||
type Props = {
|
||||
isEditing?: boolean;
|
||||
isLoading: boolean;
|
||||
onSubmit: (formValues: HelmRepositoryFormValues) => void;
|
||||
URLs: string[];
|
||||
};
|
||||
|
||||
const defaultInitialValues: HelmRepositoryFormValues = {
|
||||
URL: '',
|
||||
};
|
||||
|
||||
export function HelmRepositoryForm({
|
||||
isEditing = false,
|
||||
isLoading,
|
||||
onSubmit,
|
||||
URLs,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Formik<HelmRepositoryFormValues>
|
||||
initialValues={defaultInitialValues}
|
||||
enableReinitialize
|
||||
validationSchema={() => validationSchema(URLs)}
|
||||
onSubmit={(values) => onSubmit(values)}
|
||||
validateOnMount
|
||||
>
|
||||
{({ values, errors, handleSubmit, isValid, dirty }) => (
|
||||
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
|
||||
<FormControl inputId="url" label="URL" errors={errors.URL} required>
|
||||
<Field
|
||||
as={Input}
|
||||
name="URL"
|
||||
value={values.URL}
|
||||
autoComplete="off"
|
||||
id="URL"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 mt-3">
|
||||
<LoadingButton
|
||||
disabled={!isValid || !dirty}
|
||||
isLoading={isLoading}
|
||||
loadingText="Saving Helm repository..."
|
||||
>
|
||||
{isEditing ? 'Update Helm repository' : 'Save Helm repository'}
|
||||
</LoadingButton>
|
||||
{isEditing && (
|
||||
<Button
|
||||
color="default"
|
||||
onClick={() => router.stateService.go('portainer.account')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
|
@ -23,7 +23,7 @@ export function ScreenBannerFieldset() {
|
|||
<SwitchField
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
label="Login screen banner"
|
||||
checked={isEnabled}
|
||||
checked
|
||||
name="toggle_login_banner"
|
||||
disabled={isDemoQuery.data}
|
||||
onChange={(checked) => setIsEnabled(checked)}
|
||||
|
|
|
@ -12,7 +12,7 @@ export function HelmSection() {
|
|||
<FormSection title="Helm Repository">
|
||||
<div className="mb-2">
|
||||
<TextTip color="blue">
|
||||
You can specify the URL to your own helm repository here. See the{' '}
|
||||
You can specify the URL to your own Helm repository here. See the{' '}
|
||||
<a
|
||||
href="https://helm.sh/docs/topics/chart_repository/"
|
||||
target="_blank"
|
||||
|
|
|
@ -2,7 +2,6 @@ import { Box, Edit, Layers, Lock, Server, Shuffle } from 'lucide-react';
|
|||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import Helm from '@/assets/ico/vendor/helm.svg?c';
|
||||
import Route from '@/assets/ico/route.svg?c';
|
||||
|
||||
import { DashboardLink } from '../items/DashboardLink';
|
||||
|
@ -49,19 +48,6 @@ export function KubernetesSidebar({ environmentId }: Props) {
|
|||
data-cy="k8sSidebar-namespaces"
|
||||
/>
|
||||
|
||||
<Authorized
|
||||
authorizations="HelmInstallChart"
|
||||
environmentId={environmentId}
|
||||
>
|
||||
<SidebarItem
|
||||
to="kubernetes.templates.helm"
|
||||
params={{ endpointId: environmentId }}
|
||||
icon={Helm}
|
||||
label="Helm"
|
||||
data-cy="k8sSidebar-helm"
|
||||
/>
|
||||
</Authorized>
|
||||
|
||||
<SidebarItem
|
||||
to="kubernetes.applications"
|
||||
params={{ endpointId: environmentId }}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue