diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.status.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.status.tsx index ddadc4332..6f3b6e492 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.status.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.status.tsx @@ -1,4 +1,4 @@ -import { CellContext } from '@tanstack/react-table'; +import { CellContext, Row } from '@tanstack/react-table'; import clsx from 'clsx'; import { @@ -6,14 +6,22 @@ import { KubernetesApplicationTypes, } from '@/kubernetes/models/application/models/appConstants'; +import { filterHOC } from '@@/datatables/Filter'; + import styles from './columns.status.module.css'; import { helper } from './columns.helper'; import { ApplicationRowData } from './types'; -export const status = helper.accessor('Status', { +export const status = helper.accessor(getStatusSummary, { header: 'Status', cell: Cell, - enableSorting: false, + meta: { + filter: filterHOC('Filter by status'), + }, + enableColumnFilter: true, + filterFn: (row: Row, _: string, filterValue: string[]) => + filterValue.length === 0 || + filterValue.includes(getStatusSummary(row.original)), }); 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'; +} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx index 0ece6ed2e..a60d48072 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx @@ -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 { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { Link } from '@@/Link'; import { SystemBadge } from '@@/Badge/SystemBadge'; +import { filterHOC } from '@@/datatables/Filter'; import { Application } from './types'; import { helper } from './columns.helper'; @@ -49,7 +50,15 @@ export const image = helper.accessor('Image', { }); export const appType = helper.accessor('ApplicationType', { - header: 'Application Type', + header: 'Application type', + meta: { + filter: filterHOC('Filter by application type'), + }, + enableColumnFilter: true, + filterFn: (row: Row, _: string, filterValue: string[]) => + filterValue.length === 0 || + (!!row.original.ApplicationType && + filterValue.includes(row.original.ApplicationType)), }); export const published = helper.accessor('Services', { diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx index 8d037d251..4d388ca60 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx @@ -9,7 +9,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { useHelmRepoVersions, ChartVersion, -} from '../../queries/useHelmRepositories'; +} from '../../queries/useHelmRepoVersions'; import { HelmRelease } from '../../types'; import { openUpgradeHelmModal } from './UpgradeHelmModal'; @@ -25,16 +25,18 @@ vi.mock('@/portainer/services/notifications', () => ({ notifySuccess: vi.fn(), })); -// Mock the useHelmRepoVersions and useHelmRepositories hooks -vi.mock('../../queries/useHelmRepositories', () => ({ - useHelmRepoVersions: vi.fn(), - useHelmRepositories: vi.fn(() => ({ +vi.mock('../../queries/useHelmRegistries', () => ({ + useHelmRegistries: vi.fn(() => ({ data: ['repo1', 'repo2'], isInitialLoading: false, isError: false, })), })); +vi.mock('../../queries/useHelmRepoVersions', () => ({ + useHelmRepoVersions: vi.fn(), +})); + // Mock the useHelmRelease hook vi.mock('../queries/useHelmRelease', () => ({ useHelmRelease: vi.fn(() => ({ diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx index 3821b18d4..2c3f05ab6 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx @@ -13,11 +13,9 @@ import { Link } from '@@/Link'; import { HelmRelease, UpdateHelmReleasePayload } from '../../types'; import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation'; -import { - useHelmRepoVersions, - useHelmRepositories, -} from '../../queries/useHelmRepositories'; +import { useHelmRepoVersions } from '../../queries/useHelmRepoVersions'; import { useHelmRelease } from '../queries/useHelmRelease'; +import { useHelmRegistries } from '../../queries/useHelmRegistries'; import { openUpgradeHelmModal } from './UpgradeHelmModal'; @@ -38,11 +36,11 @@ export function UpgradeButton({ const [useCache, setUseCache] = useState(true); const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId); - const repositoriesQuery = useHelmRepositories(); + const registriesQuery = useHelmRegistries(); const helmRepoVersionsQuery = useHelmRepoVersions( release?.chart.metadata?.name || '', 60 * 60 * 1000, // 1 hour - repositoriesQuery.data, + registriesQuery.data, useCache ); const versions = helmRepoVersionsQuery.data; @@ -50,8 +48,8 @@ export function UpgradeButton({ // Combined loading state const isLoading = - repositoriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching - const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError; + registriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching + const isError = registriesQuery.isError || helmRepoVersionsQuery.isError; const latestVersionQuery = useHelmRelease( environmentId, releaseName, diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx index e1b1ba5e2..74a60454c 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx @@ -3,7 +3,7 @@ import { ArrowUp } from 'lucide-react'; import { withReactQuery } from '@/react-tools/withReactQuery'; 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 { Button } from '@@/buttons'; diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx index a2d6613a1..80d39eb3e 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx @@ -43,7 +43,7 @@ vi.mock('../queries/useUpdateHelmReleaseMutation', () => ({ })), })); -vi.mock('../queries/useHelmRepositories', () => ({ +vi.mock('../queries/useHelmRepoVersions', () => ({ useHelmRepoVersions: vi.fn(() => ({ data: [ { Version: '1.0.0', AppVersion: '1.0.0' }, @@ -75,6 +75,8 @@ const mockChart: Chart = { annotations: { category: 'database', }, + version: '1.0.1', + versions: ['1.0.0', '1.0.1'], }; const mockRouterStateService = { diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx index ba723b1c6..669dbd46c 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx @@ -11,10 +11,6 @@ import { confirmGenericDiscard } from '@@/modals/confirm'; import { Option } from '@@/form-components/PortainerSelect'; import { Chart } from '../types'; -import { - ChartVersion, - useHelmRepoVersions, -} from '../queries/useHelmRepositories'; import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation'; import { HelmInstallInnerForm } from './HelmInstallInnerForm'; @@ -30,23 +26,16 @@ export function HelmInstallForm({ selectedChart, namespace, name }: Props) { const environmentId = useEnvironmentId(); const router = useRouter(); const analytics = useAnalytics(); - const helmRepoVersionsQuery = useHelmRepoVersions( - selectedChart.name, - 60 * 60 * 1000, // 1 hour - [selectedChart.repo], - false - ); - const versions = helmRepoVersionsQuery.data; - const versionOptions: Option[] = versions.map( + const versionOptions: Option[] = selectedChart.versions.map( (version, index) => ({ - label: index === 0 ? `${version.Version} (latest)` : version.Version, + label: index === 0 ? `${version} (latest)` : version, value: version, }) ); const defaultVersion = versionOptions[0]?.value; const initialValues: HelmInstallFormValues = { values: '', - version: defaultVersion?.Version ?? '', + version: defaultVersion ?? '', }; const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId); @@ -66,7 +55,6 @@ export function HelmInstallForm({ selectedChart, namespace, name }: Props) { namespace={namespace} name={name} versionOptions={versionOptions} - isLoadingVersions={helmRepoVersionsQuery.isInitialLoading} /> ); diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmInstallInnerForm.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmInstallInnerForm.tsx index 0f1a35488..9f85a0b48 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmInstallInnerForm.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmInstallInnerForm.tsx @@ -6,7 +6,6 @@ import { FormControl } from '@@/form-components/FormControl'; import { Option, PortainerSelect } from '@@/form-components/PortainerSelect'; import { FormSection } from '@@/form-components/FormSection'; -import { ChartVersion } from '../queries/useHelmRepositories'; import { Chart } from '../types'; import { useHelmChartValues } from '../queries/useHelmChartValues'; import { HelmValuesInput } from '../components/HelmValuesInput'; @@ -17,8 +16,7 @@ type Props = { selectedChart: Chart; namespace?: string; name?: string; - versionOptions: Option[]; - isLoadingVersions: boolean; + versionOptions: Option[]; }; export function HelmInstallInnerForm({ @@ -26,7 +24,6 @@ export function HelmInstallInnerForm({ namespace, name, versionOptions, - isLoadingVersions, }: Props) { const { values, setFieldValue, isSubmitting } = useFormikContext(); @@ -39,7 +36,7 @@ export function HelmInstallInnerForm({ const selectedVersion = useMemo( () => - versionOptions.find((v) => v.value.Version === values.version)?.value ?? + versionOptions.find((v) => v.value === values.version)?.value ?? versionOptions[0]?.value, [versionOptions, values.version] ); @@ -51,15 +48,14 @@ export function HelmInstallInnerForm({ - + value={selectedVersion} options={versionOptions} onChange={(version) => { if (version) { - setFieldValue('version', version.Version); + setFieldValue('version', version); } }} data-cy="helm-version-input" diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx index 24a5420b3..ffd122cd2 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx @@ -1,12 +1,11 @@ import { useState } from 'react'; +import { compact } from 'lodash'; import { useCurrentUser } from '@/react/hooks/useUser'; import { Chart } from '../types'; -import { - useHelmChartList, - useHelmRepositories, -} from '../queries/useHelmChartList'; +import { useHelmChartList } from '../queries/useHelmChartList'; +import { useHelmRegistries } from '../queries/useHelmRegistries'; import { HelmTemplatesList } from './HelmTemplatesList'; import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem'; @@ -20,10 +19,11 @@ interface Props { export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) { const [selectedChart, setSelectedChart] = useState(null); + const [selectedRegistry, setSelectedRegistry] = useState(null); const { user } = useCurrentUser(); - const helmReposQuery = useHelmRepositories(user.Id); - const chartListQuery = useHelmChartList(user.Id, helmReposQuery.data ?? []); + const helmReposQuery = useHelmRegistries(); + const chartListQuery = useHelmChartList(user.Id, compact([selectedRegistry])); function clearHelmChart() { setSelectedChart(null); onSelectHelmChart(''); @@ -54,6 +54,9 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) { charts={chartListQuery.data} selectAction={handleChartSelection} isLoading={chartListQuery.isInitialLoading} + registries={helmReposQuery.data ?? []} + selectedRegistry={selectedRegistry} + setSelectedRegistry={setSelectedRegistry} /> )} diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx index f02d83131..98b96e9a4 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx @@ -19,6 +19,8 @@ const mockCharts: Chart[] = [ annotations: { category: 'database', }, + version: '1.0.0', + versions: ['1.0.0', '1.0.1'], }, { name: 'test-chart-2', @@ -27,14 +29,18 @@ const mockCharts: Chart[] = [ annotations: { category: 'database', }, + version: '1.0.0', + versions: ['1.0.0', '1.0.1'], }, { name: 'nginx-chart', description: 'Nginx Web Server', - repo: 'https://example.com', + repo: 'https://example.com/2', annotations: { category: 'web', }, + version: '1.0.0', + versions: ['1.0.0', '1.0.1'], }, ]; @@ -44,8 +50,11 @@ function renderComponent({ loading = false, charts = mockCharts, selectAction = selectActionMock, + selectedRegistry = '', } = {}) { const user = new UserViewModel({ Username: 'user' }); + const registries = ['https://example.com', 'https://example.com/2']; + const Wrapped = withTestQueryProvider( withUserProvider( withTestRouter(() => ( @@ -53,6 +62,9 @@ function renderComponent({ isLoading={loading} charts={charts} selectAction={selectAction} + registries={registries} + selectedRegistry={selectedRegistry} + setSelectedRegistry={() => {}} /> )), user @@ -77,6 +89,7 @@ describe('HelmTemplatesList', () => { expect(screen.getByText('Test Chart 1 Description')).toBeInTheDocument(); expect(screen.getByText('nginx-chart')).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 () => { @@ -146,11 +159,24 @@ describe('HelmTemplatesList', () => { ).toBeInTheDocument(); }); - it('should show empty message when no charts are available', async () => { - renderComponent({ charts: [] }); + it('should show empty message when no charts are available and a registry is selected', async () => { + renderComponent({ charts: [], selectedRegistry: 'https://example.com' }); // 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 () => { diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx index c41b0acbb..1b02bed1d 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx @@ -1,6 +1,10 @@ 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 { InsightsBox } from '@@/InsightsBox'; @@ -15,70 +19,31 @@ interface Props { isLoading: boolean; charts?: Chart[]; selectAction: (chart: Chart) => 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; - }); + registries: string[]; + selectedRegistry: string | null; + setSelectedRegistry: (registry: string | null) => void; } export function HelmTemplatesList({ isLoading, charts = [], selectAction, + registries, + selectedRegistry, + setSelectedRegistry, }: Props) { const [textFilter, setTextFilter] = useState(''); const [selectedCategory, setSelectedCategory] = useState(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), @@ -87,7 +52,7 @@ export function HelmTemplatesList({ return (
-
+
Helm chart
-
+
+ +
+ +
setSelectedCategory(value)} + onChange={setSelectedCategory} isClearable bindToBody data-cy="helm-category-select" @@ -173,12 +151,85 @@ export function HelmTemplatesList({
)} - {!isLoading && charts.length === 0 && ( + {!isLoading && charts.length === 0 && selectedRegistry && (
- No helm charts available. + No helm charts available in this registry. +
+ )} + + {!selectedRegistry && ( +
+ Please select a registry to view available Helm charts.
)}
); } + +// truncate the registry text, because some registry names are urls, which are too long +function RegistryOption(props: OptionProps>) { + const { data: registry } = props; + + return ( +
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + {registry.value} + +
+ ); +} + +/** + * 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; + }); +} diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx index 1a2cbb69f..2bbe5696a 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx @@ -19,6 +19,8 @@ const mockChart: Chart = { annotations: { category: 'database', }, + version: '1.0.1', + versions: ['1.0.0', '1.0.1'], }; const clearHelmChartMock = vi.fn(); diff --git a/app/react/kubernetes/helm/queries/useHelmChartList.ts b/app/react/kubernetes/helm/queries/useHelmChartList.ts index 068588c42..5824236bc 100644 --- a/app/react/kubernetes/helm/queries/useHelmChartList.ts +++ b/app/react/kubernetes/helm/queries/useHelmChartList.ts @@ -1,66 +1,11 @@ -import { useQuery, useQueries } from '@tanstack/react-query'; +import { useQueries } from '@tanstack/react-query'; import { compact, flatMap } from 'lodash'; 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 { UserId } from '@/portainer/users/types'; -import { Chart, HelmChartsResponse, HelmRepositoriesResponse } from '../types'; - -/** - * Get Helm repositories for user - */ -export async function getHelmRepositories(userId: UserId) { - try { - const { data } = await axios.get( - `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 { - try { - // Construct the URL with required repo parameter - const response = await axios.get('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'), - } - ); -} +import { Chart, HelmChartsResponse } from '../types'; /** * 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), }; } + +async function getChartsFromRepo(repo: string): Promise { + try { + // Construct the URL with required repo parameter + const response = await axios.get('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 []; + } +} diff --git a/app/react/kubernetes/helm/queries/useHelmRegistries.ts b/app/react/kubernetes/helm/queries/useHelmRegistries.ts new file mode 100644 index 000000000..f48fb72fa --- /dev/null +++ b/app/react/kubernetes/helm/queries/useHelmRegistries.ts @@ -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( + `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'); + } +} diff --git a/app/react/kubernetes/helm/queries/useHelmRepositories.ts b/app/react/kubernetes/helm/queries/useHelmRepoVersions.ts similarity index 81% rename from app/react/kubernetes/helm/queries/useHelmRepositories.ts rename to app/react/kubernetes/helm/queries/useHelmRepoVersions.ts index 0713165c0..006b74599 100644 --- a/app/react/kubernetes/helm/queries/useHelmRepositories.ts +++ b/app/react/kubernetes/helm/queries/useHelmRepoVersions.ts @@ -1,12 +1,9 @@ -import { useQuery, useQueries } from '@tanstack/react-query'; +import { useQueries } from '@tanstack/react-query'; import { useMemo } from 'react'; import { compact, flatMap } from 'lodash'; import { withGlobalError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { useCurrentUser } from '@/react/hooks/useUser'; - -import { getHelmRepositories } from './useHelmChartList'; interface HelmSearch { entries: Entries; @@ -21,26 +18,13 @@ export interface ChartVersion { 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 * * @param chart The chart name to get versions for * @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( chart: string, diff --git a/app/react/kubernetes/helm/types.ts b/app/react/kubernetes/helm/types.ts index 5d5f27536..3094f84b2 100644 --- a/app/react/kubernetes/helm/types.ts +++ b/app/react/kubernetes/helm/types.ts @@ -87,6 +87,8 @@ export interface HelmChartResponse { annotations?: { category?: string; }; + version: string; + versions: string[]; } export interface HelmRepositoryResponse { @@ -95,7 +97,7 @@ export interface HelmRepositoryResponse { URL: string; } -export interface HelmRepositoriesResponse { +export interface HelmRegistriesResponse { GlobalRepository: string; UserRepositories: HelmRepositoryResponse[]; }