mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 13:29:41 +02:00
feat(helm): add registry dropdown [r8s-340] (#779)
This commit is contained in:
parent
c9e3717ce3
commit
1963edda66
16 changed files with 288 additions and 190 deletions
|
@ -1,4 +1,4 @@
|
||||||
import { CellContext } from '@tanstack/react-table';
|
import { CellContext, Row } from '@tanstack/react-table';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -6,14 +6,22 @@ import {
|
||||||
KubernetesApplicationTypes,
|
KubernetesApplicationTypes,
|
||||||
} from '@/kubernetes/models/application/models/appConstants';
|
} from '@/kubernetes/models/application/models/appConstants';
|
||||||
|
|
||||||
|
import { filterHOC } from '@@/datatables/Filter';
|
||||||
|
|
||||||
import styles from './columns.status.module.css';
|
import styles from './columns.status.module.css';
|
||||||
import { helper } from './columns.helper';
|
import { helper } from './columns.helper';
|
||||||
import { ApplicationRowData } from './types';
|
import { ApplicationRowData } from './types';
|
||||||
|
|
||||||
export const status = helper.accessor('Status', {
|
export const status = helper.accessor(getStatusSummary, {
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: Cell,
|
cell: Cell,
|
||||||
enableSorting: false,
|
meta: {
|
||||||
|
filter: filterHOC('Filter by status'),
|
||||||
|
},
|
||||||
|
enableColumnFilter: true,
|
||||||
|
filterFn: (row: Row<ApplicationRowData>, _: string, filterValue: string[]) =>
|
||||||
|
filterValue.length === 0 ||
|
||||||
|
filterValue.includes(getStatusSummary(row.original)),
|
||||||
});
|
});
|
||||||
|
|
||||||
function Cell({
|
function Cell({
|
||||||
|
@ -67,3 +75,17 @@ function Cell({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusSummary(item: ApplicationRowData): 'Ready' | 'Not Ready' {
|
||||||
|
if (
|
||||||
|
item.ApplicationType === KubernetesApplicationTypes.Pod &&
|
||||||
|
item.Pods &&
|
||||||
|
item.Pods.length > 0
|
||||||
|
) {
|
||||||
|
return item.Pods[0].Status === 'Running' ? 'Ready' : 'Not Ready';
|
||||||
|
}
|
||||||
|
return item.TotalPodsCount > 0 &&
|
||||||
|
item.TotalPodsCount === item.RunningPodsCount
|
||||||
|
? 'Ready'
|
||||||
|
: 'Not Ready';
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { CellContext } from '@tanstack/react-table';
|
import { CellContext, Row } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { isoDate, truncate } from '@/portainer/filters/filters';
|
import { isoDate, truncate } from '@/portainer/filters/filters';
|
||||||
import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace';
|
import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace';
|
||||||
|
|
||||||
import { Link } from '@@/Link';
|
import { Link } from '@@/Link';
|
||||||
import { SystemBadge } from '@@/Badge/SystemBadge';
|
import { SystemBadge } from '@@/Badge/SystemBadge';
|
||||||
|
import { filterHOC } from '@@/datatables/Filter';
|
||||||
|
|
||||||
import { Application } from './types';
|
import { Application } from './types';
|
||||||
import { helper } from './columns.helper';
|
import { helper } from './columns.helper';
|
||||||
|
@ -49,7 +50,15 @@ export const image = helper.accessor('Image', {
|
||||||
});
|
});
|
||||||
|
|
||||||
export const appType = helper.accessor('ApplicationType', {
|
export const appType = helper.accessor('ApplicationType', {
|
||||||
header: 'Application Type',
|
header: 'Application type',
|
||||||
|
meta: {
|
||||||
|
filter: filterHOC('Filter by application type'),
|
||||||
|
},
|
||||||
|
enableColumnFilter: true,
|
||||||
|
filterFn: (row: Row<Application>, _: string, filterValue: string[]) =>
|
||||||
|
filterValue.length === 0 ||
|
||||||
|
(!!row.original.ApplicationType &&
|
||||||
|
filterValue.includes(row.original.ApplicationType)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const published = helper.accessor('Services', {
|
export const published = helper.accessor('Services', {
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import {
|
import {
|
||||||
useHelmRepoVersions,
|
useHelmRepoVersions,
|
||||||
ChartVersion,
|
ChartVersion,
|
||||||
} from '../../queries/useHelmRepositories';
|
} from '../../queries/useHelmRepoVersions';
|
||||||
import { HelmRelease } from '../../types';
|
import { HelmRelease } from '../../types';
|
||||||
|
|
||||||
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||||
|
@ -25,16 +25,18 @@ vi.mock('@/portainer/services/notifications', () => ({
|
||||||
notifySuccess: vi.fn(),
|
notifySuccess: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the useHelmRepoVersions and useHelmRepositories hooks
|
vi.mock('../../queries/useHelmRegistries', () => ({
|
||||||
vi.mock('../../queries/useHelmRepositories', () => ({
|
useHelmRegistries: vi.fn(() => ({
|
||||||
useHelmRepoVersions: vi.fn(),
|
|
||||||
useHelmRepositories: vi.fn(() => ({
|
|
||||||
data: ['repo1', 'repo2'],
|
data: ['repo1', 'repo2'],
|
||||||
isInitialLoading: false,
|
isInitialLoading: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../queries/useHelmRepoVersions', () => ({
|
||||||
|
useHelmRepoVersions: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the useHelmRelease hook
|
// Mock the useHelmRelease hook
|
||||||
vi.mock('../queries/useHelmRelease', () => ({
|
vi.mock('../queries/useHelmRelease', () => ({
|
||||||
useHelmRelease: vi.fn(() => ({
|
useHelmRelease: vi.fn(() => ({
|
||||||
|
|
|
@ -13,11 +13,9 @@ import { Link } from '@@/Link';
|
||||||
|
|
||||||
import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
|
import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
|
||||||
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation';
|
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation';
|
||||||
import {
|
import { useHelmRepoVersions } from '../../queries/useHelmRepoVersions';
|
||||||
useHelmRepoVersions,
|
|
||||||
useHelmRepositories,
|
|
||||||
} from '../../queries/useHelmRepositories';
|
|
||||||
import { useHelmRelease } from '../queries/useHelmRelease';
|
import { useHelmRelease } from '../queries/useHelmRelease';
|
||||||
|
import { useHelmRegistries } from '../../queries/useHelmRegistries';
|
||||||
|
|
||||||
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||||
|
|
||||||
|
@ -38,11 +36,11 @@ export function UpgradeButton({
|
||||||
const [useCache, setUseCache] = useState(true);
|
const [useCache, setUseCache] = useState(true);
|
||||||
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
|
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
|
||||||
|
|
||||||
const repositoriesQuery = useHelmRepositories();
|
const registriesQuery = useHelmRegistries();
|
||||||
const helmRepoVersionsQuery = useHelmRepoVersions(
|
const helmRepoVersionsQuery = useHelmRepoVersions(
|
||||||
release?.chart.metadata?.name || '',
|
release?.chart.metadata?.name || '',
|
||||||
60 * 60 * 1000, // 1 hour
|
60 * 60 * 1000, // 1 hour
|
||||||
repositoriesQuery.data,
|
registriesQuery.data,
|
||||||
useCache
|
useCache
|
||||||
);
|
);
|
||||||
const versions = helmRepoVersionsQuery.data;
|
const versions = helmRepoVersionsQuery.data;
|
||||||
|
@ -50,8 +48,8 @@ export function UpgradeButton({
|
||||||
|
|
||||||
// Combined loading state
|
// Combined loading state
|
||||||
const isLoading =
|
const isLoading =
|
||||||
repositoriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching
|
registriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching
|
||||||
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
|
const isError = registriesQuery.isError || helmRepoVersionsQuery.isError;
|
||||||
const latestVersionQuery = useHelmRelease(
|
const latestVersionQuery = useHelmRelease(
|
||||||
environmentId,
|
environmentId,
|
||||||
releaseName,
|
releaseName,
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { ArrowUp } from 'lucide-react';
|
||||||
|
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { ChartVersion } from '@/react/kubernetes/helm/queries/useHelmRepositories';
|
import { ChartVersion } from '@/react/kubernetes/helm/queries/useHelmRepoVersions';
|
||||||
|
|
||||||
import { Modal, OnSubmit, openModal } from '@@/modals';
|
import { Modal, OnSubmit, openModal } from '@@/modals';
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
|
|
|
@ -43,7 +43,7 @@ vi.mock('../queries/useUpdateHelmReleaseMutation', () => ({
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../queries/useHelmRepositories', () => ({
|
vi.mock('../queries/useHelmRepoVersions', () => ({
|
||||||
useHelmRepoVersions: vi.fn(() => ({
|
useHelmRepoVersions: vi.fn(() => ({
|
||||||
data: [
|
data: [
|
||||||
{ Version: '1.0.0', AppVersion: '1.0.0' },
|
{ Version: '1.0.0', AppVersion: '1.0.0' },
|
||||||
|
@ -75,6 +75,8 @@ const mockChart: Chart = {
|
||||||
annotations: {
|
annotations: {
|
||||||
category: 'database',
|
category: 'database',
|
||||||
},
|
},
|
||||||
|
version: '1.0.1',
|
||||||
|
versions: ['1.0.0', '1.0.1'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockRouterStateService = {
|
const mockRouterStateService = {
|
||||||
|
|
|
@ -11,10 +11,6 @@ import { confirmGenericDiscard } from '@@/modals/confirm';
|
||||||
import { Option } from '@@/form-components/PortainerSelect';
|
import { Option } from '@@/form-components/PortainerSelect';
|
||||||
|
|
||||||
import { Chart } from '../types';
|
import { Chart } from '../types';
|
||||||
import {
|
|
||||||
ChartVersion,
|
|
||||||
useHelmRepoVersions,
|
|
||||||
} from '../queries/useHelmRepositories';
|
|
||||||
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation';
|
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation';
|
||||||
|
|
||||||
import { HelmInstallInnerForm } from './HelmInstallInnerForm';
|
import { HelmInstallInnerForm } from './HelmInstallInnerForm';
|
||||||
|
@ -30,23 +26,16 @@ export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
|
||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
const helmRepoVersionsQuery = useHelmRepoVersions(
|
const versionOptions: Option<string>[] = selectedChart.versions.map(
|
||||||
selectedChart.name,
|
|
||||||
60 * 60 * 1000, // 1 hour
|
|
||||||
[selectedChart.repo],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
const versions = helmRepoVersionsQuery.data;
|
|
||||||
const versionOptions: Option<ChartVersion>[] = versions.map(
|
|
||||||
(version, index) => ({
|
(version, index) => ({
|
||||||
label: index === 0 ? `${version.Version} (latest)` : version.Version,
|
label: index === 0 ? `${version} (latest)` : version,
|
||||||
value: version,
|
value: version,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const defaultVersion = versionOptions[0]?.value;
|
const defaultVersion = versionOptions[0]?.value;
|
||||||
const initialValues: HelmInstallFormValues = {
|
const initialValues: HelmInstallFormValues = {
|
||||||
values: '',
|
values: '',
|
||||||
version: defaultVersion?.Version ?? '',
|
version: defaultVersion ?? '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId);
|
const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId);
|
||||||
|
@ -66,7 +55,6 @@ export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
|
||||||
namespace={namespace}
|
namespace={namespace}
|
||||||
name={name}
|
name={name}
|
||||||
versionOptions={versionOptions}
|
versionOptions={versionOptions}
|
||||||
isLoadingVersions={helmRepoVersionsQuery.isInitialLoading}
|
|
||||||
/>
|
/>
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,7 +6,6 @@ import { FormControl } from '@@/form-components/FormControl';
|
||||||
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
import { FormSection } from '@@/form-components/FormSection';
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
|
||||||
import { ChartVersion } from '../queries/useHelmRepositories';
|
|
||||||
import { Chart } from '../types';
|
import { Chart } from '../types';
|
||||||
import { useHelmChartValues } from '../queries/useHelmChartValues';
|
import { useHelmChartValues } from '../queries/useHelmChartValues';
|
||||||
import { HelmValuesInput } from '../components/HelmValuesInput';
|
import { HelmValuesInput } from '../components/HelmValuesInput';
|
||||||
|
@ -17,8 +16,7 @@ type Props = {
|
||||||
selectedChart: Chart;
|
selectedChart: Chart;
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
versionOptions: Option<ChartVersion>[];
|
versionOptions: Option<string>[];
|
||||||
isLoadingVersions: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HelmInstallInnerForm({
|
export function HelmInstallInnerForm({
|
||||||
|
@ -26,7 +24,6 @@ export function HelmInstallInnerForm({
|
||||||
namespace,
|
namespace,
|
||||||
name,
|
name,
|
||||||
versionOptions,
|
versionOptions,
|
||||||
isLoadingVersions,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { values, setFieldValue, isSubmitting } =
|
const { values, setFieldValue, isSubmitting } =
|
||||||
useFormikContext<HelmInstallFormValues>();
|
useFormikContext<HelmInstallFormValues>();
|
||||||
|
@ -39,7 +36,7 @@ export function HelmInstallInnerForm({
|
||||||
|
|
||||||
const selectedVersion = useMemo(
|
const selectedVersion = useMemo(
|
||||||
() =>
|
() =>
|
||||||
versionOptions.find((v) => v.value.Version === values.version)?.value ??
|
versionOptions.find((v) => v.value === values.version)?.value ??
|
||||||
versionOptions[0]?.value,
|
versionOptions[0]?.value,
|
||||||
[versionOptions, values.version]
|
[versionOptions, values.version]
|
||||||
);
|
);
|
||||||
|
@ -51,15 +48,14 @@ export function HelmInstallInnerForm({
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Version"
|
label="Version"
|
||||||
inputId="version-input"
|
inputId="version-input"
|
||||||
isLoading={isLoadingVersions}
|
|
||||||
loadingText="Loading versions..."
|
loadingText="Loading versions..."
|
||||||
>
|
>
|
||||||
<PortainerSelect<ChartVersion>
|
<PortainerSelect<string>
|
||||||
value={selectedVersion}
|
value={selectedVersion}
|
||||||
options={versionOptions}
|
options={versionOptions}
|
||||||
onChange={(version) => {
|
onChange={(version) => {
|
||||||
if (version) {
|
if (version) {
|
||||||
setFieldValue('version', version.Version);
|
setFieldValue('version', version);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
data-cy="helm-version-input"
|
data-cy="helm-version-input"
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { compact } from 'lodash';
|
||||||
|
|
||||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
import { Chart } from '../types';
|
import { Chart } from '../types';
|
||||||
import {
|
import { useHelmChartList } from '../queries/useHelmChartList';
|
||||||
useHelmChartList,
|
import { useHelmRegistries } from '../queries/useHelmRegistries';
|
||||||
useHelmRepositories,
|
|
||||||
} from '../queries/useHelmChartList';
|
|
||||||
|
|
||||||
import { HelmTemplatesList } from './HelmTemplatesList';
|
import { HelmTemplatesList } from './HelmTemplatesList';
|
||||||
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
|
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
|
||||||
|
@ -20,10 +19,11 @@ interface Props {
|
||||||
|
|
||||||
export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
|
export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
|
||||||
const [selectedChart, setSelectedChart] = useState<Chart | null>(null);
|
const [selectedChart, setSelectedChart] = useState<Chart | null>(null);
|
||||||
|
const [selectedRegistry, setSelectedRegistry] = useState<string | null>(null);
|
||||||
|
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const helmReposQuery = useHelmRepositories(user.Id);
|
const helmReposQuery = useHelmRegistries();
|
||||||
const chartListQuery = useHelmChartList(user.Id, helmReposQuery.data ?? []);
|
const chartListQuery = useHelmChartList(user.Id, compact([selectedRegistry]));
|
||||||
function clearHelmChart() {
|
function clearHelmChart() {
|
||||||
setSelectedChart(null);
|
setSelectedChart(null);
|
||||||
onSelectHelmChart('');
|
onSelectHelmChart('');
|
||||||
|
@ -54,6 +54,9 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
|
||||||
charts={chartListQuery.data}
|
charts={chartListQuery.data}
|
||||||
selectAction={handleChartSelection}
|
selectAction={handleChartSelection}
|
||||||
isLoading={chartListQuery.isInitialLoading}
|
isLoading={chartListQuery.isInitialLoading}
|
||||||
|
registries={helmReposQuery.data ?? []}
|
||||||
|
selectedRegistry={selectedRegistry}
|
||||||
|
setSelectedRegistry={setSelectedRegistry}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -19,6 +19,8 @@ const mockCharts: Chart[] = [
|
||||||
annotations: {
|
annotations: {
|
||||||
category: 'database',
|
category: 'database',
|
||||||
},
|
},
|
||||||
|
version: '1.0.0',
|
||||||
|
versions: ['1.0.0', '1.0.1'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'test-chart-2',
|
name: 'test-chart-2',
|
||||||
|
@ -27,14 +29,18 @@ const mockCharts: Chart[] = [
|
||||||
annotations: {
|
annotations: {
|
||||||
category: 'database',
|
category: 'database',
|
||||||
},
|
},
|
||||||
|
version: '1.0.0',
|
||||||
|
versions: ['1.0.0', '1.0.1'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'nginx-chart',
|
name: 'nginx-chart',
|
||||||
description: 'Nginx Web Server',
|
description: 'Nginx Web Server',
|
||||||
repo: 'https://example.com',
|
repo: 'https://example.com/2',
|
||||||
annotations: {
|
annotations: {
|
||||||
category: 'web',
|
category: 'web',
|
||||||
},
|
},
|
||||||
|
version: '1.0.0',
|
||||||
|
versions: ['1.0.0', '1.0.1'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -44,8 +50,11 @@ function renderComponent({
|
||||||
loading = false,
|
loading = false,
|
||||||
charts = mockCharts,
|
charts = mockCharts,
|
||||||
selectAction = selectActionMock,
|
selectAction = selectActionMock,
|
||||||
|
selectedRegistry = '',
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const user = new UserViewModel({ Username: 'user' });
|
const user = new UserViewModel({ Username: 'user' });
|
||||||
|
const registries = ['https://example.com', 'https://example.com/2'];
|
||||||
|
|
||||||
const Wrapped = withTestQueryProvider(
|
const Wrapped = withTestQueryProvider(
|
||||||
withUserProvider(
|
withUserProvider(
|
||||||
withTestRouter(() => (
|
withTestRouter(() => (
|
||||||
|
@ -53,6 +62,9 @@ function renderComponent({
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
charts={charts}
|
charts={charts}
|
||||||
selectAction={selectAction}
|
selectAction={selectAction}
|
||||||
|
registries={registries}
|
||||||
|
selectedRegistry={selectedRegistry}
|
||||||
|
setSelectedRegistry={() => {}}
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
user
|
user
|
||||||
|
@ -77,6 +89,7 @@ describe('HelmTemplatesList', () => {
|
||||||
expect(screen.getByText('Test Chart 1 Description')).toBeInTheDocument();
|
expect(screen.getByText('Test Chart 1 Description')).toBeInTheDocument();
|
||||||
expect(screen.getByText('nginx-chart')).toBeInTheDocument();
|
expect(screen.getByText('nginx-chart')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Nginx Web Server')).toBeInTheDocument();
|
expect(screen.getByText('Nginx Web Server')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('https://example.com/2')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call selectAction when a chart is clicked', async () => {
|
it('should call selectAction when a chart is clicked', async () => {
|
||||||
|
@ -146,11 +159,24 @@ describe('HelmTemplatesList', () => {
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show empty message when no charts are available', async () => {
|
it('should show empty message when no charts are available and a registry is selected', async () => {
|
||||||
renderComponent({ charts: [] });
|
renderComponent({ charts: [], selectedRegistry: 'https://example.com' });
|
||||||
|
|
||||||
// Check for empty message
|
// Check for empty message
|
||||||
expect(screen.getByText('No helm charts available.')).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText('No helm charts available in this registry.')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show 'select registry' message when no charts are available and no registry is selected", async () => {
|
||||||
|
renderComponent({ charts: [] });
|
||||||
|
|
||||||
|
// Check for message
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Please select a registry to view available Helm charts.'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show no results message when search has no matches', async () => {
|
it('should show no results message when search has no matches', async () => {
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { components, OptionProps } from 'react-select';
|
||||||
|
|
||||||
import { PortainerSelect } from '@/react/components/form-components/PortainerSelect';
|
import {
|
||||||
|
PortainerSelect,
|
||||||
|
Option,
|
||||||
|
} from '@/react/components/form-components/PortainerSelect';
|
||||||
import { Link } from '@/react/components/Link';
|
import { Link } from '@/react/components/Link';
|
||||||
|
|
||||||
import { InsightsBox } from '@@/InsightsBox';
|
import { InsightsBox } from '@@/InsightsBox';
|
||||||
|
@ -15,70 +19,31 @@ interface Props {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
charts?: Chart[];
|
charts?: Chart[];
|
||||||
selectAction: (chart: Chart) => void;
|
selectAction: (chart: Chart) => void;
|
||||||
}
|
registries: string[];
|
||||||
|
selectedRegistry: string | null;
|
||||||
/**
|
setSelectedRegistry: (registry: string | null) => void;
|
||||||
* Get categories from charts
|
|
||||||
* @param charts - The charts to get the categories from
|
|
||||||
* @returns Categories
|
|
||||||
*/
|
|
||||||
function getCategories(charts: Chart[]) {
|
|
||||||
const annotationCategories = charts
|
|
||||||
.map((chart) => chart.annotations?.category) // get category
|
|
||||||
.filter((c): c is string => !!c); // filter out nulls/undefined
|
|
||||||
|
|
||||||
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
|
|
||||||
|
|
||||||
// Create options array in the format expected by PortainerSelect
|
|
||||||
return availableCategories.map((cat) => ({
|
|
||||||
label: cat,
|
|
||||||
value: cat,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get filtered charts
|
|
||||||
* @param charts - The charts to get the filtered charts from
|
|
||||||
* @param textFilter - The text filter
|
|
||||||
* @param selectedCategory - The selected category
|
|
||||||
* @returns Filtered charts
|
|
||||||
*/
|
|
||||||
function getFilteredCharts(
|
|
||||||
charts: Chart[],
|
|
||||||
textFilter: string,
|
|
||||||
selectedCategory: string | null
|
|
||||||
) {
|
|
||||||
return charts.filter((chart) => {
|
|
||||||
// Text filter
|
|
||||||
if (
|
|
||||||
textFilter &&
|
|
||||||
!chart.name.toLowerCase().includes(textFilter.toLowerCase()) &&
|
|
||||||
!chart.description.toLowerCase().includes(textFilter.toLowerCase())
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category filter
|
|
||||||
if (
|
|
||||||
selectedCategory &&
|
|
||||||
(!chart.annotations || chart.annotations.category !== selectedCategory)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HelmTemplatesList({
|
export function HelmTemplatesList({
|
||||||
isLoading,
|
isLoading,
|
||||||
charts = [],
|
charts = [],
|
||||||
selectAction,
|
selectAction,
|
||||||
|
registries,
|
||||||
|
selectedRegistry,
|
||||||
|
setSelectedRegistry,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [textFilter, setTextFilter] = useState('');
|
const [textFilter, setTextFilter] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
const categories = useMemo(() => getCategories(charts), [charts]);
|
const categories = useMemo(() => getCategories(charts), [charts]);
|
||||||
|
const registryOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
registries.map((registry) => ({
|
||||||
|
label: registry,
|
||||||
|
value: registry,
|
||||||
|
})),
|
||||||
|
[registries]
|
||||||
|
);
|
||||||
|
|
||||||
const filteredCharts = useMemo(
|
const filteredCharts = useMemo(
|
||||||
() => getFilteredCharts(charts, textFilter, selectedCategory),
|
() => getFilteredCharts(charts, textFilter, selectedCategory),
|
||||||
|
@ -87,7 +52,7 @@ export function HelmTemplatesList({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="datatable" aria-label="Helm charts">
|
<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">
|
<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="toolBarTitle vertical-center">Helm chart</div>
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
|
@ -98,12 +63,25 @@ export function HelmTemplatesList({
|
||||||
className="!mr-0 h-9"
|
className="!mr-0 h-9"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full sm:w-1/5">
|
<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">
|
||||||
<PortainerSelect
|
<PortainerSelect
|
||||||
placeholder="Select a category"
|
placeholder="Select a category"
|
||||||
value={selectedCategory}
|
value={selectedCategory}
|
||||||
options={categories}
|
options={categories}
|
||||||
onChange={(value) => setSelectedCategory(value)}
|
onChange={setSelectedCategory}
|
||||||
isClearable
|
isClearable
|
||||||
bindToBody
|
bindToBody
|
||||||
data-cy="helm-category-select"
|
data-cy="helm-category-select"
|
||||||
|
@ -173,12 +151,85 @@ export function HelmTemplatesList({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && charts.length === 0 && (
|
{!isLoading && charts.length === 0 && selectedRegistry && (
|
||||||
<div className="text-muted text-center">
|
<div className="text-muted text-center">
|
||||||
No helm charts available.
|
No helm charts available in this registry.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedRegistry && (
|
||||||
|
<div className="text-muted text-center">
|
||||||
|
Please select a registry to view available Helm charts.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
* @returns Categories
|
||||||
|
*/
|
||||||
|
function getCategories(charts: Chart[]) {
|
||||||
|
const annotationCategories = charts
|
||||||
|
.map((chart) => chart.annotations?.category) // get category
|
||||||
|
.filter((c): c is string => !!c); // filter out nulls/undefined
|
||||||
|
|
||||||
|
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
|
||||||
|
|
||||||
|
// Create options array in the format expected by PortainerSelect
|
||||||
|
return availableCategories.map((cat) => ({
|
||||||
|
label: cat,
|
||||||
|
value: cat,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filtered charts
|
||||||
|
* @param charts - The charts to get the filtered charts from
|
||||||
|
* @param textFilter - The text filter
|
||||||
|
* @param selectedCategory - The selected category
|
||||||
|
* @returns Filtered charts
|
||||||
|
*/
|
||||||
|
function getFilteredCharts(
|
||||||
|
charts: Chart[],
|
||||||
|
textFilter: string,
|
||||||
|
selectedCategory: string | null
|
||||||
|
) {
|
||||||
|
return charts.filter((chart) => {
|
||||||
|
// Text filter
|
||||||
|
if (
|
||||||
|
textFilter &&
|
||||||
|
!chart.name.toLowerCase().includes(textFilter.toLowerCase()) &&
|
||||||
|
!chart.description.toLowerCase().includes(textFilter.toLowerCase())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
if (
|
||||||
|
selectedCategory &&
|
||||||
|
(!chart.annotations || chart.annotations.category !== selectedCategory)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,8 @@ const mockChart: Chart = {
|
||||||
annotations: {
|
annotations: {
|
||||||
category: 'database',
|
category: 'database',
|
||||||
},
|
},
|
||||||
|
version: '1.0.1',
|
||||||
|
versions: ['1.0.0', '1.0.1'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearHelmChartMock = vi.fn();
|
const clearHelmChartMock = vi.fn();
|
||||||
|
|
|
@ -1,66 +1,11 @@
|
||||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
import { useQueries } from '@tanstack/react-query';
|
||||||
import { compact, flatMap } from 'lodash';
|
import { compact, flatMap } from 'lodash';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios from '@/portainer/services/axios';
|
||||||
import { withGlobalError } from '@/react-tools/react-query';
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
import { UserId } from '@/portainer/users/types';
|
|
||||||
|
|
||||||
import { Chart, HelmChartsResponse, HelmRepositoriesResponse } from '../types';
|
import { Chart, HelmChartsResponse } from '../types';
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Helm repositories for user
|
|
||||||
*/
|
|
||||||
export async function getHelmRepositories(userId: UserId) {
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get<HelmRepositoriesResponse>(
|
|
||||||
`users/${userId}/helm/repositories`
|
|
||||||
);
|
|
||||||
const repos = compact([
|
|
||||||
// compact will remove the global repository if it's empty
|
|
||||||
data.GlobalRepository.toLowerCase(),
|
|
||||||
...data.UserRepositories.map((repo) => repo.URL.toLowerCase()),
|
|
||||||
]);
|
|
||||||
return [...new Set(repos)];
|
|
||||||
} catch (err) {
|
|
||||||
throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getChartsFromRepo(repo: string): Promise<Chart[]> {
|
|
||||||
try {
|
|
||||||
// Construct the URL with required repo parameter
|
|
||||||
const response = await axios.get<HelmChartsResponse>('templates/helm', {
|
|
||||||
params: { repo },
|
|
||||||
});
|
|
||||||
|
|
||||||
return compact(
|
|
||||||
Object.values(response.data.entries).map((versions) =>
|
|
||||||
versions[0] ? { ...versions[0], repo } : null
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore errors from chart repositories as some may error but others may not
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to fetch all accessible Helm repositories for a user
|
|
||||||
*
|
|
||||||
* @param userId User ID
|
|
||||||
* @returns Query result with list of repository URLs
|
|
||||||
*/
|
|
||||||
export function useHelmRepositories(userId: number) {
|
|
||||||
return useQuery(
|
|
||||||
[userId, 'helm-repositories'],
|
|
||||||
() => getHelmRepositories(userId),
|
|
||||||
{
|
|
||||||
enabled: !!userId,
|
|
||||||
...withGlobalError('Unable to retrieve Helm repositories'),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook to fetch helm charts from the provided repositories
|
* React hook to fetch helm charts from the provided repositories
|
||||||
|
@ -103,3 +48,28 @@ export function useHelmChartList(userId: number, repositories: string[] = []) {
|
||||||
isError: chartQueries.some((q) => q.isError),
|
isError: chartQueries.some((q) => q.isError),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getChartsFromRepo(repo: string): Promise<Chart[]> {
|
||||||
|
try {
|
||||||
|
// Construct the URL with required repo parameter
|
||||||
|
const response = await axios.get<HelmChartsResponse>('templates/helm', {
|
||||||
|
params: { repo },
|
||||||
|
});
|
||||||
|
|
||||||
|
return compact(
|
||||||
|
Object.values(response.data.entries).map((versions) =>
|
||||||
|
versions[0]
|
||||||
|
? {
|
||||||
|
...versions[0],
|
||||||
|
repo,
|
||||||
|
// versions are within this response too, so we don't need a new query to fetch versions when this is used
|
||||||
|
versions: versions.map((v) => v.version),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors from chart repositories as some may error but others may not
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
43
app/react/kubernetes/helm/queries/useHelmRegistries.ts
Normal file
43
app/react/kubernetes/helm/queries/useHelmRegistries.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { compact } from 'lodash';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
|
import { HelmRegistriesResponse } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch all Helm registries for the current user
|
||||||
|
*/
|
||||||
|
export function useHelmRegistries() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
return useQuery(
|
||||||
|
['helm', 'registries'],
|
||||||
|
async () => getHelmRegistries(user.Id),
|
||||||
|
{
|
||||||
|
enabled: !!user.Id,
|
||||||
|
...withGlobalError('Unable to retrieve helm registries'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Helm registries for user
|
||||||
|
*/
|
||||||
|
async function getHelmRegistries(userId: UserId) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<HelmRegistriesResponse>(
|
||||||
|
`users/${userId}/helm/repositories`
|
||||||
|
);
|
||||||
|
const repos = compact([
|
||||||
|
// compact will remove the global repository if it's empty
|
||||||
|
data.GlobalRepository.toLowerCase(),
|
||||||
|
...data.UserRepositories.map((repo) => repo.URL.toLowerCase()),
|
||||||
|
]);
|
||||||
|
return [...new Set(repos)];
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,9 @@
|
||||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
import { useQueries } from '@tanstack/react-query';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { compact, flatMap } from 'lodash';
|
import { compact, flatMap } from 'lodash';
|
||||||
|
|
||||||
import { withGlobalError } from '@/react-tools/react-query';
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
|
||||||
|
|
||||||
import { getHelmRepositories } from './useHelmChartList';
|
|
||||||
|
|
||||||
interface HelmSearch {
|
interface HelmSearch {
|
||||||
entries: Entries;
|
entries: Entries;
|
||||||
|
@ -21,26 +18,13 @@ export interface ChartVersion {
|
||||||
Version: string;
|
Version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to fetch all Helm repositories for the current user
|
|
||||||
*/
|
|
||||||
export function useHelmRepositories() {
|
|
||||||
const { user } = useCurrentUser();
|
|
||||||
return useQuery(
|
|
||||||
['helm', 'repositories'],
|
|
||||||
async () => getHelmRepositories(user.Id),
|
|
||||||
{
|
|
||||||
enabled: !!user.Id,
|
|
||||||
...withGlobalError('Unable to retrieve helm repositories'),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook to get a list of available versions for a chart from specified repositories
|
* React hook to get a list of available versions for a chart from specified repositories
|
||||||
*
|
*
|
||||||
* @param chart The chart name to get versions for
|
* @param chart The chart name to get versions for
|
||||||
* @param repositories Array of repository URLs to search in
|
* @param repositories Array of repository URLs to search in
|
||||||
|
* @param staleTime Stale time for the query
|
||||||
|
* @param useCache Whether to use the cache for the query
|
||||||
*/
|
*/
|
||||||
export function useHelmRepoVersions(
|
export function useHelmRepoVersions(
|
||||||
chart: string,
|
chart: string,
|
|
@ -87,6 +87,8 @@ export interface HelmChartResponse {
|
||||||
annotations?: {
|
annotations?: {
|
||||||
category?: string;
|
category?: string;
|
||||||
};
|
};
|
||||||
|
version: string;
|
||||||
|
versions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HelmRepositoryResponse {
|
export interface HelmRepositoryResponse {
|
||||||
|
@ -95,7 +97,7 @@ export interface HelmRepositoryResponse {
|
||||||
URL: string;
|
URL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HelmRepositoriesResponse {
|
export interface HelmRegistriesResponse {
|
||||||
GlobalRepository: string;
|
GlobalRepository: string;
|
||||||
UserRepositories: HelmRepositoryResponse[];
|
UserRepositories: HelmRepositoryResponse[];
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue