1
0
Fork 0
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:
Prabhat Khera 2023-10-09 11:20:44 +13:00 committed by GitHub
parent 9885694df6
commit b468070945
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 877 additions and 388 deletions

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

@ -7,6 +7,7 @@ import Svg, { SvgIcons } from './Svg';
export interface IconProps {
icon: ReactNode | ComponentType<unknown>;
iconClass?: string;
}
export type IconMode =

View file

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

View file

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

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { HelmRepository } from '../types';
export const columnHelper = createColumnHelper<HelmRepository>();

View file

@ -0,0 +1,3 @@
import { url } from './url';
export const columns = [url];

View file

@ -0,0 +1,3 @@
import { columnHelper } from './helper';
export const url = columnHelper.accessor('URL', { id: 'url' });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"

View file

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