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

feat(oci): oci helm support [r8s-361] (#787)

This commit is contained in:
Ali 2025-07-13 10:37:43 +12:00 committed by GitHub
parent b6a6ce9aaf
commit 2697d6c5d7
80 changed files with 4264 additions and 812 deletions

View file

@ -98,6 +98,7 @@ function renderComponent({
selectedChart={selectedChart}
namespace={namespace}
name={name}
isRepoAvailable
/>
)),
user

View file

@ -12,6 +12,10 @@ import { Option } from '@@/form-components/PortainerSelect';
import { Chart } from '../types';
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation';
import {
ChartVersion,
useHelmRepoVersions,
} from '../queries/useHelmRepoVersions';
import { HelmInstallInnerForm } from './HelmInstallInnerForm';
import { HelmInstallFormValues } from './types';
@ -20,22 +24,39 @@ type Props = {
selectedChart: Chart;
namespace?: string;
name?: string;
isRepoAvailable: boolean;
};
export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
export function HelmInstallForm({
selectedChart,
namespace,
name,
isRepoAvailable,
}: Props) {
const environmentId = useEnvironmentId();
const router = useRouter();
const analytics = useAnalytics();
const versionOptions: Option<string>[] = selectedChart.versions.map(
const helmRepoVersionsQuery = useHelmRepoVersions(
selectedChart.name,
60 * 60 * 1000, // 1 hour
[
{
repo: selectedChart.repo,
},
]
);
const versions = helmRepoVersionsQuery.data;
const versionOptions: Option<ChartVersion>[] = versions.map(
(version, index) => ({
label: index === 0 ? `${version} (latest)` : version,
label: index === 0 ? `${version.Version} (latest)` : version.Version,
value: version,
})
);
const defaultVersion = versionOptions[0]?.value;
const initialValues: HelmInstallFormValues = {
values: '',
version: defaultVersion ?? '',
version: defaultVersion?.Version ?? '',
repo: defaultVersion?.Repo ?? selectedChart.repo ?? '',
};
const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId);
@ -55,6 +76,8 @@ export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
namespace={namespace}
name={name}
versionOptions={versionOptions}
isVersionsLoading={helmRepoVersionsQuery.isInitialLoading}
isRepoAvailable={isRepoAvailable}
/>
</Formik>
);

View file

@ -1,14 +1,15 @@
import { Form, useFormikContext } from 'formik';
import { useMemo } from 'react';
import { FormActions } from '@@/form-components/FormActions';
import { FormControl } from '@@/form-components/FormControl';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { FormSection } from '@@/form-components/FormSection';
import { LoadingButton } from '@@/buttons';
import { Chart } from '../types';
import { useHelmChartValues } from '../queries/useHelmChartValues';
import { HelmValuesInput } from '../components/HelmValuesInput';
import { ChartVersion } from '../queries/useHelmRepoVersions';
import { HelmInstallFormValues } from './types';
@ -16,7 +17,9 @@ type Props = {
selectedChart: Chart;
namespace?: string;
name?: string;
versionOptions: Option<string>[];
versionOptions: Option<ChartVersion>[];
isVersionsLoading: boolean;
isRepoAvailable: boolean;
};
export function HelmInstallInnerForm({
@ -24,21 +27,39 @@ export function HelmInstallInnerForm({
namespace,
name,
versionOptions,
isVersionsLoading,
isRepoAvailable,
}: Props) {
const { values, setFieldValue, isSubmitting } =
useFormikContext<HelmInstallFormValues>();
const chartValuesRefQuery = useHelmChartValues({
chart: selectedChart.name,
repo: selectedChart.repo,
version: values?.version,
});
const selectedVersion = useMemo(
const selectedVersion: ChartVersion | undefined = useMemo(
() =>
versionOptions.find((v) => v.value === values.version)?.value ??
versionOptions[0]?.value,
[versionOptions, values.version]
versionOptions.find(
(v) =>
v.value.Version === values.version &&
v.value.Repo === selectedChart.repo
)?.value ?? versionOptions[0]?.value,
[versionOptions, values.version, selectedChart.repo]
);
const repoParams = {
repo: selectedChart.repo,
};
// use isLatestVersionFetched to cache the latest version, to avoid duplicate fetches
const isLatestVersionFetched =
// if no version is selected, the latest version gets fetched
!versionOptions.length ||
// otherwise check if the selected version is the latest version
(selectedVersion?.Version === versionOptions[0]?.value.Version &&
selectedVersion?.Repo === versionOptions[0]?.value.Repo);
const chartValuesRefQuery = useHelmChartValues(
{
chart: selectedChart.name,
version: values?.version,
...repoParams,
},
isLatestVersionFetched
);
return (
@ -48,14 +69,18 @@ export function HelmInstallInnerForm({
<FormControl
label="Version"
inputId="version-input"
isLoading={isVersionsLoading}
loadingText="Loading versions..."
>
<PortainerSelect<string>
<PortainerSelect<ChartVersion>
value={selectedVersion}
options={versionOptions}
noOptionsMessage={() => 'No versions found'}
placeholder="Select a version"
onChange={(version) => {
if (version) {
setFieldValue('version', version);
setFieldValue('version', version.Version);
setFieldValue('repo', version.Repo);
}
}}
data-cy="helm-version-input"
@ -70,13 +95,15 @@ export function HelmInstallInnerForm({
</FormSection>
</div>
<FormActions
submitLabel="Install"
<LoadingButton
className="!ml-0"
loadingText="Installing Helm chart"
isLoading={isSubmitting}
isValid={!!namespace && !!name}
disabled={!namespace || !name || !isRepoAvailable}
data-cy="helm-install"
/>
>
Install
</LoadingButton>
</Form>
);
}

View file

@ -1,15 +1,20 @@
import { useState } from 'react';
import { compact } from 'lodash';
import { useCurrentUser } from '@/react/hooks/useUser';
import { Chart } from '../types';
import { useHelmChartList } from '../queries/useHelmChartList';
import { useHelmRegistries } from '../queries/useHelmRegistries';
import { FormSection } from '@@/form-components/FormSection';
import { useHelmHTTPChartList } from '../queries/useHelmChartList';
import { Chart } from '../types';
import {
HelmRegistrySelect,
RepoValue,
} from '../components/HelmRegistrySelect';
import { useHelmRepoOptions } from '../queries/useHelmRepositories';
import { HelmTemplatesList } from './HelmTemplatesList';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
import { HelmInstallForm } from './HelmInstallForm';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
import { HelmTemplatesList } from './HelmTemplatesList';
interface Props {
onSelectHelmChart: (chartName: string) => void;
@ -19,11 +24,60 @@ interface Props {
export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
const [selectedChart, setSelectedChart] = useState<Chart | null>(null);
const [selectedRegistry, setSelectedRegistry] = useState<string | null>(null);
const [selectedRepo, setSelectedRepo] = useState<RepoValue | null>(null);
const { user } = useCurrentUser();
const helmReposQuery = useHelmRegistries();
const chartListQuery = useHelmChartList(user.Id, compact([selectedRegistry]));
const chartListQuery = useHelmHTTPChartList(
user.Id,
selectedRepo?.repoUrl ?? '',
!!selectedRepo?.repoUrl
);
const repoOptionsQuery = useHelmRepoOptions();
const isRepoAvailable =
!!repoOptionsQuery.data && repoOptionsQuery.data.length > 0;
return (
<div className="row">
<div className="col-sm-12 p-0">
<FormSection title="Helm chart">
{selectedChart ? (
<>
<HelmTemplatesSelectedItem
selectedChart={selectedChart}
clearHelmChart={clearHelmChart}
/>
<HelmInstallForm
selectedChart={selectedChart}
namespace={namespace}
name={name}
isRepoAvailable={isRepoAvailable}
/>
</>
) : (
<>
<HelmRegistrySelect
selectedRegistry={selectedRepo}
onRegistryChange={setSelectedRepo}
namespace={namespace}
isRepoAvailable={isRepoAvailable}
isLoading={repoOptionsQuery.isLoading}
isError={repoOptionsQuery.isError}
repoOptions={repoOptionsQuery.data ?? []}
/>
{selectedRepo && (
<HelmTemplatesList
charts={chartListQuery.data ?? []}
selectAction={handleChartSelection}
isLoadingCharts={chartListQuery.isInitialLoading}
selectedRegistry={selectedRepo}
/>
)}
</>
)}
</FormSection>
</div>
</div>
);
function clearHelmChart() {
setSelectedChart(null);
onSelectHelmChart('');
@ -33,33 +87,4 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
setSelectedChart(chart);
onSelectHelmChart(chart.name);
}
return (
<div className="row">
<div className="col-sm-12 p-0">
{selectedChart ? (
<>
<HelmTemplatesSelectedItem
selectedChart={selectedChart}
clearHelmChart={clearHelmChart}
/>
<HelmInstallForm
selectedChart={selectedChart}
namespace={namespace}
name={name}
/>
</>
) : (
<HelmTemplatesList
charts={chartListQuery.data}
selectAction={handleChartSelection}
isLoading={chartListQuery.isInitialLoading}
registries={helmReposQuery.data ?? []}
selectedRegistry={selectedRegistry}
setSelectedRegistry={setSelectedRegistry}
/>
)}
</div>
</div>
);
}

View file

@ -46,25 +46,63 @@ const mockCharts: Chart[] = [
const selectActionMock = vi.fn();
const mockUseEnvironmentId = vi.fn(() => 1);
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => mockUseEnvironmentId(),
}));
// Mock the helm registries query
vi.mock('../queries/useHelmRegistries', () => ({
useHelmRegistries: vi.fn(() => ({
data: ['https://example.com', 'https://example.com/2'],
isInitialLoading: false,
isError: false,
})),
}));
// Mock the environment registries query
vi.mock(
'@/react/portainer/environments/queries/useEnvironmentRegistries',
() => ({
useEnvironmentRegistries: vi.fn(() => ({
data: [
{ Id: 1, URL: 'https://registry.example.com' },
{ Id: 2, URL: 'https://registry2.example.com' },
],
isInitialLoading: false,
isError: false,
})),
})
);
function renderComponent({
loading = false,
charts = mockCharts,
selectAction = selectActionMock,
selectedRegistry = '',
selectedRegistry = {
repoUrl: 'https://example.com',
name: 'Test Registry',
},
}: {
loading?: boolean;
charts?: Chart[];
selectAction?: (chart: Chart) => void;
selectedRegistry?: {
repoUrl?: string;
name?: string;
} | null;
} = {}) {
const user = new UserViewModel({ Username: 'user' });
const registries = ['https://example.com', 'https://example.com/2'];
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<HelmTemplatesList
isLoading={loading}
isLoadingCharts={loading}
charts={charts}
selectAction={selectAction}
registries={registries}
selectedRegistry={selectedRegistry}
setSelectedRegistry={() => {}}
/>
)),
user
@ -81,8 +119,10 @@ describe('HelmTemplatesList', () => {
it('should display title and charts list', async () => {
renderComponent();
// Check for the title
expect(screen.getByText('Helm chart')).toBeInTheDocument();
// Check for the title with registry name
expect(
screen.getByText('Select a helm chart from Test Registry')
).toBeInTheDocument();
// Check for charts
expect(screen.getByText('test-chart-1')).toBeInTheDocument();
@ -160,21 +200,27 @@ describe('HelmTemplatesList', () => {
});
it('should show empty message when no charts are available and a registry is selected', async () => {
renderComponent({ charts: [], selectedRegistry: 'https://example.com' });
renderComponent({
charts: [],
selectedRegistry: {
repoUrl: 'https://example.com',
name: 'Test Registry',
},
});
// Check for empty message
expect(
screen.getByText('No helm charts available in this registry.')
screen.getByText('No helm charts available in this repository.')
).toBeInTheDocument();
});
it("should show 'select registry' message when no charts are available and no registry is selected", async () => {
renderComponent({ charts: [] });
renderComponent({ charts: [], selectedRegistry: null });
// Check for message
expect(
screen.getByText(
'Please select a registry to view available Helm charts.'
'Please select a repository to view available Helm charts.'
)
).toBeInTheDocument();
});

View file

@ -1,59 +1,47 @@
import { useState, useMemo } from 'react';
import { components, OptionProps } from 'react-select';
import {
PortainerSelect,
Option,
} from '@/react/components/form-components/PortainerSelect';
import { Link } from '@/react/components/Link';
import { PortainerSelect } from '@/react/components/form-components/PortainerSelect';
import { InsightsBox } from '@@/InsightsBox';
import { SearchBar } from '@@/datatables/SearchBar';
import { InlineLoader } from '@@/InlineLoader';
import { Chart } from '../types';
import { RepoValue } from '../components/HelmRegistrySelect';
import { HelmTemplatesListItem } from './HelmTemplatesListItem';
interface Props {
isLoading: boolean;
isLoadingCharts: boolean;
charts?: Chart[];
selectAction: (chart: Chart) => void;
registries: string[];
selectedRegistry: string | null;
setSelectedRegistry: (registry: string | null) => void;
selectedRegistry: RepoValue | null;
}
export function HelmTemplatesList({
isLoading,
isLoadingCharts,
charts = [],
selectAction,
registries,
selectedRegistry,
setSelectedRegistry,
}: Props) {
const [textFilter, setTextFilter] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const categories = useMemo(() => getCategories(charts), [charts]);
const registryOptions = useMemo(
() =>
registries.map((registry) => ({
label: registry,
value: registry,
})),
[registries]
);
const filteredCharts = useMemo(
() => getFilteredCharts(charts, textFilter, selectedCategory),
[charts, textFilter, selectedCategory]
);
const isSelectedRegistryEmpty =
!isLoadingCharts && charts.length === 0 && selectedRegistry;
return (
<section className="datatable" aria-label="Helm charts">
<div className="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0 !overflow-visible">
<div className="toolBarTitle vertical-center">Helm chart</div>
<div className="toolBar vertical-center relative w-full !gap-x-5 !gap-y-1 !px-0 overflow-auto">
<div className="toolBarTitle vertical-center whitespace-nowrap">
Select a helm chart from {selectedRegistry?.name}
</div>
<SearchBar
value={textFilter}
@ -63,20 +51,7 @@ export function HelmTemplatesList({
className="!mr-0 h-9"
/>
<div className="w-full sm:w-1/4">
<PortainerSelect
placeholder="Select a registry"
value={selectedRegistry ?? ''}
options={registryOptions}
onChange={setSelectedRegistry}
isClearable
bindToBody
components={{ Option: RegistryOption }}
data-cy="helm-registry-select"
/>
</div>
<div className="w-full sm:w-1/4">
<div className="w-full sm:w-1/4 flex-none">
<PortainerSelect
placeholder="Select a category"
value={selectedCategory}
@ -88,42 +63,6 @@ export function HelmTemplatesList({
/>
</div>
</div>
<div className="w-fit">
<div className="small text-muted mb-2">
Select the Helm chart to use. Bring further Helm charts into your
selection list via{' '}
<Link
to="portainer.account"
params={{ '#': 'helm-repositories' }}
data-cy="helm-repositories-link"
>
User settings - Helm repositories
</Link>
.
</div>
<InsightsBox
header="Disclaimer"
type="slim"
content={
<>
At present Portainer does not support OCI format Helm charts.
Support for OCI charts will be available in a future release.
<br />
If you would like to provide feedback on OCI support or get access
to early releases to test this functionality,{' '}
<a
href="https://bit.ly/3WVkayl"
target="_blank"
rel="noopener noreferrer"
>
please get in touch
</a>
.
</>
}
/>
</div>
<div className="blocklist !px-0" role="list">
{filteredCharts.map((chart) => (
@ -138,7 +77,7 @@ export function HelmTemplatesList({
<div className="text-muted small mt-4">No Helm charts found</div>
)}
{isLoading && (
{isLoadingCharts && (
<div className="flex flex-col">
<InlineLoader className="justify-center">
Loading helm charts...
@ -151,15 +90,15 @@ export function HelmTemplatesList({
</div>
)}
{!isLoading && charts.length === 0 && selectedRegistry && (
{isSelectedRegistryEmpty && (
<div className="text-muted text-center">
No helm charts available in this registry.
No helm charts available in this repository.
</div>
)}
{!selectedRegistry && (
<div className="text-muted text-center">
Please select a registry to view available Helm charts.
Please select a repository to view available Helm charts.
</div>
)}
</div>
@ -167,20 +106,6 @@ export function HelmTemplatesList({
);
}
// truncate the registry text, because some registry names are urls, which are too long
function RegistryOption(props: OptionProps<Option<string>>) {
const { data: registry } = props;
return (
<div title={registry.value}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<components.Option {...props} className="whitespace-nowrap truncate">
{registry.value}
</components.Option>
</div>
);
}
/**
* Get categories from charts
* @param charts - The charts to get the categories from

View file

@ -26,7 +26,7 @@ export function HelmTemplatesSelectedItem({
<FallbackImage
src={selectedChart.icon}
fallbackIcon={HelmIcon}
className="h-16 w-16"
className="h-16 w-16 flex-none"
/>
<div className="col-sm-12">
<div>

View file

@ -1,4 +1,5 @@
export type HelmInstallFormValues = {
values: string;
version: string;
repo: string;
};