mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 13:29:41 +02:00
feat(helm): helm actions [r8s-259] (#715)
Co-authored-by: James Player <james.player@portainer.io> Co-authored-by: Cara Ryan <cara.ryan@portainer.io> Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
parent
dfa32b6755
commit
4ee349bd6b
117 changed files with 4161 additions and 696 deletions
|
@ -1,31 +1,53 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { HelmRelease } from '../../types';
|
||||
|
||||
import { RollbackButton } from './RollbackButton';
|
||||
import { UninstallButton } from './UninstallButton';
|
||||
import { UpgradeButton } from './UpgradeButton';
|
||||
|
||||
type Props = {
|
||||
environmentId: EnvironmentId;
|
||||
releaseName: string;
|
||||
namespace: string;
|
||||
latestRevision?: number;
|
||||
earlistRevision?: number;
|
||||
selectedRevision?: number;
|
||||
release?: HelmRelease;
|
||||
updateRelease: (release: HelmRelease) => void;
|
||||
};
|
||||
|
||||
export function ChartActions({
|
||||
environmentId,
|
||||
releaseName,
|
||||
namespace,
|
||||
currentRevision,
|
||||
}: {
|
||||
environmentId: EnvironmentId;
|
||||
releaseName: string;
|
||||
namespace?: string;
|
||||
currentRevision?: number;
|
||||
}) {
|
||||
const hasPreviousRevision = currentRevision && currentRevision >= 2;
|
||||
latestRevision,
|
||||
earlistRevision,
|
||||
selectedRevision,
|
||||
release,
|
||||
updateRelease,
|
||||
}: Props) {
|
||||
const showRollbackButton =
|
||||
latestRevision && earlistRevision && latestRevision > earlistRevision;
|
||||
|
||||
return (
|
||||
<div className="inline-flex gap-x-2">
|
||||
<div className="inline-flex gap-2 flex-wrap">
|
||||
<UpgradeButton
|
||||
environmentId={environmentId}
|
||||
releaseName={releaseName}
|
||||
namespace={namespace}
|
||||
release={release}
|
||||
updateRelease={updateRelease}
|
||||
/>
|
||||
<UninstallButton
|
||||
environmentId={environmentId}
|
||||
releaseName={releaseName}
|
||||
namespace={namespace}
|
||||
/>
|
||||
{hasPreviousRevision && (
|
||||
{showRollbackButton && (
|
||||
<RollbackButton
|
||||
latestRevision={currentRevision}
|
||||
latestRevision={latestRevision}
|
||||
selectedRevision={selectedRevision}
|
||||
environmentId={environmentId}
|
||||
releaseName={releaseName}
|
||||
namespace={namespace}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { vi, type Mock } from 'vitest';
|
|||
import { server } from '@/setup-tests/server';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
|
||||
|
@ -25,13 +26,18 @@ vi.mock('@/portainer/services/notifications', () => ({
|
|||
function renderButton(props = {}) {
|
||||
const defaultProps = {
|
||||
latestRevision: 3, // So we're rolling back to revision 2
|
||||
selectedRevision: 3, // This simulates the selectedRevision from URL params
|
||||
environmentId: 1,
|
||||
releaseName: 'test-release',
|
||||
namespace: 'default',
|
||||
...props,
|
||||
};
|
||||
|
||||
const Wrapped = withTestQueryProvider(RollbackButton);
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(RollbackButton, {
|
||||
route: '/?revision=3',
|
||||
})
|
||||
);
|
||||
return render(<Wrapped {...defaultProps} />);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { RotateCcw } from 'lucide-react';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
@ -12,6 +13,7 @@ import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation';
|
|||
|
||||
type Props = {
|
||||
latestRevision: number;
|
||||
selectedRevision?: number;
|
||||
environmentId: EnvironmentId;
|
||||
releaseName: string;
|
||||
namespace?: string;
|
||||
|
@ -19,13 +21,16 @@ type Props = {
|
|||
|
||||
export function RollbackButton({
|
||||
latestRevision,
|
||||
selectedRevision,
|
||||
environmentId,
|
||||
releaseName,
|
||||
namespace,
|
||||
}: Props) {
|
||||
// the selectedRevision can be a prop when selecting a revision is implemented
|
||||
const selectedRevision = latestRevision ? latestRevision - 1 : undefined;
|
||||
|
||||
// when the latest revision is selected, rollback to the previous revision
|
||||
// otherwise, rollback to the selected revision
|
||||
const rollbackRevision =
|
||||
selectedRevision === latestRevision ? latestRevision - 1 : selectedRevision;
|
||||
const router = useRouter();
|
||||
const rollbackMutation = useHelmRollbackMutation(environmentId);
|
||||
|
||||
return (
|
||||
|
@ -38,7 +43,7 @@ export function RollbackButton({
|
|||
color="default"
|
||||
size="medium"
|
||||
>
|
||||
Rollback to #{selectedRevision}
|
||||
Rollback to #{rollbackRevision}
|
||||
</LoadingButton>
|
||||
);
|
||||
|
||||
|
@ -47,7 +52,7 @@ export function RollbackButton({
|
|||
title: 'Are you sure?',
|
||||
modalType: ModalType.Warn,
|
||||
confirmButton: buildConfirmButton('Rollback'),
|
||||
message: `Rolling back will restore the application to revision #${selectedRevision}, which will cause service interruption. Do you wish to continue?`,
|
||||
message: `Rolling back will restore the application to revision #${rollbackRevision}, which could cause service interruption. Do you wish to continue?`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
|
@ -56,14 +61,20 @@ export function RollbackButton({
|
|||
rollbackMutation.mutate(
|
||||
{
|
||||
releaseName,
|
||||
params: { namespace, revision: selectedRevision },
|
||||
params: { namespace, revision: rollbackRevision },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess(
|
||||
'Success',
|
||||
`Application rolled back to revision #${selectedRevision} successfully.`
|
||||
`Application rolled back to revision #${rollbackRevision} successfully.`
|
||||
);
|
||||
// set the revision url param to undefined to refresh the page at the latest revision
|
||||
router.stateService.go('kubernetes.helm', {
|
||||
namespace,
|
||||
name: releaseName,
|
||||
revision: undefined,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -0,0 +1,182 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
|
||||
import {
|
||||
useHelmRepoVersions,
|
||||
ChartVersion,
|
||||
} from '../queries/useHelmRepositories';
|
||||
import { HelmRelease } from '../../types';
|
||||
|
||||
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||
import { UpgradeButton } from './UpgradeButton';
|
||||
|
||||
// Mock the upgrade modal function
|
||||
vi.mock('./UpgradeHelmModal', () => ({
|
||||
openUpgradeHelmModal: vi.fn(() => Promise.resolve(undefined)),
|
||||
}));
|
||||
|
||||
// Mock the notifications service
|
||||
vi.mock('@/portainer/services/notifications', () => ({
|
||||
notifySuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useHelmRepoVersions and useHelmRepositories hooks
|
||||
vi.mock('../queries/useHelmRepositories', () => ({
|
||||
useHelmRepoVersions: vi.fn(() => ({
|
||||
data: [
|
||||
{ Version: '1.0.0', Repo: 'stable' },
|
||||
{ Version: '1.1.0', Repo: 'stable' },
|
||||
],
|
||||
isInitialLoading: false,
|
||||
isError: false,
|
||||
})),
|
||||
useHelmRepositories: vi.fn(() => ({
|
||||
data: ['repo1', 'repo2'],
|
||||
isInitialLoading: false,
|
||||
isError: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderButton(props = {}) {
|
||||
const defaultProps = {
|
||||
environmentId: 1,
|
||||
releaseName: 'test-release',
|
||||
namespace: 'default',
|
||||
release: {
|
||||
name: 'test-release',
|
||||
chart: {
|
||||
metadata: {
|
||||
name: 'test-chart',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
values: {
|
||||
userSuppliedValues: '{}',
|
||||
},
|
||||
manifest: '',
|
||||
} as HelmRelease,
|
||||
updateRelease: vi.fn(),
|
||||
...props,
|
||||
};
|
||||
|
||||
const Wrapped = withTestQueryProvider(withTestRouter(UpgradeButton));
|
||||
return render(<Wrapped {...defaultProps} />);
|
||||
}
|
||||
|
||||
describe('UpgradeButton', () => {
|
||||
test('should display the upgrade button', () => {
|
||||
renderButton();
|
||||
|
||||
const button = screen.getByRole('button', { name: /Upgrade/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should be disabled when no versions are available', () => {
|
||||
const data: ChartVersion[] = [];
|
||||
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
||||
data,
|
||||
isInitialLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderButton();
|
||||
|
||||
const button = screen.getByRole('button', { name: /Upgrade/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should show loading state when checking for versions', () => {
|
||||
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
||||
data: [],
|
||||
isInitialLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderButton();
|
||||
|
||||
expect(
|
||||
screen.getByText('Checking for new versions...')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show "No versions available" when no versions are found', () => {
|
||||
const data: ChartVersion[] = [];
|
||||
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
||||
data,
|
||||
isInitialLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderButton();
|
||||
|
||||
expect(screen.getByText('No versions available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should open upgrade modal when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockRelease = {
|
||||
name: 'test-release',
|
||||
chart: {
|
||||
metadata: {
|
||||
name: 'test-chart',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
values: {
|
||||
userSuppliedValues: '{}',
|
||||
},
|
||||
manifest: '',
|
||||
} as HelmRelease;
|
||||
|
||||
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
||||
data: [
|
||||
{ Version: '1.0.0', Repo: 'stable' },
|
||||
{ Version: '1.1.0', Repo: 'stable' },
|
||||
],
|
||||
isInitialLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderButton({ release: mockRelease });
|
||||
|
||||
const button = screen.getByRole('button', { name: /Upgrade/i });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openUpgradeHelmModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'test-release',
|
||||
chart: 'test-chart',
|
||||
namespace: 'default',
|
||||
values: '{}',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
expect.arrayContaining([
|
||||
{ Version: '1.0.0', Repo: 'stable' },
|
||||
{ Version: '1.1.0', Repo: 'stable' },
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should not execute the upgrade if modal is cancelled', async () => {
|
||||
const mockUpdateRelease = vi.fn();
|
||||
vi.mocked(openUpgradeHelmModal).mockResolvedValueOnce(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderButton({ updateRelease: mockUpdateRelease });
|
||||
|
||||
const button = screen.getByRole('button', { name: /Upgrade/i });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openUpgradeHelmModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockUpdateRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,167 @@
|
|||
import { ArrowUp } from 'lucide-react';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { semverCompare } from '@/react/common/semver-utils';
|
||||
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { HelmRelease } from '../../types';
|
||||
import {
|
||||
useUpdateHelmReleaseMutation,
|
||||
UpdateHelmReleasePayload,
|
||||
} from '../queries/useUpdateHelmReleaseMutation';
|
||||
import {
|
||||
ChartVersion,
|
||||
useHelmRepoVersions,
|
||||
useHelmRepositories,
|
||||
} from '../queries/useHelmRepositories';
|
||||
import { useHelmRelease } from '../queries/useHelmRelease';
|
||||
|
||||
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||
|
||||
export function UpgradeButton({
|
||||
environmentId,
|
||||
releaseName,
|
||||
namespace,
|
||||
release,
|
||||
updateRelease,
|
||||
}: {
|
||||
environmentId: EnvironmentId;
|
||||
releaseName: string;
|
||||
namespace: string;
|
||||
release?: HelmRelease;
|
||||
updateRelease: (release: HelmRelease) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
|
||||
|
||||
const repositoriesQuery = useHelmRepositories();
|
||||
const helmRepoVersionsQuery = useHelmRepoVersions(
|
||||
release?.chart.metadata?.name || '',
|
||||
60 * 60 * 1000, // 1 hour
|
||||
repositoriesQuery.data
|
||||
);
|
||||
const versions = helmRepoVersionsQuery.data;
|
||||
|
||||
// Combined loading state
|
||||
const isInitialLoading =
|
||||
repositoriesQuery.isInitialLoading ||
|
||||
helmRepoVersionsQuery.isInitialLoading;
|
||||
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
|
||||
|
||||
const latestVersion = useHelmRelease(environmentId, releaseName, namespace, {
|
||||
select: (data) => data.chart.metadata?.version,
|
||||
});
|
||||
const latestVersionAvailable = versions[0]?.Version ?? '';
|
||||
const isNewVersionAvailable =
|
||||
latestVersion?.data &&
|
||||
semverCompare(latestVersionAvailable, latestVersion?.data) === 1;
|
||||
|
||||
const editableHelmRelease: UpdateHelmReleasePayload = {
|
||||
name: releaseName,
|
||||
namespace: namespace || '',
|
||||
values: release?.values?.userSuppliedValues,
|
||||
chart: release?.chart.metadata?.name || '',
|
||||
version: release?.chart.metadata?.version,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<LoadingButton
|
||||
color="secondary"
|
||||
data-cy="k8sApp-upgradeHelmChartButton"
|
||||
onClick={() => openUpgradeForm(versions, release)}
|
||||
disabled={
|
||||
versions.length === 0 ||
|
||||
isInitialLoading ||
|
||||
isError ||
|
||||
release?.info?.status?.startsWith('pending')
|
||||
}
|
||||
loadingText="Upgrading..."
|
||||
isLoading={updateHelmReleaseMutation.isLoading}
|
||||
icon={ArrowUp}
|
||||
size="medium"
|
||||
>
|
||||
Upgrade
|
||||
</LoadingButton>
|
||||
{versions.length === 0 && isInitialLoading && (
|
||||
<InlineLoader
|
||||
size="xs"
|
||||
className="absolute -bottom-5 left-0 right-0 whitespace-nowrap"
|
||||
>
|
||||
Checking for new versions...
|
||||
</InlineLoader>
|
||||
)}
|
||||
{versions.length === 0 && !isInitialLoading && !isError && (
|
||||
<span className="absolute flex items-center -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap">
|
||||
No versions available
|
||||
<Tooltip
|
||||
message={
|
||||
<div>
|
||||
Portainer is unable to find any versions for this chart in the
|
||||
repositories saved. Try adding a new repository which contains
|
||||
the chart in the{' '}
|
||||
<Link
|
||||
to="portainer.account"
|
||||
params={{ '#': 'helm-repositories' }}
|
||||
data-cy="user-settings-link"
|
||||
>
|
||||
Helm repositories settings
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{isNewVersionAvailable && (
|
||||
<span className="absolute -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap">
|
||||
New version available ({latestVersionAvailable})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
async function openUpgradeForm(
|
||||
versions: ChartVersion[],
|
||||
release?: HelmRelease
|
||||
) {
|
||||
const result = await openUpgradeHelmModal(editableHelmRelease, versions);
|
||||
|
||||
if (result) {
|
||||
handleUpgrade(result, release);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpgrade(
|
||||
payload: UpdateHelmReleasePayload,
|
||||
release?: HelmRelease
|
||||
) {
|
||||
if (release?.info) {
|
||||
const updatedRelease = {
|
||||
...release,
|
||||
info: {
|
||||
...release.info,
|
||||
status: 'pending-upgrade',
|
||||
description: 'Preparing upgrade',
|
||||
},
|
||||
};
|
||||
updateRelease(updatedRelease);
|
||||
}
|
||||
updateHelmReleaseMutation.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Helm chart upgraded successfully');
|
||||
// set the revision url param to undefined to refresh the page at the latest revision
|
||||
router.stateService.go('kubernetes.helm', {
|
||||
namespace,
|
||||
name: releaseName,
|
||||
revision: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
import { useState } from 'react';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
|
||||
import { Modal, OnSubmit, openModal } from '@@/modals';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { CodeEditor } from '@@/CodeEditor';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { WidgetTitle } from '@@/Widget';
|
||||
|
||||
import { UpdateHelmReleasePayload } from '../queries/useUpdateHelmReleaseMutation';
|
||||
import { ChartVersion } from '../queries/useHelmRepositories';
|
||||
|
||||
interface Props {
|
||||
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
|
||||
values: UpdateHelmReleasePayload;
|
||||
versions: ChartVersion[];
|
||||
}
|
||||
|
||||
export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
|
||||
const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
|
||||
const isCurrentVersion = version.Version === values.version;
|
||||
const label = `${version.Repo}@${version.Version}${
|
||||
isCurrentVersion ? ' (current)' : ''
|
||||
}`;
|
||||
return {
|
||||
label,
|
||||
value: version,
|
||||
};
|
||||
});
|
||||
const defaultVersion =
|
||||
versionOptions.find((v) => v.value.Version === values.version)?.value ||
|
||||
versionOptions[0]?.value;
|
||||
const [version, setVersion] = useState<ChartVersion>(defaultVersion);
|
||||
const [userValues, setUserValues] = useState<string>(values.values || '');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onDismiss={() => onSubmit()}
|
||||
size="lg"
|
||||
className="flex flex-col h-[80vh] px-0"
|
||||
aria-label="upgrade-helm"
|
||||
>
|
||||
<Modal.Header
|
||||
title={<WidgetTitle className="px-5" title="Upgrade" icon={ArrowUp} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto px-5">
|
||||
<Modal.Body>
|
||||
<FormControl label="Version" inputId="version-input" size="vertical">
|
||||
<PortainerSelect<ChartVersion>
|
||||
value={version}
|
||||
options={versionOptions}
|
||||
onChange={(version) => {
|
||||
if (version) {
|
||||
setVersion(version);
|
||||
}
|
||||
}}
|
||||
data-cy="helm-version-input"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
label="Release name"
|
||||
inputId="release-name-input"
|
||||
size="vertical"
|
||||
>
|
||||
<Input
|
||||
id="release-name-input"
|
||||
value={values.name}
|
||||
readOnly
|
||||
disabled
|
||||
data-cy="helm-release-name-input"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
inputId="namespace-input"
|
||||
size="vertical"
|
||||
>
|
||||
<Input
|
||||
id="namespace-input"
|
||||
value={values.namespace}
|
||||
readOnly
|
||||
disabled
|
||||
data-cy="helm-namespace-input"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
label="User-defined values"
|
||||
inputId="user-values-editor"
|
||||
size="vertical"
|
||||
>
|
||||
<CodeEditor
|
||||
id="user-values-editor"
|
||||
value={userValues}
|
||||
onChange={(value) => setUserValues(value)}
|
||||
height="50vh"
|
||||
type="yaml"
|
||||
data-cy="helm-user-values-editor"
|
||||
placeholder="Define or paste the content of your values yaml file here"
|
||||
/>
|
||||
</FormControl>
|
||||
</Modal.Body>
|
||||
</div>
|
||||
<div className="px-5 border-solid border-0 border-t border-gray-5 th-dark:border-gray-7 th-highcontrast:border-white">
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
onClick={() => onSubmit()}
|
||||
color="secondary"
|
||||
key="cancel-button"
|
||||
size="medium"
|
||||
data-cy="cancel-button-cy"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
name: values.name,
|
||||
values: userValues,
|
||||
namespace: values.namespace,
|
||||
chart: values.chart,
|
||||
repo: version.Repo,
|
||||
version: version.Version,
|
||||
})
|
||||
}
|
||||
color="primary"
|
||||
key="update-button"
|
||||
size="medium"
|
||||
data-cy="update-button-cy"
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export async function openUpgradeHelmModal(
|
||||
values: UpdateHelmReleasePayload,
|
||||
versions: ChartVersion[]
|
||||
) {
|
||||
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
|
||||
values,
|
||||
versions,
|
||||
});
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { HttpResponse } from 'msw';
|
||||
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
|
@ -7,12 +7,14 @@ import { withTestRouter } from '@/react/test-utils/withRouter';
|
|||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
|
||||
import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
|
||||
|
||||
import { HelmApplicationView } from './HelmApplicationView';
|
||||
|
||||
// Mock the necessary hooks and dependencies
|
||||
const mockUseCurrentStateAndParams = vi.fn();
|
||||
const mockUseEnvironmentId = vi.fn();
|
||||
mockLocalizeDate();
|
||||
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
|
@ -32,32 +34,109 @@ const minimalHelmRelease = {
|
|||
chart: {
|
||||
metadata: {
|
||||
name: 'test-chart',
|
||||
// appVersion: '1.0.0', // can be missing for a minimal release
|
||||
version: '2.2.2',
|
||||
},
|
||||
},
|
||||
info: {
|
||||
status: 'deployed',
|
||||
last_deployed: '2021-01-01T00:00:00Z',
|
||||
// notes: 'This is a test note', // can be missing for a minimal release
|
||||
},
|
||||
manifest: 'This is a test manifest',
|
||||
};
|
||||
|
||||
const helmReleaseWithAdditionalDetails = {
|
||||
...minimalHelmRelease,
|
||||
info: {
|
||||
...minimalHelmRelease.info,
|
||||
notes: 'This is a test note',
|
||||
},
|
||||
// Create a more complete helm release object for testing
|
||||
const completeHelmRelease = {
|
||||
name: 'test-release',
|
||||
version: '1',
|
||||
namespace: 'default',
|
||||
chart: {
|
||||
...minimalHelmRelease.chart,
|
||||
metadata: {
|
||||
...minimalHelmRelease.chart.metadata,
|
||||
name: 'test-chart',
|
||||
appVersion: '1.0.0',
|
||||
version: '2.2.2',
|
||||
},
|
||||
},
|
||||
info: {
|
||||
status: 'deployed',
|
||||
notes: 'This is a test note',
|
||||
resources: [
|
||||
{
|
||||
kind: 'Deployment',
|
||||
name: 'test-deployment',
|
||||
namespace: 'default',
|
||||
uid: 'test-deployment-uid',
|
||||
status: {
|
||||
healthSummary: {
|
||||
status: 'Healthy',
|
||||
reason: 'Running',
|
||||
message: 'All replicas are ready',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
name: 'test-deployment',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Service',
|
||||
name: 'test-service',
|
||||
namespace: 'default',
|
||||
uid: 'test-service-uid',
|
||||
status: {
|
||||
healthSummary: {
|
||||
status: 'Healthy',
|
||||
reason: 'Available',
|
||||
message: 'Service is available',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
name: 'test-service',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: 'This is a test manifest',
|
||||
values: {
|
||||
// Add some values to ensure the Values tab is present
|
||||
replicaCount: 1,
|
||||
image: {
|
||||
repository: 'nginx',
|
||||
tag: 'latest',
|
||||
},
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
kind: 'Deployment',
|
||||
name: 'test-deployment',
|
||||
namespace: 'default',
|
||||
status: {
|
||||
healthSummary: {
|
||||
status: 'Healthy',
|
||||
reason: 'Running',
|
||||
message: 'All replicas are ready',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
name: 'test-deployment',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const helmReleaseHistory = [
|
||||
{
|
||||
version: 1,
|
||||
updated: '2023-06-01T12:00:00Z',
|
||||
status: 'deployed',
|
||||
chart: 'test-chart-1.0.0',
|
||||
app_version: '1.0.0',
|
||||
description: 'Install complete',
|
||||
},
|
||||
];
|
||||
|
||||
function renderComponent() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
const Wrapped = withTestQueryProvider(
|
||||
|
@ -66,92 +145,171 @@ function renderComponent() {
|
|||
return render(<Wrapped />);
|
||||
}
|
||||
|
||||
describe('HelmApplicationView', () => {
|
||||
beforeEach(() => {
|
||||
// Set up default mock values
|
||||
mockUseEnvironmentId.mockReturnValue(3);
|
||||
mockUseCurrentStateAndParams.mockReturnValue({
|
||||
params: {
|
||||
name: 'test-release',
|
||||
namespace: 'default',
|
||||
},
|
||||
describe(
|
||||
'HelmApplicationView',
|
||||
() => {
|
||||
beforeEach(() => {
|
||||
// Set up default mock values
|
||||
mockUseEnvironmentId.mockReturnValue(3);
|
||||
mockUseCurrentStateAndParams.mockReturnValue({
|
||||
params: {
|
||||
name: 'test-release',
|
||||
namespace: 'default',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display helm release details for minimal release when data is loaded', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
it('should display helm release details for minimal release when data is loaded', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
HttpResponse.json(minimalHelmRelease)
|
||||
)
|
||||
);
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
HttpResponse.json(minimalHelmRelease)
|
||||
),
|
||||
http.get('/api/users/undefined/helm/repositories', () =>
|
||||
HttpResponse.json({
|
||||
GlobalRepository: 'https://charts.helm.sh/stable',
|
||||
UserRepositories: [
|
||||
{ Id: '1', URL: 'https://charts.helm.sh/stable' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.get('/api/templates/helm', () =>
|
||||
HttpResponse.json({
|
||||
entries: {
|
||||
'test-chart': [{ version: '1.0.0' }],
|
||||
},
|
||||
})
|
||||
),
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
|
||||
HttpResponse.json(helmReleaseHistory)
|
||||
),
|
||||
http.get(
|
||||
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
|
||||
() =>
|
||||
HttpResponse.json({
|
||||
kind: 'EventList',
|
||||
apiVersion: 'v1',
|
||||
metadata: { resourceVersion: '12345' },
|
||||
items: [],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const { findByText, findAllByText } = renderComponent();
|
||||
const { findByText, findAllByText } = renderComponent();
|
||||
|
||||
// Check for the page header
|
||||
expect(await findByText('Helm details')).toBeInTheDocument();
|
||||
// Check for the page header
|
||||
expect(await findByText('Helm details')).toBeInTheDocument();
|
||||
|
||||
// Check for the badge content
|
||||
expect(await findByText(/Namespace/)).toBeInTheDocument();
|
||||
expect(await findByText(/Chart version:/)).toBeInTheDocument();
|
||||
expect(await findByText(/Chart:/)).toBeInTheDocument();
|
||||
expect(await findByText(/Revision/)).toBeInTheDocument();
|
||||
// Check for the badge content
|
||||
expect(await findByText(/Namespace: default/)).toBeInTheDocument();
|
||||
expect(
|
||||
await findByText(/Chart version: test-chart-2.2.2/)
|
||||
).toBeInTheDocument();
|
||||
expect(await findByText(/Chart: test-chart/)).toBeInTheDocument();
|
||||
expect(await findByText(/Revision: #1/)).toBeInTheDocument();
|
||||
expect(
|
||||
await findByText(/Last deployed: Jan 1, 2021, 12:00 AM/)
|
||||
).toBeInTheDocument();
|
||||
// Check for the actual values
|
||||
expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
|
||||
expect(await findAllByText(/test-chart/)).toHaveLength(2); // title and badge (not checking revision list item)
|
||||
|
||||
// Check for the actual values
|
||||
expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
|
||||
expect(await findAllByText(/test-chart/)).toHaveLength(2);
|
||||
// There shouldn't be a notes tab when there are no notes
|
||||
expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
|
||||
|
||||
// There shouldn't be a notes tab when there are no notes
|
||||
expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
|
||||
// There shouldn't be an app version badge when it's missing
|
||||
expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
|
||||
|
||||
// There shouldn't be an app version badge when it's missing
|
||||
expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
|
||||
// Ensure there are no console errors
|
||||
// eslint-disable-next-line no-console
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
|
||||
// Ensure there are no console errors
|
||||
// eslint-disable-next-line no-console
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
// Restore console.error
|
||||
vi.spyOn(console, 'error').mockRestore();
|
||||
});
|
||||
|
||||
// Restore console.error
|
||||
vi.spyOn(console, 'error').mockRestore();
|
||||
});
|
||||
it('should display error message when API request fails', async () => {
|
||||
// Mock API failure
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
HttpResponse.error()
|
||||
),
|
||||
// Add mock for events endpoint
|
||||
http.get(
|
||||
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
|
||||
() =>
|
||||
HttpResponse.json({
|
||||
kind: 'EventList',
|
||||
apiVersion: 'v1',
|
||||
metadata: { resourceVersion: '12345' },
|
||||
items: [],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
it('should display error message when API request fails', async () => {
|
||||
// Mock API failure
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
HttpResponse.error()
|
||||
)
|
||||
);
|
||||
// Mock console.error to prevent test output pollution
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock console.error to prevent test output pollution
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
renderComponent();
|
||||
|
||||
renderComponent();
|
||||
// Wait for the error message to appear
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Failed to load Helm application details',
|
||||
{},
|
||||
{ timeout: 6500 }
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Wait for the error message to appear
|
||||
expect(
|
||||
await screen.findByText('Failed to load Helm application details')
|
||||
).toBeInTheDocument();
|
||||
// Restore console.error
|
||||
vi.spyOn(console, 'error').mockRestore();
|
||||
});
|
||||
|
||||
// Restore console.error
|
||||
vi.spyOn(console, 'error').mockRestore();
|
||||
});
|
||||
it('should display additional details when available in helm release', async () => {
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
HttpResponse.json(completeHelmRelease)
|
||||
),
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
|
||||
HttpResponse.json(helmReleaseHistory)
|
||||
),
|
||||
http.get(
|
||||
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
|
||||
() =>
|
||||
HttpResponse.json({
|
||||
kind: 'EventList',
|
||||
apiVersion: 'v1',
|
||||
metadata: { resourceVersion: '12345' },
|
||||
items: [],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
it('should display additional details when available in helm release', async () => {
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
HttpResponse.json(helmReleaseWithAdditionalDetails)
|
||||
)
|
||||
);
|
||||
const { findByText } = renderComponent();
|
||||
|
||||
const { findByText } = renderComponent();
|
||||
expect(await findByText('Helm details')).toBeInTheDocument();
|
||||
|
||||
// Check for the notes tab when notes are available
|
||||
expect(await findByText(/Notes/)).toBeInTheDocument();
|
||||
// Check for the app version badge when it's available
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/App version/, { exact: false })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check for the app version badge when it's available
|
||||
expect(await findByText(/App version/)).toBeInTheDocument();
|
||||
expect(await findByText('1.0.0', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Look for specific tab text
|
||||
expect(screen.getByText('Resources')).toBeInTheDocument();
|
||||
expect(screen.getByText('Values')).toBeInTheDocument();
|
||||
expect(screen.getByText('Manifest')).toBeInTheDocument();
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Events')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(await findByText(/App version: 1.0.0/)).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{
|
||||
timeout: 7000,
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import helm from '@/assets/ico/vendor/helm.svg?c';
|
||||
import { PageHeader } from '@/react/components/PageHeader';
|
||||
|
@ -15,14 +16,25 @@ import { HelmSummary } from './HelmSummary';
|
|||
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
|
||||
import { useHelmRelease } from './queries/useHelmRelease';
|
||||
import { ChartActions } from './ChartActions/ChartActions';
|
||||
import { HelmRevisionList } from './HelmRevisionList';
|
||||
import { HelmRevisionListSheet } from './HelmRevisionListSheet';
|
||||
import { useHelmHistory } from './queries/useHelmHistory';
|
||||
|
||||
export function HelmApplicationView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const queryClient = useQueryClient();
|
||||
const { params } = useCurrentStateAndParams();
|
||||
const { name, namespace } = params;
|
||||
const { name, namespace, revision } = params;
|
||||
const helmHistoryQuery = useHelmHistory(environmentId, name, namespace);
|
||||
const latestRevision = helmHistoryQuery.data?.[0]?.version;
|
||||
const earlistRevision =
|
||||
helmHistoryQuery.data?.[helmHistoryQuery.data.length - 1]?.version;
|
||||
// when loading the page fresh, the revision is undefined, so use the latest revision
|
||||
const selectedRevision = revision ? parseInt(revision, 10) : latestRevision;
|
||||
|
||||
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
|
||||
showResources: true,
|
||||
revision: selectedRevision,
|
||||
});
|
||||
|
||||
return (
|
||||
|
@ -38,26 +50,61 @@ export function HelmApplicationView() {
|
|||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
{name && (
|
||||
<WidgetTitle icon={helm} title={name}>
|
||||
<Authorized authorizations="K8sApplicationsW">
|
||||
<ChartActions
|
||||
environmentId={environmentId}
|
||||
releaseName={name}
|
||||
namespace={namespace}
|
||||
currentRevision={helmReleaseQuery.data?.version}
|
||||
<Widget className="overflow-hidden">
|
||||
<div className="flex">
|
||||
<div className="flex-1 min-w-0">
|
||||
{name && (
|
||||
<WidgetTitle icon={helm} title={name}>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="2xl:hidden">
|
||||
<HelmRevisionListSheet
|
||||
currentRevision={helmReleaseQuery.data?.version}
|
||||
history={helmHistoryQuery.data}
|
||||
/>
|
||||
</div>
|
||||
<Authorized authorizations="K8sApplicationsW">
|
||||
<ChartActions
|
||||
environmentId={environmentId}
|
||||
releaseName={String(name)}
|
||||
namespace={String(namespace)}
|
||||
latestRevision={latestRevision ?? 1}
|
||||
earlistRevision={earlistRevision}
|
||||
selectedRevision={selectedRevision}
|
||||
release={helmReleaseQuery.data}
|
||||
updateRelease={(updatedRelease: HelmRelease) => {
|
||||
queryClient.setQueryData(
|
||||
[
|
||||
environmentId,
|
||||
'helm',
|
||||
'releases',
|
||||
namespace,
|
||||
name,
|
||||
true,
|
||||
],
|
||||
updatedRelease
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Authorized>
|
||||
</div>
|
||||
</WidgetTitle>
|
||||
)}
|
||||
<WidgetBody className="!pt-2.5">
|
||||
<HelmDetails
|
||||
isLoading={helmReleaseQuery.isInitialLoading}
|
||||
isError={helmReleaseQuery.isError}
|
||||
release={helmReleaseQuery.data}
|
||||
selectedRevision={selectedRevision}
|
||||
/>
|
||||
</Authorized>
|
||||
</WidgetTitle>
|
||||
)}
|
||||
<WidgetBody>
|
||||
<HelmDetails
|
||||
isLoading={helmReleaseQuery.isInitialLoading}
|
||||
isError={helmReleaseQuery.isError}
|
||||
release={helmReleaseQuery.data}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</WidgetBody>
|
||||
</div>
|
||||
<div className="w-80 hidden 2xl:!block">
|
||||
<HelmRevisionList
|
||||
currentRevision={helmReleaseQuery.data?.version}
|
||||
history={helmHistoryQuery.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -68,10 +115,16 @@ export function HelmApplicationView() {
|
|||
type HelmDetailsProps = {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
release: HelmRelease | undefined;
|
||||
selectedRevision?: number;
|
||||
release?: HelmRelease;
|
||||
};
|
||||
|
||||
function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
|
||||
function HelmDetails({
|
||||
isLoading,
|
||||
isError,
|
||||
release,
|
||||
selectedRevision,
|
||||
}: HelmDetailsProps) {
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
@ -82,16 +135,16 @@ function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
|
|||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
if (!release || !selectedRevision) {
|
||||
return <Alert color="error" title="No Helm application details found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<HelmSummary release={data} />
|
||||
<HelmSummary release={release} />
|
||||
<div className="my-6 h-[1px] w-full bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-white" />
|
||||
<Card className="bg-inherit">
|
||||
<ReleaseTabs release={data} />
|
||||
<ReleaseTabs release={release} selectedRevision={selectedRevision} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
|
||||
|
||||
import { HelmRelease } from '../types';
|
||||
|
||||
import { HelmRevisionItem } from './HelmRevisionItem';
|
||||
|
||||
const mockHelmRelease: HelmRelease = {
|
||||
name: 'my-release',
|
||||
version: 1,
|
||||
info: {
|
||||
status: 'deployed',
|
||||
last_deployed: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
chart: {
|
||||
metadata: {
|
||||
name: 'my-app',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
manifest: 'apiVersion: v1\nkind: Service\nmetadata:\n name: my-service',
|
||||
};
|
||||
|
||||
mockLocalizeDate();
|
||||
|
||||
vi.mock('@uirouter/react', () => ({
|
||||
useCurrentStateAndParams: () => ({
|
||||
params: {
|
||||
namespace: 'default',
|
||||
name: 'my-release',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseCurrentStateAndParams = vi.fn();
|
||||
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
useCurrentStateAndParams: () => mockUseCurrentStateAndParams(),
|
||||
}));
|
||||
|
||||
function getTestComponent() {
|
||||
return withTestRouter(HelmRevisionItem);
|
||||
}
|
||||
|
||||
describe('HelmRevisionItem', () => {
|
||||
it('should display correct revision details', () => {
|
||||
const TestComponent = getTestComponent();
|
||||
render(
|
||||
<TestComponent
|
||||
item={mockHelmRelease}
|
||||
namespace="default"
|
||||
name="my-release"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check status badge
|
||||
expect(screen.getByText('Deployed')).toBeInTheDocument();
|
||||
|
||||
// Check revision number
|
||||
expect(screen.getByText('Revision #1')).toBeInTheDocument();
|
||||
|
||||
// Check chart name and version
|
||||
expect(screen.getByText('my-app-1.0.0')).toBeInTheDocument();
|
||||
|
||||
// Check deployment date
|
||||
expect(screen.getByText('Jan 1, 2024, 12:00 AM')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have selected class when currentRevision matches item version', () => {
|
||||
const TestComponent = getTestComponent();
|
||||
const { container } = render(
|
||||
<TestComponent
|
||||
item={mockHelmRelease}
|
||||
currentRevision={1}
|
||||
namespace="default"
|
||||
name="my-release"
|
||||
/>
|
||||
);
|
||||
|
||||
const blocklistItem = container.querySelector('.blocklist-item');
|
||||
expect(blocklistItem).toHaveClass('blocklist-item--selected');
|
||||
});
|
||||
|
||||
it('should not have selected class when currentRevision does not match item version', () => {
|
||||
const TestComponent = getTestComponent();
|
||||
const { container } = render(
|
||||
<TestComponent
|
||||
item={mockHelmRelease}
|
||||
currentRevision={2}
|
||||
namespace="default"
|
||||
name="my-release"
|
||||
/>
|
||||
);
|
||||
|
||||
const blocklistItem = container.querySelector('.blocklist-item');
|
||||
expect(blocklistItem).not.toHaveClass('blocklist-item--selected');
|
||||
});
|
||||
|
||||
it('should not have selected class when currentRevision is undefined', () => {
|
||||
const TestComponent = getTestComponent();
|
||||
const { container } = render(
|
||||
<TestComponent
|
||||
item={mockHelmRelease}
|
||||
namespace="default"
|
||||
name="my-release"
|
||||
/>
|
||||
);
|
||||
|
||||
const blocklistItem = container.querySelector('.blocklist-item');
|
||||
expect(blocklistItem).not.toHaveClass('blocklist-item--selected');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,49 @@
|
|||
import { localizeDate } from '@/react/common/date-utils';
|
||||
|
||||
import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
|
||||
import { Link } from '@@/Link';
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import { HelmRelease } from '../types';
|
||||
import { getStatusColor, getStatusText } from '../helm-status-utils';
|
||||
|
||||
export function HelmRevisionItem({
|
||||
item,
|
||||
currentRevision,
|
||||
namespace,
|
||||
name,
|
||||
}: {
|
||||
item: HelmRelease;
|
||||
currentRevision?: number;
|
||||
namespace: string;
|
||||
name: string;
|
||||
}) {
|
||||
return (
|
||||
<BlocklistItem
|
||||
data-cy="helm-history-item"
|
||||
isSelected={item.version === currentRevision}
|
||||
as={Link}
|
||||
to="kubernetes.helm"
|
||||
params={{ namespace, name, revision: item.version }}
|
||||
>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex flex-wrap gap-1 justify-between">
|
||||
<Badge type={getStatusColor(item.info?.status)}>
|
||||
{getStatusText(item.info?.status)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted">Revision #{item.version}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 justify-between">
|
||||
<span className="text-xs text-muted">
|
||||
{item.chart.metadata?.name}-{item.chart.metadata?.version}
|
||||
</span>
|
||||
{item.info?.last_deployed && (
|
||||
<span className="text-xs text-muted">
|
||||
{localizeDate(new Date(item.info.last_deployed))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BlocklistItem>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { History } from 'lucide-react';
|
||||
|
||||
import { WidgetIcon } from '@@/Widget/WidgetIcon';
|
||||
|
||||
import { HelmRelease } from '../types';
|
||||
|
||||
import { HelmRevisionItem } from './HelmRevisionItem';
|
||||
|
||||
export function HelmRevisionList({
|
||||
currentRevision,
|
||||
history,
|
||||
}: {
|
||||
currentRevision?: number;
|
||||
history: HelmRelease[] | undefined;
|
||||
}) {
|
||||
const { params } = useCurrentStateAndParams();
|
||||
const { name, namespace } = params;
|
||||
|
||||
if (!history) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-0 min-h-full overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<div className="p-5 pb-2.5">
|
||||
<span className="vertical-center mb-5">
|
||||
<WidgetIcon icon={History} />
|
||||
<h2 className="text-base m-0 ml-1">Revisions</h2>
|
||||
</span>
|
||||
{history?.map((historyItem) => (
|
||||
<HelmRevisionItem
|
||||
key={historyItem.version}
|
||||
item={historyItem}
|
||||
namespace={namespace}
|
||||
name={name}
|
||||
currentRevision={currentRevision}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { Eye } from 'lucide-react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTrigger,
|
||||
} from '@@/Sheet';
|
||||
|
||||
import { HelmRelease } from '../types';
|
||||
|
||||
import { HelmRevisionList } from './HelmRevisionList';
|
||||
|
||||
export function HelmRevisionListSheet({
|
||||
currentRevision,
|
||||
history,
|
||||
}: {
|
||||
currentRevision: number | undefined;
|
||||
history: HelmRelease[] | undefined;
|
||||
}) {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger className="btn btn-link">
|
||||
<Icon icon={Eye} />
|
||||
View revisions
|
||||
</SheetTrigger>
|
||||
<SheetContent className="!w-80 !p-0 !pt-1 overflow-auto">
|
||||
<div className="sr-only">
|
||||
<SheetHeader title="Revisions" />
|
||||
<SheetDescription>
|
||||
View the history of this Helm application.
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<HelmRevisionList currentRevision={currentRevision} history={history} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
|
@ -1,24 +1,19 @@
|
|||
import { Badge } from '@/react/components/Badge';
|
||||
import { localizeDate } from '@/react/common/date-utils';
|
||||
|
||||
import { Alert } from '@@/Alert';
|
||||
|
||||
import { HelmRelease } from '../types';
|
||||
import {
|
||||
DeploymentStatus,
|
||||
getStatusColor,
|
||||
getStatusText,
|
||||
} from '../helm-status-utils';
|
||||
|
||||
interface Props {
|
||||
release: HelmRelease;
|
||||
}
|
||||
|
||||
export enum DeploymentStatus {
|
||||
DEPLOYED = 'deployed',
|
||||
FAILED = 'failed',
|
||||
PENDING = 'pending-install',
|
||||
PENDINGUPGRADE = 'pending-upgrade',
|
||||
PENDINGROLLBACK = 'pending-rollback',
|
||||
SUPERSEDED = 'superseded',
|
||||
UNINSTALLED = 'uninstalled',
|
||||
UNINSTALLING = 'uninstalling',
|
||||
}
|
||||
|
||||
export function HelmSummary({ release }: Props) {
|
||||
const isSuccess =
|
||||
release.info?.status === DeploymentStatus.DEPLOYED ||
|
||||
|
@ -29,9 +24,14 @@ export function HelmSummary({ release }: Props) {
|
|||
<div className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<Badge type={getStatusColor(release.info?.status)}>
|
||||
{getText(release.info?.status)}
|
||||
{getStatusText(release.info?.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
{!!release.info?.description && !isSuccess && (
|
||||
<Alert color={getAlertColor(release.info?.status)}>
|
||||
{release.info?.description}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!!release.namespace && <Badge>Namespace: {release.namespace}</Badge>}
|
||||
{!!release.version && <Badge>Revision: #{release.version}</Badge>}
|
||||
|
@ -47,12 +47,13 @@ export function HelmSummary({ release }: Props) {
|
|||
{release.chart.metadata.version}
|
||||
</Badge>
|
||||
)}
|
||||
{!!release.info?.last_deployed && (
|
||||
<Badge>
|
||||
Last deployed:{' '}
|
||||
{localizeDate(new Date(release.info.last_deployed))}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!!release.info?.description && !isSuccess && (
|
||||
<Alert color={getAlertColor(release.info?.status)}>
|
||||
{release.info?.description}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -74,38 +75,3 @@ function getAlertColor(status?: string) {
|
|||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status?: string) {
|
||||
switch (status?.toLowerCase()) {
|
||||
case DeploymentStatus.DEPLOYED:
|
||||
return 'success';
|
||||
case DeploymentStatus.FAILED:
|
||||
return 'danger';
|
||||
case DeploymentStatus.PENDING:
|
||||
case DeploymentStatus.PENDINGUPGRADE:
|
||||
case DeploymentStatus.PENDINGROLLBACK:
|
||||
case DeploymentStatus.UNINSTALLING:
|
||||
return 'warn';
|
||||
case DeploymentStatus.SUPERSEDED:
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
function getText(status?: string) {
|
||||
switch (status?.toLowerCase()) {
|
||||
case DeploymentStatus.DEPLOYED:
|
||||
return 'Deployed';
|
||||
case DeploymentStatus.FAILED:
|
||||
return 'Failed';
|
||||
case DeploymentStatus.PENDING:
|
||||
case DeploymentStatus.PENDINGUPGRADE:
|
||||
case DeploymentStatus.PENDINGROLLBACK:
|
||||
case DeploymentStatus.UNINSTALLING:
|
||||
return 'Pending';
|
||||
case DeploymentStatus.SUPERSEDED:
|
||||
return 'Superseded';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,193 @@
|
|||
import { fireEvent, render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { DiffControl, DiffViewMode } from './DiffControl';
|
||||
|
||||
// Create a mock for useDebounce that directly passes the setter function
|
||||
vi.mock('@/react/hooks/useDebounce', () => ({
|
||||
useDebounce: (initialValue: number, setter: (value: number) => void) =>
|
||||
// Return the initial value and a function that directly calls the setter
|
||||
[initialValue, setter],
|
||||
}));
|
||||
|
||||
function renderComponent({
|
||||
selectedRevisionNumber = 5,
|
||||
latestRevisionNumber = 10,
|
||||
compareRevisionNumber = 4,
|
||||
setCompareRevisionNumber = vi.fn(),
|
||||
earliestRevisionNumber = 1,
|
||||
diffViewMode = 'view' as DiffViewMode,
|
||||
setDiffViewMode = vi.fn(),
|
||||
isUserSupplied = false,
|
||||
setIsUserSupplied = vi.fn(),
|
||||
showUserSuppliedCheckbox = false,
|
||||
} = {}) {
|
||||
return render(
|
||||
<DiffControl
|
||||
selectedRevisionNumber={selectedRevisionNumber}
|
||||
latestRevisionNumber={latestRevisionNumber}
|
||||
compareRevisionNumber={compareRevisionNumber}
|
||||
setCompareRevisionNumber={setCompareRevisionNumber}
|
||||
earliestRevisionNumber={earliestRevisionNumber}
|
||||
diffViewMode={diffViewMode}
|
||||
setDiffViewMode={setDiffViewMode}
|
||||
isUserSupplied={isUserSupplied}
|
||||
setIsUserSupplied={setIsUserSupplied}
|
||||
showUserSuppliedCheckbox={showUserSuppliedCheckbox}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('DiffControl', () => {
|
||||
it('should only render the user supplied checkbox when latestRevisionNumber is 1 and showUserSuppliedCheckbox is true', () => {
|
||||
const { queryByLabelText } = renderComponent({
|
||||
latestRevisionNumber: 1,
|
||||
showUserSuppliedCheckbox: true,
|
||||
setIsUserSupplied: vi.fn(),
|
||||
});
|
||||
expect(queryByLabelText('View')).toBeNull();
|
||||
expect(queryByLabelText('Diff with previous')).toBeNull();
|
||||
expect(queryByLabelText('Diff with specific revision:')).toBeNull();
|
||||
expect(queryByLabelText('User defined only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render any controls when latestRevisionNumber is 1 and showUserSuppliedCheckbox is false', () => {
|
||||
const { queryByLabelText } = renderComponent({
|
||||
latestRevisionNumber: 1,
|
||||
showUserSuppliedCheckbox: false,
|
||||
});
|
||||
expect(queryByLabelText('Diff with previous')).toBeNull();
|
||||
expect(queryByLabelText('Diff with specific revision:')).toBeNull();
|
||||
expect(queryByLabelText('View')).toBeNull();
|
||||
expect(queryByLabelText('User defined only')).toBeNull();
|
||||
});
|
||||
|
||||
it('should render view option', () => {
|
||||
const { getByLabelText } = renderComponent();
|
||||
expect(getByLabelText('View')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Diff with previous" option when earliestRevisionNumber < selectedRevisionNumber', () => {
|
||||
const { getByLabelText } = renderComponent({
|
||||
earliestRevisionNumber: 3,
|
||||
selectedRevisionNumber: 5,
|
||||
});
|
||||
expect(getByLabelText('Diff with previous')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Diff with previous" option as disabled when earliestRevisionNumber >= selectedRevisionNumber', () => {
|
||||
const { getByLabelText } = renderComponent({
|
||||
earliestRevisionNumber: 5,
|
||||
selectedRevisionNumber: 5,
|
||||
});
|
||||
|
||||
expect(getByLabelText('View')).toBeInTheDocument();
|
||||
expect(getByLabelText('Diff with specific revision:')).toBeInTheDocument();
|
||||
// 'Diff with previous' should exist and be disabled
|
||||
const diffWithPreviousOption = getByLabelText('Diff with previous');
|
||||
expect(diffWithPreviousOption).toBeInTheDocument();
|
||||
expect(diffWithPreviousOption).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render "Diff with specific revision" option', () => {
|
||||
const { getByLabelText } = renderComponent();
|
||||
expect(getByLabelText('Diff with specific revision:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render user supplied checkbox when showUserSuppliedCheckbox is true', () => {
|
||||
const { getByLabelText } = renderComponent({
|
||||
showUserSuppliedCheckbox: true,
|
||||
});
|
||||
expect(getByLabelText('User defined only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render user supplied checkbox when showUserSuppliedCheckbox is false', () => {
|
||||
const { queryByLabelText } = renderComponent({
|
||||
showUserSuppliedCheckbox: false,
|
||||
});
|
||||
expect(queryByLabelText('User defined only')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call setDiffViewMode when a radio option is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
const setDiffViewMode = vi.fn();
|
||||
|
||||
const { getByLabelText } = renderComponent({
|
||||
setDiffViewMode,
|
||||
diffViewMode: 'view',
|
||||
});
|
||||
|
||||
await user.click(getByLabelText('Diff with specific revision:'));
|
||||
expect(setDiffViewMode).toHaveBeenCalledWith('specific');
|
||||
});
|
||||
|
||||
it('should call setIsUserSupplied when checkbox is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const setIsUserSupplied = vi.fn();
|
||||
|
||||
const { getByLabelText } = renderComponent({
|
||||
setIsUserSupplied,
|
||||
isUserSupplied: false,
|
||||
showUserSuppliedCheckbox: true,
|
||||
});
|
||||
|
||||
await user.click(getByLabelText('User defined only'));
|
||||
expect(setIsUserSupplied).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiffWithSpecificRevision', () => {
|
||||
it('should display input with compareRevisionNumber value when not NaN', () => {
|
||||
const compareRevisionNumber = 3;
|
||||
const { getByRole } = renderComponent({
|
||||
diffViewMode: 'specific',
|
||||
compareRevisionNumber,
|
||||
});
|
||||
|
||||
const input = getByRole('spinbutton');
|
||||
expect(input).toHaveValue(compareRevisionNumber);
|
||||
});
|
||||
|
||||
it('should handle input values and constraints properly', () => {
|
||||
const setCompareRevisionNumber = vi.fn();
|
||||
const earliestRevisionNumber = 2;
|
||||
const latestRevisionNumber = 10;
|
||||
|
||||
const { getByRole } = renderComponent({
|
||||
diffViewMode: 'specific',
|
||||
earliestRevisionNumber,
|
||||
latestRevisionNumber,
|
||||
setCompareRevisionNumber,
|
||||
compareRevisionNumber: 4,
|
||||
});
|
||||
|
||||
// Check that input has the right min/max attributes
|
||||
const input = getByRole('spinbutton');
|
||||
expect(input).toHaveAttribute('min', earliestRevisionNumber.toString());
|
||||
expect(input).toHaveAttribute('max', latestRevisionNumber.toString());
|
||||
|
||||
fireEvent.change(input, { target: { valueAsNumber: 11 } });
|
||||
expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(
|
||||
latestRevisionNumber
|
||||
);
|
||||
|
||||
fireEvent.change(input, { target: { valueAsNumber: 1 } });
|
||||
expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(
|
||||
earliestRevisionNumber
|
||||
);
|
||||
|
||||
fireEvent.change(input, { target: { valueAsNumber: 5 } });
|
||||
expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(5);
|
||||
});
|
||||
|
||||
it('should handle NaN values in the input as empty string', () => {
|
||||
const { getByRole } = renderComponent({
|
||||
diffViewMode: 'specific',
|
||||
compareRevisionNumber: NaN,
|
||||
});
|
||||
|
||||
const input = getByRole('spinbutton') as HTMLInputElement;
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,146 @@
|
|||
import { ChangeEvent } from 'react';
|
||||
|
||||
import { useDebounce } from '@/react/hooks/useDebounce';
|
||||
|
||||
import { RadioGroup, RadioGroupOption } from '@@/RadioGroup/RadioGroup';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
|
||||
import {
|
||||
LatestRevisionNumber,
|
||||
EarliestRevisionNumber,
|
||||
CompareRevisionNumber,
|
||||
SelectedRevisionNumber,
|
||||
} from './types';
|
||||
|
||||
export type DiffViewMode = 'view' | 'previous' | 'specific';
|
||||
|
||||
type Props = {
|
||||
selectedRevisionNumber: SelectedRevisionNumber;
|
||||
latestRevisionNumber: LatestRevisionNumber;
|
||||
compareRevisionNumber: CompareRevisionNumber;
|
||||
setCompareRevisionNumber: (
|
||||
compareRevisionNumber: CompareRevisionNumber
|
||||
) => void;
|
||||
earliestRevisionNumber: EarliestRevisionNumber;
|
||||
diffViewMode: DiffViewMode;
|
||||
setDiffViewMode: (diffViewMode: DiffViewMode) => void;
|
||||
isUserSupplied?: boolean;
|
||||
setIsUserSupplied?: (isUserSupplied: boolean) => void;
|
||||
showUserSuppliedCheckbox?: boolean;
|
||||
};
|
||||
|
||||
export function DiffControl({
|
||||
selectedRevisionNumber,
|
||||
latestRevisionNumber,
|
||||
compareRevisionNumber,
|
||||
setCompareRevisionNumber,
|
||||
earliestRevisionNumber,
|
||||
diffViewMode,
|
||||
setDiffViewMode,
|
||||
isUserSupplied,
|
||||
setIsUserSupplied,
|
||||
showUserSuppliedCheckbox,
|
||||
}: Props) {
|
||||
// If there is a different version to compare, show view option radio group
|
||||
const showViewOptions = latestRevisionNumber > earliestRevisionNumber;
|
||||
|
||||
// to show the previous option, the earliest revision number available must be less than the selected revision number. (compare is still allowed, because we can still compare with a later revision)
|
||||
const disabledPreviousOption =
|
||||
earliestRevisionNumber >= selectedRevisionNumber;
|
||||
|
||||
const options: Array<RadioGroupOption<DiffViewMode>> = [
|
||||
{ label: 'View', value: 'view' },
|
||||
{
|
||||
label: 'Diff with previous',
|
||||
value: 'previous',
|
||||
disabled: disabledPreviousOption,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<DiffWithSpecificRevision
|
||||
latestRevisionNumber={latestRevisionNumber}
|
||||
earliestRevisionNumber={earliestRevisionNumber}
|
||||
compareRevisionNumber={compareRevisionNumber}
|
||||
setCompareRevisionNumber={setCompareRevisionNumber}
|
||||
/>
|
||||
),
|
||||
value: 'specific',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-16 gap-y-1 items-center">
|
||||
{showViewOptions && (
|
||||
<RadioGroup
|
||||
options={options}
|
||||
selectedOption={diffViewMode}
|
||||
name="diffControl"
|
||||
onOptionChange={setDiffViewMode}
|
||||
groupClassName="inline-flex flex-wrap gap-x-16 gap-y-1"
|
||||
itemClassName="control-label !p-0 text-left font-normal"
|
||||
/>
|
||||
)}
|
||||
{!!showUserSuppliedCheckbox && !!setIsUserSupplied && (
|
||||
<Checkbox
|
||||
label="User defined only"
|
||||
id="values-details-user-supplied"
|
||||
checked={isUserSupplied}
|
||||
onChange={() => setIsUserSupplied(!isUserSupplied)}
|
||||
data-cy="values-details-user-supplied"
|
||||
className="font-normal control-label"
|
||||
bold={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffWithSpecificRevision({
|
||||
latestRevisionNumber,
|
||||
earliestRevisionNumber,
|
||||
compareRevisionNumber,
|
||||
setCompareRevisionNumber,
|
||||
}: {
|
||||
latestRevisionNumber: LatestRevisionNumber;
|
||||
earliestRevisionNumber: EarliestRevisionNumber;
|
||||
compareRevisionNumber: CompareRevisionNumber;
|
||||
setCompareRevisionNumber: (
|
||||
compareRevisionNumber: CompareRevisionNumber
|
||||
) => void;
|
||||
}) {
|
||||
// the revision number is debounced to avoid too many requests to the backend
|
||||
const [
|
||||
debouncedSetCompareRevisionNumber,
|
||||
setDebouncedSetCompareRevisionNumber,
|
||||
] = useDebounce(compareRevisionNumber, setCompareRevisionNumber, 500);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>Diff with specific revision:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={earliestRevisionNumber}
|
||||
max={latestRevisionNumber}
|
||||
value={debouncedSetCompareRevisionNumber}
|
||||
onChange={handleSpecificRevisionChange}
|
||||
className="w-20 ml-2"
|
||||
data-cy="revision-specific-input"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
function handleSpecificRevisionChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const inputNumber = e.target.valueAsNumber;
|
||||
// handle out of range values
|
||||
if (inputNumber > latestRevisionNumber) {
|
||||
setCompareRevisionNumber(latestRevisionNumber);
|
||||
return;
|
||||
}
|
||||
if (inputNumber < earliestRevisionNumber) {
|
||||
setCompareRevisionNumber(earliestRevisionNumber);
|
||||
return;
|
||||
}
|
||||
setDebouncedSetCompareRevisionNumber(inputNumber);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { DiffViewer } from '@@/CodeEditor/DiffViewer';
|
||||
import { Loading } from '@@/Widget';
|
||||
import { Alert } from '@@/Alert';
|
||||
|
||||
import { CompareRevisionNumberFetched, SelectedRevisionNumber } from './types';
|
||||
|
||||
interface Props extends AutomationTestingProps {
|
||||
isCompareReleaseLoading: boolean;
|
||||
isCompareReleaseError: boolean;
|
||||
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
|
||||
selectedRevisionNumber: SelectedRevisionNumber;
|
||||
newText: string;
|
||||
originalText: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function DiffViewSection({
|
||||
isCompareReleaseLoading,
|
||||
isCompareReleaseError,
|
||||
compareRevisionNumberFetched,
|
||||
selectedRevisionNumber,
|
||||
newText,
|
||||
originalText,
|
||||
id,
|
||||
'data-cy': dataCy,
|
||||
}: Props) {
|
||||
if (isCompareReleaseLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (isCompareReleaseError) {
|
||||
return <Alert color="error">Error loading compare values</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DiffViewer
|
||||
newCode={newText}
|
||||
originalCode={originalText}
|
||||
id={id}
|
||||
data-cy={dataCy}
|
||||
placeholder="No values found"
|
||||
fileNames={{
|
||||
original: compareRevisionNumberFetched
|
||||
? `Revision #${compareRevisionNumberFetched}`
|
||||
: 'No revision selected',
|
||||
modified: `Revision #${selectedRevisionNumber}`,
|
||||
}}
|
||||
className="mt-2"
|
||||
type="yaml"
|
||||
height="60vh"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { HttpResponse } from 'msw';
|
||||
import { Event, EventList } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { server, http } from '@/setup-tests/server';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
|
||||
|
||||
import { GenericResource } from '../../types';
|
||||
|
||||
import {
|
||||
HelmEventsDatatable,
|
||||
filterRelatedEvents,
|
||||
} from './HelmEventsDatatable';
|
||||
|
||||
const mockUseEnvironmentId = vi.fn();
|
||||
mockLocalizeDate();
|
||||
|
||||
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||
}));
|
||||
|
||||
const testResources: GenericResource[] = [
|
||||
{
|
||||
kind: 'Deployment',
|
||||
status: {
|
||||
healthSummary: {
|
||||
status: 'Healthy',
|
||||
reason: 'Running',
|
||||
message: 'All replicas are ready',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
name: 'test-deployment',
|
||||
namespace: 'default',
|
||||
uid: 'test-deployment-uid',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Service',
|
||||
status: {
|
||||
healthSummary: {
|
||||
status: 'Healthy',
|
||||
reason: 'Available',
|
||||
message: 'Service is available',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
name: 'test-service',
|
||||
namespace: 'default',
|
||||
uid: 'test-service-uid',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockEventsResponse: EventList = {
|
||||
kind: 'EventList',
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
resourceVersion: '12345',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'test-deployment-123456',
|
||||
namespace: 'default',
|
||||
uid: 'event-uid-1',
|
||||
resourceVersion: '1000',
|
||||
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
involvedObject: {
|
||||
kind: 'Deployment',
|
||||
namespace: 'default',
|
||||
name: 'test-deployment',
|
||||
uid: 'test-deployment-uid',
|
||||
apiVersion: 'apps/v1',
|
||||
resourceVersion: '2000',
|
||||
},
|
||||
reason: 'ScalingReplicaSet',
|
||||
message: 'Scaled up replica set test-deployment-abc123 to 1',
|
||||
source: {
|
||||
component: 'deployment-controller',
|
||||
},
|
||||
firstTimestamp: '2023-01-01T00:00:00Z',
|
||||
lastTimestamp: '2023-01-01T00:00:00Z',
|
||||
count: 1,
|
||||
type: 'Normal',
|
||||
reportingComponent: 'deployment-controller',
|
||||
reportingInstance: '',
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
name: 'test-service-123456',
|
||||
namespace: 'default',
|
||||
uid: 'event-uid-2',
|
||||
resourceVersion: '1001',
|
||||
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
involvedObject: {
|
||||
kind: 'Service',
|
||||
namespace: 'default',
|
||||
name: 'test-service',
|
||||
uid: 'test-service-uid',
|
||||
apiVersion: 'v1',
|
||||
resourceVersion: '2001',
|
||||
},
|
||||
reason: 'CreatedLoadBalancer',
|
||||
message: 'Created load balancer',
|
||||
source: {
|
||||
component: 'service-controller',
|
||||
},
|
||||
firstTimestamp: '2023-01-01T00:00:00Z',
|
||||
lastTimestamp: '2023-01-01T00:00:00Z',
|
||||
count: 1,
|
||||
type: 'Normal',
|
||||
reportingComponent: 'service-controller',
|
||||
reportingInstance: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mixedEventsResponse: EventList = {
|
||||
kind: 'EventList',
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
resourceVersion: '12345',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'test-deployment-123456',
|
||||
namespace: 'default',
|
||||
uid: 'event-uid-1',
|
||||
resourceVersion: '1000',
|
||||
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
involvedObject: {
|
||||
kind: 'Deployment',
|
||||
namespace: 'default',
|
||||
name: 'test-deployment',
|
||||
uid: 'test-deployment-uid', // This matches a resource UID
|
||||
apiVersion: 'apps/v1',
|
||||
resourceVersion: '2000',
|
||||
},
|
||||
reason: 'ScalingReplicaSet',
|
||||
message: 'Scaled up replica set test-deployment-abc123 to 1',
|
||||
source: {
|
||||
component: 'deployment-controller',
|
||||
},
|
||||
firstTimestamp: '2023-01-01T00:00:00Z',
|
||||
lastTimestamp: '2023-01-01T00:00:00Z',
|
||||
count: 1,
|
||||
type: 'Normal',
|
||||
reportingComponent: 'deployment-controller',
|
||||
reportingInstance: '',
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
name: 'unrelated-pod-123456',
|
||||
namespace: 'default',
|
||||
uid: 'event-uid-3',
|
||||
resourceVersion: '1002',
|
||||
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
involvedObject: {
|
||||
kind: 'Pod',
|
||||
namespace: 'default',
|
||||
name: 'unrelated-pod',
|
||||
uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs
|
||||
apiVersion: 'v1',
|
||||
resourceVersion: '2002',
|
||||
},
|
||||
reason: 'Scheduled',
|
||||
message: 'Successfully assigned unrelated-pod to node',
|
||||
source: {
|
||||
component: 'default-scheduler',
|
||||
},
|
||||
firstTimestamp: '2023-01-01T00:00:00Z',
|
||||
lastTimestamp: '2023-01-01T00:00:00Z',
|
||||
count: 1,
|
||||
reportingComponent: 'scheduler',
|
||||
reportingInstance: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function renderComponent() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
mockUseEnvironmentId.mockReturnValue(3);
|
||||
|
||||
const HelmEventsDatatableWithProviders = withTestQueryProvider(
|
||||
withUserProvider(withTestRouter(HelmEventsDatatable), user)
|
||||
);
|
||||
|
||||
return render(
|
||||
<HelmEventsDatatableWithProviders
|
||||
namespace="default"
|
||||
releaseResources={testResources}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('HelmEventsDatatable', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get(
|
||||
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
|
||||
() => HttpResponse.json(mockEventsResponse)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('should render events datatable with correct title', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Events reflect the latest revision only.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should correctly filter related events using the filterRelatedEvents function', () => {
|
||||
const filteredEvents = filterRelatedEvents(
|
||||
mixedEventsResponse.items as Event[],
|
||||
testResources
|
||||
);
|
||||
|
||||
expect(filteredEvents.length).toBe(1);
|
||||
expect(filteredEvents[0].involvedObject.uid).toBe('test-deployment-uid');
|
||||
|
||||
const unrelatedEvents = filteredEvents.filter(
|
||||
(e) => e.involvedObject.uid === 'unrelated-pod-uid'
|
||||
);
|
||||
expect(unrelatedEvents.length).toBe(0);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,77 @@
|
|||
import { compact } from 'lodash';
|
||||
import { Event } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||
import { EventsDatatable } from '@/react/kubernetes/components/EventsDatatable';
|
||||
import { useEvents } from '@/react/kubernetes/queries/useEvents';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { GenericResource } from '../../types';
|
||||
|
||||
export const storageKey = 'k8sHelmEventsDatatable';
|
||||
export const settingsStore = createStore(storageKey, {
|
||||
id: 'Date',
|
||||
desc: true,
|
||||
});
|
||||
|
||||
export function HelmEventsDatatable({
|
||||
namespace,
|
||||
releaseResources,
|
||||
}: {
|
||||
namespace: string;
|
||||
releaseResources: GenericResource[];
|
||||
}) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
|
||||
const eventsQuery = useEvents(environmentId, {
|
||||
namespace,
|
||||
queryOptions: {
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
select: (data) => filterRelatedEvents(data, releaseResources),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<EventsDatatable
|
||||
dataset={eventsQuery.data || []}
|
||||
title={
|
||||
<TextTip inline color="blue" className="!text-xs">
|
||||
Events reflect the latest revision only.
|
||||
</TextTip>
|
||||
}
|
||||
titleIcon={null}
|
||||
tableState={tableState}
|
||||
isLoading={eventsQuery.isInitialLoading}
|
||||
data-cy="k8sAppDetail-eventsTable"
|
||||
// no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
|
||||
noWidget
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHelmEventsTableState() {
|
||||
return useTableState(settingsStore, storageKey);
|
||||
}
|
||||
|
||||
export function filterRelatedEvents(
|
||||
events: Event[],
|
||||
resources: GenericResource[]
|
||||
) {
|
||||
const relatedUids = getReleaseUids(resources);
|
||||
const relatedUidsSet = new Set(relatedUids);
|
||||
return events.filter(
|
||||
(event) =>
|
||||
event.involvedObject.uid && relatedUidsSet.has(event.involvedObject.uid)
|
||||
);
|
||||
}
|
||||
|
||||
function getReleaseUids(resources: GenericResource[]) {
|
||||
return compact(resources.map((resource) => resource.metadata.uid));
|
||||
}
|
|
@ -1,18 +1,58 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
import { CodeEditor } from '@@/CodeEditor';
|
||||
|
||||
import { DiffViewMode } from './DiffControl';
|
||||
import { DiffViewSection } from './DiffViewSection';
|
||||
import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
|
||||
|
||||
type Props = {
|
||||
manifest: string;
|
||||
selectedRevisionNumber: SelectedRevisionNumber;
|
||||
diffViewMode: DiffViewMode;
|
||||
compareManifest?: string;
|
||||
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
|
||||
isCompareReleaseLoading: boolean;
|
||||
isCompareReleaseError: boolean;
|
||||
diffControl: ReactNode;
|
||||
};
|
||||
|
||||
export function ManifestDetails({ manifest }: Props) {
|
||||
export function ManifestDetails({
|
||||
manifest,
|
||||
selectedRevisionNumber,
|
||||
diffViewMode,
|
||||
compareManifest,
|
||||
compareRevisionNumberFetched,
|
||||
isCompareReleaseLoading,
|
||||
isCompareReleaseError,
|
||||
diffControl,
|
||||
}: Props) {
|
||||
return (
|
||||
<CodeEditor
|
||||
id="helm-manifest"
|
||||
type="yaml"
|
||||
data-cy="helm-manifest"
|
||||
value={manifest}
|
||||
height="600px"
|
||||
readonly
|
||||
/>
|
||||
<>
|
||||
{diffControl}
|
||||
{diffViewMode === 'view' ? (
|
||||
<CodeEditor
|
||||
id="helm-manifest"
|
||||
type="yaml"
|
||||
data-cy="helm-manifest"
|
||||
value={manifest}
|
||||
readonly
|
||||
fileName={`Revision #${selectedRevisionNumber}`}
|
||||
placeholder="No manifest found"
|
||||
height="60vh"
|
||||
/>
|
||||
) : (
|
||||
<DiffViewSection
|
||||
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||
isCompareReleaseError={isCompareReleaseError}
|
||||
compareRevisionNumberFetched={compareRevisionNumberFetched}
|
||||
selectedRevisionNumber={selectedRevisionNumber}
|
||||
newText={manifest}
|
||||
originalText={compareManifest ?? ''}
|
||||
id="helm-manifest-diff-viewer"
|
||||
data-cy="helm-manifest-diff-viewer"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,48 @@
|
|||
import Markdown from 'markdown-to-jsx';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { DiffViewMode } from './DiffControl';
|
||||
import { DiffViewSection } from './DiffViewSection';
|
||||
import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
|
||||
|
||||
type Props = {
|
||||
notes: string;
|
||||
selectedRevisionNumber: SelectedRevisionNumber;
|
||||
diffViewMode: DiffViewMode;
|
||||
compareNotes?: string;
|
||||
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
|
||||
isCompareReleaseLoading: boolean;
|
||||
isCompareReleaseError: boolean;
|
||||
diffControl: ReactNode;
|
||||
};
|
||||
|
||||
export function NotesDetails({ notes }: Props) {
|
||||
return <Markdown className="list-inside mt-6">{notes}</Markdown>;
|
||||
export function NotesDetails({
|
||||
notes,
|
||||
selectedRevisionNumber,
|
||||
diffViewMode,
|
||||
compareNotes,
|
||||
compareRevisionNumberFetched,
|
||||
isCompareReleaseLoading,
|
||||
isCompareReleaseError,
|
||||
diffControl,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
{diffControl}
|
||||
{diffViewMode === 'view' ? (
|
||||
<Markdown className="list-inside mt-6">{notes}</Markdown>
|
||||
) : (
|
||||
<DiffViewSection
|
||||
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||
isCompareReleaseError={isCompareReleaseError}
|
||||
compareRevisionNumberFetched={compareRevisionNumberFetched}
|
||||
selectedRevisionNumber={selectedRevisionNumber}
|
||||
newText={notes}
|
||||
originalText={compareNotes ?? ''}
|
||||
id="helm-notes-diff-viewer"
|
||||
data-cy="helm-notes-diff-viewer"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,32 +1,184 @@
|
|||
import { useState } from 'react';
|
||||
import { compact } from 'lodash';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useEvents } from '@/react/kubernetes/queries/useEvents';
|
||||
|
||||
import { NavTabs, Option } from '@@/NavTabs';
|
||||
import { Badge } from '@@/Badge';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { HelmRelease } from '../../types';
|
||||
import { useHelmHistory } from '../queries/useHelmHistory';
|
||||
|
||||
import { ManifestDetails } from './ManifestDetails';
|
||||
import { NotesDetails } from './NotesDetails';
|
||||
import { ValuesDetails } from './ValuesDetails';
|
||||
import { ResourcesTable } from './ResourcesTable/ResourcesTable';
|
||||
import { DiffControl, DiffViewMode } from './DiffControl';
|
||||
import { useHelmReleaseToCompare } from './useHelmReleaseToCompare';
|
||||
import {
|
||||
filterRelatedEvents,
|
||||
HelmEventsDatatable,
|
||||
useHelmEventsTableState,
|
||||
} from './HelmEventsDatatable';
|
||||
|
||||
type Props = {
|
||||
release: HelmRelease;
|
||||
selectedRevision: number;
|
||||
};
|
||||
|
||||
type Tab = 'values' | 'notes' | 'manifest' | 'resources';
|
||||
type Tab = 'values' | 'notes' | 'manifest' | 'resources' | 'events';
|
||||
|
||||
export function ReleaseTabs({ release, selectedRevision }: Props) {
|
||||
const {
|
||||
params: { tab },
|
||||
} = useCurrentStateAndParams();
|
||||
const router = useRouter();
|
||||
const environmentId = useEnvironmentId();
|
||||
// state is here so that the state isn't lost when the tab changes
|
||||
const [isUserSupplied, setIsUserSupplied] = useState(true);
|
||||
// start with NaN so that the input is empty (see <Input /> for more details)
|
||||
const [selectedCompareRevisionNumber, setSelectedCompareRevisionNumber] =
|
||||
useState(NaN);
|
||||
const [diffViewMode, setDiffViewMode] = useState<DiffViewMode>('view');
|
||||
|
||||
const historyQuery = useHelmHistory(
|
||||
environmentId,
|
||||
release.name,
|
||||
release.namespace ?? ''
|
||||
);
|
||||
const earliestRevisionNumber =
|
||||
historyQuery.data?.[historyQuery.data.length - 1]?.version ??
|
||||
release.version ??
|
||||
1;
|
||||
const latestRevisionNumber =
|
||||
historyQuery.data?.[0]?.version ?? release.version ?? 1;
|
||||
const { compareRelease, isCompareReleaseLoading, isCompareReleaseError } =
|
||||
useHelmReleaseToCompare(
|
||||
release,
|
||||
earliestRevisionNumber,
|
||||
latestRevisionNumber,
|
||||
diffViewMode,
|
||||
selectedRevision,
|
||||
selectedCompareRevisionNumber
|
||||
);
|
||||
|
||||
const { autoRefreshRate } = useHelmEventsTableState();
|
||||
const { data: eventWarningCount } = useEvents(environmentId, {
|
||||
namespace: release.namespace ?? '',
|
||||
queryOptions: {
|
||||
autoRefreshRate: autoRefreshRate * 1000,
|
||||
select: (data) => {
|
||||
const relatedEvents = filterRelatedEvents(
|
||||
data,
|
||||
release.info?.resources ?? []
|
||||
);
|
||||
return relatedEvents.filter((e) => e.type === 'Warning').length;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<NavTabs<Tab>
|
||||
onSelect={setTab}
|
||||
selectedId={parseValidTab(tab, !!release.info?.notes)}
|
||||
type="pills"
|
||||
justified
|
||||
options={helmTabs(
|
||||
release,
|
||||
isUserSupplied,
|
||||
setIsUserSupplied,
|
||||
earliestRevisionNumber,
|
||||
latestRevisionNumber,
|
||||
selectedRevision,
|
||||
selectedCompareRevisionNumber,
|
||||
setSelectedCompareRevisionNumber,
|
||||
diffViewMode,
|
||||
handleDiffViewChange,
|
||||
isCompareReleaseLoading,
|
||||
isCompareReleaseError,
|
||||
eventWarningCount ?? 0,
|
||||
compareRelease
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
function handleDiffViewChange(diffViewMode: DiffViewMode) {
|
||||
setDiffViewMode(diffViewMode);
|
||||
|
||||
if (latestRevisionNumber === earliestRevisionNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the input for compare revision number is NaN, set it to the previous revision number
|
||||
if (
|
||||
Number.isNaN(selectedCompareRevisionNumber) &&
|
||||
diffViewMode === 'specific'
|
||||
) {
|
||||
if (selectedRevision > earliestRevisionNumber) {
|
||||
setSelectedCompareRevisionNumber(selectedRevision - 1);
|
||||
return;
|
||||
}
|
||||
// it could be useful to compare to the latest revision number if the selected revision number is the earliest revision number
|
||||
setSelectedCompareRevisionNumber(latestRevisionNumber);
|
||||
}
|
||||
}
|
||||
|
||||
function setTab(tab: Tab) {
|
||||
router.stateService.go('kubernetes.helm', {
|
||||
tab,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function helmTabs(
|
||||
release: HelmRelease,
|
||||
isUserSupplied: boolean,
|
||||
setIsUserSupplied: (isUserSupplied: boolean) => void
|
||||
setIsUserSupplied: (isUserSupplied: boolean) => void,
|
||||
earliestRevisionNumber: number,
|
||||
latestRevisionNumber: number,
|
||||
selectedRevisionNumber: number,
|
||||
compareRevisionNumber: number,
|
||||
setCompareRevisionNumber: (compareRevisionNumber: number) => void,
|
||||
diffViewMode: DiffViewMode,
|
||||
setDiffViewMode: (diffViewMode: DiffViewMode) => void,
|
||||
isCompareReleaseLoading: boolean,
|
||||
isCompareReleaseError: boolean,
|
||||
eventWarningCount: number,
|
||||
compareRelease?: HelmRelease
|
||||
): Option<Tab>[] {
|
||||
// as long as the latest revision number is greater than the earliest revision number, there are changes to compare
|
||||
const showDiffControl = latestRevisionNumber > earliestRevisionNumber;
|
||||
|
||||
return compact([
|
||||
{
|
||||
label: 'Resources',
|
||||
id: 'resources',
|
||||
children: <ResourcesTable />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
Events
|
||||
{eventWarningCount >= 1 && (
|
||||
<Badge type="warnSecondary">
|
||||
<Icon icon={AlertTriangle} className="!mr-1" />
|
||||
{eventWarningCount}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
id: 'events',
|
||||
children: (
|
||||
<HelmEventsDatatable
|
||||
namespace={release.namespace ?? ''}
|
||||
releaseResources={release.info?.resources ?? []}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Values',
|
||||
id: 'values',
|
||||
|
@ -34,35 +186,97 @@ function helmTabs(
|
|||
<ValuesDetails
|
||||
values={release.values}
|
||||
isUserSupplied={isUserSupplied}
|
||||
setIsUserSupplied={setIsUserSupplied}
|
||||
selectedRevisionNumber={selectedRevisionNumber}
|
||||
diffViewMode={diffViewMode}
|
||||
compareValues={compareRelease?.values}
|
||||
compareRevisionNumberFetched={compareRelease?.version}
|
||||
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||
isCompareReleaseError={isCompareReleaseError}
|
||||
diffControl={
|
||||
<DiffControl
|
||||
selectedRevisionNumber={selectedRevisionNumber}
|
||||
latestRevisionNumber={latestRevisionNumber}
|
||||
earliestRevisionNumber={earliestRevisionNumber}
|
||||
compareRevisionNumber={compareRevisionNumber}
|
||||
setCompareRevisionNumber={setCompareRevisionNumber}
|
||||
diffViewMode={diffViewMode}
|
||||
setDiffViewMode={setDiffViewMode}
|
||||
isUserSupplied={isUserSupplied}
|
||||
setIsUserSupplied={setIsUserSupplied}
|
||||
showUserSuppliedCheckbox
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Manifest',
|
||||
id: 'manifest',
|
||||
children: <ManifestDetails manifest={release.manifest} />,
|
||||
children: (
|
||||
<ManifestDetails
|
||||
manifest={release.manifest}
|
||||
selectedRevisionNumber={selectedRevisionNumber}
|
||||
diffViewMode={diffViewMode}
|
||||
compareManifest={compareRelease?.manifest}
|
||||
compareRevisionNumberFetched={compareRelease?.version}
|
||||
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||
isCompareReleaseError={isCompareReleaseError}
|
||||
diffControl={
|
||||
showDiffControl && (
|
||||
<DiffControl
|
||||
selectedRevisionNumber={selectedRevisionNumber}
|
||||
latestRevisionNumber={latestRevisionNumber}
|
||||
earliestRevisionNumber={earliestRevisionNumber}
|
||||
compareRevisionNumber={compareRevisionNumber}
|
||||
setCompareRevisionNumber={setCompareRevisionNumber}
|
||||
diffViewMode={diffViewMode}
|
||||
setDiffViewMode={setDiffViewMode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
!!release.info?.notes && {
|
||||
label: 'Notes',
|
||||
id: 'notes',
|
||||
children: <NotesDetails notes={release.info.notes} />,
|
||||
children: (
|
||||
<NotesDetails
|
||||
notes={release.info.notes}
|
||||
selectedRevisionNumber={selectedRevisionNumber}
|
||||
diffViewMode={diffViewMode}
|
||||
compareNotes={compareRelease?.info?.notes}
|
||||
compareRevisionNumberFetched={compareRelease?.version}
|
||||
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||
isCompareReleaseError={isCompareReleaseError}
|
||||
diffControl={
|
||||
showDiffControl && (
|
||||
<DiffControl
|
||||
selectedRevisionNumber={selectedRevisionNumber}
|
||||
latestRevisionNumber={latestRevisionNumber}
|
||||
earliestRevisionNumber={earliestRevisionNumber}
|
||||
compareRevisionNumber={compareRevisionNumber}
|
||||
setCompareRevisionNumber={setCompareRevisionNumber}
|
||||
diffViewMode={diffViewMode}
|
||||
setDiffViewMode={setDiffViewMode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export function ReleaseTabs({ release }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('resources');
|
||||
// state is here so that the state isn't lost when the tab changes
|
||||
const [isUserSupplied, setIsUserSupplied] = useState(true);
|
||||
|
||||
return (
|
||||
<NavTabs<Tab>
|
||||
onSelect={setTab}
|
||||
selectedId={tab}
|
||||
type="pills"
|
||||
justified
|
||||
options={helmTabs(release, isUserSupplied, setIsUserSupplied)}
|
||||
/>
|
||||
);
|
||||
function parseValidTab(tab: string, hasNotes: boolean): Tab {
|
||||
if (
|
||||
tab === 'values' ||
|
||||
(tab === 'notes' && hasNotes) ||
|
||||
tab === 'manifest' ||
|
||||
tab === 'resources' ||
|
||||
tab === 'events'
|
||||
) {
|
||||
return tab;
|
||||
}
|
||||
return 'resources';
|
||||
}
|
||||
|
|
|
@ -6,7 +6,17 @@ import { DescribeModal } from './DescribeModal';
|
|||
|
||||
const mockUseDescribeResource = vi.fn();
|
||||
|
||||
vi.mock('yaml-schema', () => ({}));
|
||||
// Mock the CodeEditor component instead of yaml-schema
|
||||
vi.mock('@@/CodeEditor', () => ({
|
||||
CodeEditor: ({
|
||||
value,
|
||||
'data-cy': dataCy,
|
||||
}: {
|
||||
value: string;
|
||||
'data-cy'?: string;
|
||||
}) => <div data-cy={dataCy}>{value}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./queries/useDescribeResource', () => ({
|
||||
useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args),
|
||||
}));
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { useHelmRelease } from '../../queries/useHelmRelease';
|
||||
|
||||
|
@ -34,12 +35,14 @@ const settingsStore = createStore('helm-resources');
|
|||
export function ResourcesTable() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const { params } = useCurrentStateAndParams();
|
||||
const { name, namespace } = params;
|
||||
const { name, namespace, revision } = params;
|
||||
const revisionNumber = revision ? parseInt(revision, 10) : undefined;
|
||||
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
|
||||
showResources: true,
|
||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||
revision: revisionNumber,
|
||||
});
|
||||
const rows = useResourceRows(helmReleaseQuery.data?.info?.resources);
|
||||
|
||||
|
@ -48,11 +51,17 @@ export function ResourcesTable() {
|
|||
<Datatable
|
||||
// no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
|
||||
noWidget
|
||||
isLoading={helmReleaseQuery.isLoading}
|
||||
dataset={rows}
|
||||
columns={columns}
|
||||
includeSearch
|
||||
settingsManager={tableState}
|
||||
emptyContentLabel="No resources found"
|
||||
title={
|
||||
<TextTip inline color="blue" className="!text-xs">
|
||||
Resources reflect the latest revision only.
|
||||
</TextTip>
|
||||
}
|
||||
disableSelect
|
||||
getRowId={(row) => row.id}
|
||||
data-cy="helm-resources-datatable"
|
||||
|
|
|
@ -14,5 +14,9 @@ export const status = columnHelper.accessor((row) => row.status.label, {
|
|||
|
||||
function Cell({ row }: CellContext<ResourceRow, string>) {
|
||||
const { status } = row.original;
|
||||
if (!status.label) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return <StatusBadge color={status.type}>{status.label}</StatusBadge>;
|
||||
}
|
||||
|
|
|
@ -1,44 +1,74 @@
|
|||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { CodeEditor } from '@@/CodeEditor';
|
||||
|
||||
import { Values } from '../../types';
|
||||
|
||||
import { DiffViewMode } from './DiffControl';
|
||||
import { DiffViewSection } from './DiffViewSection';
|
||||
import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
|
||||
|
||||
interface Props {
|
||||
values?: Values;
|
||||
isUserSupplied: boolean;
|
||||
setIsUserSupplied: (isUserSupplied: boolean) => void;
|
||||
selectedRevisionNumber: SelectedRevisionNumber;
|
||||
diffViewMode: DiffViewMode;
|
||||
compareValues?: Values;
|
||||
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
|
||||
isCompareReleaseLoading: boolean;
|
||||
isCompareReleaseError: boolean;
|
||||
diffControl: ReactNode;
|
||||
}
|
||||
|
||||
const noValuesMessage = 'No values found';
|
||||
|
||||
export function ValuesDetails({
|
||||
values,
|
||||
isUserSupplied,
|
||||
setIsUserSupplied,
|
||||
selectedRevisionNumber,
|
||||
diffViewMode,
|
||||
compareValues,
|
||||
compareRevisionNumberFetched,
|
||||
isCompareReleaseLoading,
|
||||
isCompareReleaseError,
|
||||
diffControl,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* bring in line with the code editor copy button */}
|
||||
<div className="absolute top-1 left-0">
|
||||
<Checkbox
|
||||
label="User defined only"
|
||||
id="values-details-user-supplied"
|
||||
checked={isUserSupplied}
|
||||
onChange={() => setIsUserSupplied(!isUserSupplied)}
|
||||
data-cy="values-details-user-supplied"
|
||||
<>
|
||||
{diffControl}
|
||||
{diffViewMode === 'view' ? (
|
||||
<CodeEditor
|
||||
type="yaml"
|
||||
id="values-details-code-editor"
|
||||
data-cy="values-details-code-editor"
|
||||
value={
|
||||
isUserSupplied
|
||||
? values?.userSuppliedValues ?? ''
|
||||
: values?.computedValues ?? ''
|
||||
}
|
||||
readonly
|
||||
fileName={`Revision #${selectedRevisionNumber}`}
|
||||
placeholder="No values found"
|
||||
height="60vh"
|
||||
/>
|
||||
</div>
|
||||
<CodeEditor
|
||||
type="yaml"
|
||||
id="values-details-code-editor"
|
||||
data-cy="values-details-code-editor"
|
||||
value={
|
||||
isUserSupplied
|
||||
? values?.userSuppliedValues ?? noValuesMessage
|
||||
: values?.computedValues ?? noValuesMessage
|
||||
}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DiffViewSection
|
||||
isCompareReleaseLoading={isCompareReleaseLoading}
|
||||
isCompareReleaseError={isCompareReleaseError}
|
||||
compareRevisionNumberFetched={compareRevisionNumberFetched}
|
||||
selectedRevisionNumber={selectedRevisionNumber}
|
||||
newText={
|
||||
isUserSupplied
|
||||
? values?.userSuppliedValues ?? ''
|
||||
: values?.computedValues ?? ''
|
||||
}
|
||||
originalText={
|
||||
isUserSupplied
|
||||
? compareValues?.userSuppliedValues ?? ''
|
||||
: compareValues?.computedValues ?? ''
|
||||
}
|
||||
id="values-details-diff-viewer"
|
||||
data-cy="values-details-diff-viewer"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
// exporting as types here allows the JSDocs to be reused, improving readability
|
||||
|
||||
/**
|
||||
* The revision number of the latest release.
|
||||
*/
|
||||
export type LatestRevisionNumber = number;
|
||||
|
||||
/**
|
||||
* The revision number selected in the UI.
|
||||
*/
|
||||
export type SelectedRevisionNumber = number;
|
||||
|
||||
/**
|
||||
* The revision number to compare with.
|
||||
*/
|
||||
export type CompareRevisionNumber = number;
|
||||
|
||||
/**
|
||||
* The earliest revision number available for the chart.
|
||||
*/
|
||||
export type EarliestRevisionNumber = number;
|
||||
|
||||
/**
|
||||
* The revision number that's being fetched (instead of the form state).
|
||||
*/
|
||||
export type CompareRevisionNumberFetched = number;
|
|
@ -0,0 +1,60 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { HelmRelease } from '../../types';
|
||||
import { useHelmRelease } from '../queries/useHelmRelease';
|
||||
|
||||
import { DiffViewMode } from './DiffControl';
|
||||
|
||||
/** useHelmReleaseToCompare is a hook that returns the release to compare to based on the diffViewMode, selectedRevisionNumber and selectedCompareRevisionNumber */
|
||||
export function useHelmReleaseToCompare(
|
||||
release: HelmRelease,
|
||||
earliestRevisionNumber: number,
|
||||
latestRevisionNumber: number,
|
||||
diffViewMode: DiffViewMode,
|
||||
selectedRevisionNumber: number,
|
||||
selectedCompareRevisionNumber: number
|
||||
) {
|
||||
const environmentId = useEnvironmentId();
|
||||
// the selectedCompareRevisionNumber is the number selected in the input field, but the compareRevisionNumber is the revision number of the release to compare to
|
||||
const compareRevisionNumber = getCompareReleaseVersion(
|
||||
diffViewMode,
|
||||
selectedRevisionNumber,
|
||||
selectedCompareRevisionNumber
|
||||
);
|
||||
const enabled =
|
||||
compareRevisionNumber <= latestRevisionNumber &&
|
||||
compareRevisionNumber >= earliestRevisionNumber;
|
||||
|
||||
// a 1 hour stale time is nice because past releases are not likely to change
|
||||
const compareReleaseQuery = useHelmRelease(
|
||||
environmentId,
|
||||
release.name,
|
||||
release.namespace ?? '',
|
||||
{
|
||||
showResources: false,
|
||||
enabled,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
revision: compareRevisionNumber,
|
||||
}
|
||||
);
|
||||
return {
|
||||
compareRelease: compareReleaseQuery.data,
|
||||
isCompareReleaseLoading: compareReleaseQuery.isInitialLoading,
|
||||
isCompareReleaseError: compareReleaseQuery.isError,
|
||||
};
|
||||
}
|
||||
|
||||
// getCompareReleaseVersion is a helper function that returns the revision number that should be fetched based on the diffViewMode, selectedRevisionNumber and selectedCompareRevisionNumber
|
||||
function getCompareReleaseVersion(
|
||||
diffViewMode: DiffViewMode,
|
||||
selectedRevisionNumber: number,
|
||||
selectedCompareRevisionNumber: number
|
||||
) {
|
||||
if (diffViewMode === 'previous') {
|
||||
return selectedRevisionNumber - 1;
|
||||
}
|
||||
if (diffViewMode === 'specific') {
|
||||
return selectedCompareRevisionNumber;
|
||||
}
|
||||
return selectedRevisionNumber;
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { HelmRelease } from '../../types';
|
||||
|
||||
export function useHelmHistory(
|
||||
environmentId: EnvironmentId,
|
||||
name: string,
|
||||
namespace: string
|
||||
) {
|
||||
return useQuery(
|
||||
[environmentId, 'helm', 'releases', namespace, name, 'history'],
|
||||
() => getHelmHistory(environmentId, name, namespace),
|
||||
{
|
||||
enabled: !!environmentId && !!name && !!namespace,
|
||||
...withGlobalError('Unable to retrieve helm application history'),
|
||||
retry: 3,
|
||||
// occasionally the application shows before the release is created, take some more time to refetch
|
||||
retryDelay: 2000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getHelmHistory(
|
||||
environmentId: EnvironmentId,
|
||||
name: string,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const response = await axios.get<HelmRelease[]>(
|
||||
`endpoints/${environmentId}/kubernetes/helm/${name}/history`,
|
||||
{
|
||||
params: { namespace },
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error, 'Unable to retrieve helm application history');
|
||||
}
|
||||
}
|
|
@ -6,6 +6,15 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|||
|
||||
import { HelmRelease } from '../../types';
|
||||
|
||||
type Options<T> = {
|
||||
select?: (data: HelmRelease) => T;
|
||||
showResources?: boolean;
|
||||
refetchInterval?: number;
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
/** when the revision is undefined, the latest revision is fetched */
|
||||
revision?: number;
|
||||
};
|
||||
/**
|
||||
* React hook to fetch a specific Helm release
|
||||
*/
|
||||
|
@ -13,39 +22,52 @@ export function useHelmRelease<T = HelmRelease>(
|
|||
environmentId: EnvironmentId,
|
||||
name: string,
|
||||
namespace: string,
|
||||
options: {
|
||||
select?: (data: HelmRelease) => T;
|
||||
showResources?: boolean;
|
||||
refetchInterval?: number;
|
||||
} = {}
|
||||
options: Options<T> = {}
|
||||
) {
|
||||
const { select, showResources, refetchInterval } = options;
|
||||
const { select, showResources, refetchInterval, revision, staleTime } =
|
||||
options;
|
||||
return useQuery(
|
||||
[environmentId, 'helm', 'releases', namespace, name, options.showResources],
|
||||
[
|
||||
environmentId,
|
||||
'helm',
|
||||
'releases',
|
||||
namespace,
|
||||
name,
|
||||
revision,
|
||||
showResources,
|
||||
],
|
||||
() =>
|
||||
getHelmRelease(environmentId, name, {
|
||||
namespace,
|
||||
showResources,
|
||||
revision,
|
||||
}),
|
||||
{
|
||||
enabled: !!environmentId && !!name && !!namespace,
|
||||
enabled: !!environmentId && !!name && !!namespace && options.enabled,
|
||||
...withGlobalError('Unable to retrieve helm application details'),
|
||||
retry: 3,
|
||||
// occasionally the application shows before the release is created, take some more time to refetch
|
||||
retryDelay: 2000,
|
||||
select,
|
||||
refetchInterval,
|
||||
staleTime,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type Params = {
|
||||
namespace: string;
|
||||
showResources?: boolean;
|
||||
revision?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific Helm release
|
||||
*/
|
||||
async function getHelmRelease(
|
||||
environmentId: EnvironmentId,
|
||||
name: string,
|
||||
params: {
|
||||
namespace: string;
|
||||
showResources?: boolean;
|
||||
}
|
||||
params: Params
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<HelmRelease>(
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
import { useQuery, 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 '../../queries/useHelmChartList';
|
||||
|
||||
interface HelmSearch {
|
||||
entries: Entries;
|
||||
}
|
||||
|
||||
interface Entries {
|
||||
[key: string]: { version: string }[];
|
||||
}
|
||||
|
||||
export interface ChartVersion {
|
||||
Repo: 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
|
||||
*
|
||||
* @param chart The chart name to get versions for
|
||||
* @param repositories Array of repository URLs to search in
|
||||
*/
|
||||
export function useHelmRepoVersions(
|
||||
chart: string,
|
||||
staleTime: number,
|
||||
repositories: string[] = []
|
||||
) {
|
||||
// Fetch versions from each repository in parallel as separate queries
|
||||
const versionQueries = useQueries({
|
||||
queries: useMemo(
|
||||
() =>
|
||||
repositories.map((repo) => ({
|
||||
queryKey: ['helm', 'repositories', chart, repo],
|
||||
queryFn: () => getSearchHelmRepo(repo, chart),
|
||||
enabled: !!chart && repositories.length > 0,
|
||||
staleTime,
|
||||
...withGlobalError(`Unable to retrieve versions from ${repo}`),
|
||||
})),
|
||||
[repositories, chart, staleTime]
|
||||
),
|
||||
});
|
||||
|
||||
// Combine the results from all repositories for easier consumption
|
||||
const allVersions = useMemo(() => {
|
||||
const successfulResults = compact(versionQueries.map((q) => q.data));
|
||||
return flatMap(successfulResults);
|
||||
}, [versionQueries]);
|
||||
|
||||
return {
|
||||
data: allVersions,
|
||||
isInitialLoading: versionQueries.some((q) => q.isLoading),
|
||||
isError: versionQueries.some((q) => q.isError),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Helm repositories for user
|
||||
*/
|
||||
async function getSearchHelmRepo(
|
||||
repo: string,
|
||||
chart: string
|
||||
): Promise<ChartVersion[]> {
|
||||
try {
|
||||
const { data } = await axios.get<HelmSearch>(`templates/helm`, {
|
||||
params: { repo, chart },
|
||||
});
|
||||
const versions = data.entries[chart];
|
||||
return (
|
||||
versions?.map((v) => ({
|
||||
Repo: repo,
|
||||
Version: v.version,
|
||||
})) ?? []
|
||||
);
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||
import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { HelmRelease } from '../../types';
|
||||
|
||||
export interface UpdateHelmReleasePayload {
|
||||
namespace: string;
|
||||
values?: string;
|
||||
repo?: string;
|
||||
name: string;
|
||||
chart: string;
|
||||
version?: string;
|
||||
}
|
||||
export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: UpdateHelmReleasePayload) =>
|
||||
updateHelmRelease(environmentId, payload),
|
||||
...withInvalidate(queryClient, [
|
||||
[environmentId, 'helm', 'releases'],
|
||||
applicationsQueryKeys.applications(environmentId),
|
||||
]),
|
||||
...withGlobalError('Unable to uninstall helm application'),
|
||||
});
|
||||
}
|
||||
|
||||
async function updateHelmRelease(
|
||||
environmentId: EnvironmentId,
|
||||
payload: UpdateHelmReleasePayload
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.post<HelmRelease>(
|
||||
`endpoints/${environmentId}/kubernetes/helm`,
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Unable to update helm release');
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue