mirror of
https://github.com/portainer/portainer.git
synced 2025-07-25 08:19:40 +02:00
feat(helm): update helm view [r8s-256] (#582)
Co-authored-by: Cara Ryan <cara.ryan@portainer.io> Co-authored-by: James Player <james.player@portainer.io> Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
parent
46eddbe7b9
commit
0ca9321db1
57 changed files with 2635 additions and 222 deletions
|
@ -6,6 +6,7 @@ import { server, http } from '@/setup-tests/server';
|
|||
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 { HelmApplicationView } from './HelmApplicationView';
|
||||
|
||||
|
@ -22,6 +23,41 @@ vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
|||
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||
}));
|
||||
|
||||
mockCodeMirror();
|
||||
|
||||
const minimalHelmRelease = {
|
||||
name: 'test-release',
|
||||
version: '1',
|
||||
namespace: 'default',
|
||||
chart: {
|
||||
metadata: {
|
||||
name: 'test-chart',
|
||||
// appVersion: '1.0.0', // can be missing for a minimal release
|
||||
version: '2.2.2',
|
||||
},
|
||||
},
|
||||
info: {
|
||||
status: 'deployed',
|
||||
// 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',
|
||||
},
|
||||
chart: {
|
||||
...minimalHelmRelease.chart,
|
||||
metadata: {
|
||||
...minimalHelmRelease.chart.metadata,
|
||||
appVersion: '1.0.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function renderComponent() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
const Wrapped = withTestQueryProvider(
|
||||
|
@ -40,47 +76,52 @@ describe('HelmApplicationView', () => {
|
|||
namespace: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
// Set up default mock API responses
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm', () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
name: 'test-release',
|
||||
chart: 'test-chart-1.0.0',
|
||||
app_version: '1.0.0',
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('should display helm release details when data is loaded', async () => {
|
||||
renderComponent();
|
||||
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)
|
||||
)
|
||||
);
|
||||
|
||||
const { findByText, findAllByText } = renderComponent();
|
||||
|
||||
// Check for the page header
|
||||
expect(await screen.findByText('Helm details')).toBeInTheDocument();
|
||||
expect(await findByText('Helm details')).toBeInTheDocument();
|
||||
|
||||
// Check for the release details
|
||||
expect(await screen.findByText('Release')).toBeInTheDocument();
|
||||
|
||||
// Check for the table content
|
||||
expect(await screen.findByText('Name')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Chart')).toBeInTheDocument();
|
||||
expect(await screen.findByText('App version')).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 actual values
|
||||
expect(await screen.findByTestId('k8sAppDetail-appName')).toHaveTextContent(
|
||||
'test-release'
|
||||
);
|
||||
expect(await screen.findByText('test-chart-1.0.0')).toBeInTheDocument();
|
||||
expect(await screen.findByText('1.0.0')).toBeInTheDocument();
|
||||
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 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();
|
||||
|
||||
// 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', () => HttpResponse.error())
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
HttpResponse.error()
|
||||
)
|
||||
);
|
||||
|
||||
// Mock console.error to prevent test output pollution
|
||||
|
@ -97,23 +138,20 @@ describe('HelmApplicationView', () => {
|
|||
vi.spyOn(console, 'error').mockRestore();
|
||||
});
|
||||
|
||||
it('should display error message when release is not found', async () => {
|
||||
// Mock empty response (no releases found)
|
||||
it('should display additional details when available in helm release', async () => {
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.json([]))
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
HttpResponse.json(helmReleaseWithAdditionalDetails)
|
||||
)
|
||||
);
|
||||
|
||||
// Mock console.error to prevent test output pollution
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { findByText } = renderComponent();
|
||||
|
||||
renderComponent();
|
||||
// Check for the notes tab when notes are available
|
||||
expect(await findByText(/Notes/)).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();
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,12 +1,21 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import helm from '@/assets/ico/vendor/helm.svg?c';
|
||||
import { PageHeader } from '@/react/components/PageHeader';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { HelmDetailsWidget } from './HelmDetailsWidget';
|
||||
import { WidgetTitle, WidgetBody, Widget, Loading } from '@@/Widget';
|
||||
import { Card } from '@@/Card';
|
||||
import { Alert } from '@@/Alert';
|
||||
|
||||
import { HelmSummary } from './HelmSummary';
|
||||
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
|
||||
import { useHelmRelease } from './queries/useHelmRelease';
|
||||
|
||||
export function HelmApplicationView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const { params } = useCurrentStateAndParams();
|
||||
|
||||
const { name, namespace } = params;
|
||||
|
||||
return (
|
||||
|
@ -22,9 +31,58 @@ export function HelmApplicationView() {
|
|||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<HelmDetailsWidget name={name} namespace={namespace} />
|
||||
<Widget>
|
||||
{name && <WidgetTitle icon={helm} title={name} />}
|
||||
<WidgetBody className="!pt-1">
|
||||
<HelmDetails
|
||||
name={name}
|
||||
namespace={namespace}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type HelmDetailsProps = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
environmentId: EnvironmentId;
|
||||
};
|
||||
|
||||
function HelmDetails({ name, namespace, environmentId }: HelmDetailsProps) {
|
||||
const {
|
||||
data: release,
|
||||
isInitialLoading,
|
||||
isError,
|
||||
} = useHelmRelease(environmentId, name, namespace, {
|
||||
showResources: true,
|
||||
});
|
||||
|
||||
if (isInitialLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert color="error" title="Failed to load Helm application details" />
|
||||
);
|
||||
}
|
||||
|
||||
if (!release) {
|
||||
return <Alert color="error" title="No Helm application details found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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={release} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
import {
|
||||
Loading,
|
||||
Widget,
|
||||
WidgetBody,
|
||||
WidgetTitle,
|
||||
} from '@/react/components/Widget';
|
||||
import helm from '@/assets/ico/vendor/helm.svg?c';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { Alert } from '@@/Alert';
|
||||
|
||||
import { useHelmRelease } from './queries/useHelmRelease';
|
||||
|
||||
interface HelmDetailsWidgetProps {
|
||||
name: string;
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
export function HelmDetailsWidget({ name, namespace }: HelmDetailsWidgetProps) {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const {
|
||||
data: release,
|
||||
isInitialLoading,
|
||||
isError,
|
||||
} = useHelmRelease(environmentId, name, namespace);
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetTitle icon={helm} title="Release" />
|
||||
<WidgetBody>
|
||||
{isInitialLoading && <Loading />}
|
||||
|
||||
{isError && (
|
||||
<Alert
|
||||
color="error"
|
||||
title="Failed to load Helm application details"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isInitialLoading && !isError && release && (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="!border-none w-40">Name</td>
|
||||
<td
|
||||
className="!border-none min-w-[140px]"
|
||||
data-cy="k8sAppDetail-appName"
|
||||
>
|
||||
{release.name}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="!border-t">Chart</td>
|
||||
<td className="!border-t">{release.chart}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>App version</td>
|
||||
<td>{release.app_version}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
111
app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
Normal file
111
app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
Normal file
|
@ -0,0 +1,111 @@
|
|||
import { Badge } from '@/react/components/Badge';
|
||||
|
||||
import { Alert } from '@@/Alert';
|
||||
|
||||
import { HelmRelease } from '../types';
|
||||
|
||||
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 ||
|
||||
release.info?.status === DeploymentStatus.SUPERSEDED;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<Badge type={getStatusColor(release.info?.status)}>
|
||||
{getText(release.info?.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!!release.namespace && <Badge>Namespace: {release.namespace}</Badge>}
|
||||
{!!release.version && <Badge>Revision: #{release.version}</Badge>}
|
||||
{!!release.chart?.metadata?.name && (
|
||||
<Badge>Chart: {release.chart.metadata.name}</Badge>
|
||||
)}
|
||||
{!!release.chart?.metadata?.appVersion && (
|
||||
<Badge>App version: {release.chart.metadata.appVersion}</Badge>
|
||||
)}
|
||||
{!!release.chart?.metadata?.version && (
|
||||
<Badge>
|
||||
Chart version: {release.chart.metadata.name}-
|
||||
{release.chart.metadata.version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!!release.info?.description && !isSuccess && (
|
||||
<Alert color={getAlertColor(release.info?.status)}>
|
||||
{release.info?.description}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getAlertColor(status?: string) {
|
||||
switch (status?.toLowerCase()) {
|
||||
case DeploymentStatus.DEPLOYED:
|
||||
return 'success';
|
||||
case DeploymentStatus.FAILED:
|
||||
return 'error';
|
||||
case DeploymentStatus.PENDING:
|
||||
case DeploymentStatus.PENDINGUPGRADE:
|
||||
case DeploymentStatus.PENDINGROLLBACK:
|
||||
case DeploymentStatus.UNINSTALLING:
|
||||
return 'warn';
|
||||
case DeploymentStatus.SUPERSEDED:
|
||||
default:
|
||||
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,18 @@
|
|||
import { CodeEditor } from '@@/CodeEditor';
|
||||
|
||||
type Props = {
|
||||
manifest: string;
|
||||
};
|
||||
|
||||
export function ManifestDetails({ manifest }: Props) {
|
||||
return (
|
||||
<CodeEditor
|
||||
id="helm-manifest"
|
||||
type="yaml"
|
||||
data-cy="helm-manifest"
|
||||
value={manifest}
|
||||
height="600px"
|
||||
readonly
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import Markdown from 'markdown-to-jsx';
|
||||
|
||||
type Props = {
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export function NotesDetails({ notes }: Props) {
|
||||
return <Markdown className="list-inside mt-6">{notes}</Markdown>;
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import { useState } from 'react';
|
||||
import { compact } from 'lodash';
|
||||
|
||||
import { NavTabs, Option } from '@@/NavTabs';
|
||||
|
||||
import { HelmRelease } from '../../types';
|
||||
|
||||
import { ManifestDetails } from './ManifestDetails';
|
||||
import { NotesDetails } from './NotesDetails';
|
||||
import { ValuesDetails } from './ValuesDetails';
|
||||
import { ResourcesTable } from './ResourcesTable/ResourcesTable';
|
||||
|
||||
type Props = {
|
||||
release: HelmRelease;
|
||||
};
|
||||
|
||||
type Tab = 'values' | 'notes' | 'manifest' | 'resources';
|
||||
|
||||
function helmTabs(
|
||||
release: HelmRelease,
|
||||
isUserSupplied: boolean,
|
||||
setIsUserSupplied: (isUserSupplied: boolean) => void
|
||||
): Option<Tab>[] {
|
||||
return compact([
|
||||
{
|
||||
label: 'Resources',
|
||||
id: 'resources',
|
||||
children: <ResourcesTable resources={release.info?.resources ?? []} />,
|
||||
},
|
||||
{
|
||||
label: 'Values',
|
||||
id: 'values',
|
||||
children: (
|
||||
<ValuesDetails
|
||||
values={release.values}
|
||||
isUserSupplied={isUserSupplied}
|
||||
setIsUserSupplied={setIsUserSupplied}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Manifest',
|
||||
id: 'manifest',
|
||||
children: <ManifestDetails manifest={release.manifest} />,
|
||||
},
|
||||
!!release.info?.notes && {
|
||||
label: 'Notes',
|
||||
id: 'notes',
|
||||
children: <NotesDetails notes={release.info.notes} />,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
|
||||
import { DescribeModal } from './DescribeModal';
|
||||
|
||||
const mockUseDescribeResource = vi.fn();
|
||||
|
||||
vi.mock('yaml-schema', () => ({}));
|
||||
vi.mock('./queries/useDescribeResource', () => ({
|
||||
useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args),
|
||||
}));
|
||||
|
||||
function renderComponent({
|
||||
name = 'test-resource',
|
||||
resourceType = 'Deployment',
|
||||
namespace = 'default',
|
||||
onDismiss = vi.fn(),
|
||||
} = {}) {
|
||||
const Wrapped = withTestQueryProvider(DescribeModal);
|
||||
return render(
|
||||
<Wrapped
|
||||
name={name}
|
||||
resourceType={resourceType}
|
||||
namespace={namespace}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('DescribeModal', () => {
|
||||
beforeEach(() => {
|
||||
mockUseDescribeResource.mockReset();
|
||||
});
|
||||
|
||||
it('should display loading state initially', () => {
|
||||
mockUseDescribeResource.mockReturnValue({
|
||||
isLoading: true,
|
||||
data: undefined,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display resource details when data is loaded successfully', () => {
|
||||
const mockDescribeData = {
|
||||
describe: 'Name: test-resource\nNamespace: default\nStatus: Running',
|
||||
};
|
||||
|
||||
mockUseDescribeResource.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockDescribeData,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Check for modal title
|
||||
expect(screen.getByText('Describe Deployment')).toBeInTheDocument();
|
||||
|
||||
// Check for content
|
||||
const editor = screen.getByTestId('describe-resource');
|
||||
expect(editor).toBeInTheDocument();
|
||||
expect(editor).toHaveTextContent('Name: test-resource');
|
||||
expect(editor).toHaveTextContent('Namespace: default');
|
||||
expect(editor).toHaveTextContent('Status: Running');
|
||||
});
|
||||
|
||||
it('should display error message when query fails', () => {
|
||||
mockUseDescribeResource.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: undefined,
|
||||
isError: true,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
screen.getByText('Error loading resource details')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onDismiss when modal is closed', () => {
|
||||
mockUseDescribeResource.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: { describe: '' },
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const onDismiss = vi.fn();
|
||||
renderComponent({ onDismiss });
|
||||
|
||||
// Find and click the close button
|
||||
const closeButton = screen.getByText('×');
|
||||
closeButton.click();
|
||||
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass correct parameters to useDescribeResource', () => {
|
||||
mockUseDescribeResource.mockReturnValue({
|
||||
isLoading: true,
|
||||
data: undefined,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const props = {
|
||||
name: 'my-resource',
|
||||
resourceType: 'Pod',
|
||||
namespace: 'kube-system',
|
||||
};
|
||||
|
||||
renderComponent(props);
|
||||
|
||||
expect(mockUseDescribeResource).toHaveBeenCalledWith(
|
||||
props.name,
|
||||
props.resourceType,
|
||||
props.namespace
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
import { Alert } from '@@/Alert';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { Modal } from '@@/modals';
|
||||
import { ModalBody } from '@@/modals/Modal/ModalBody';
|
||||
import { ModalHeader } from '@@/modals/Modal/ModalHeader';
|
||||
import { CodeEditor } from '@@/CodeEditor';
|
||||
|
||||
import { useDescribeResource } from './queries/useDescribeResource';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
resourceType?: string;
|
||||
namespace?: string;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
export function DescribeModal({
|
||||
name,
|
||||
resourceType,
|
||||
namespace,
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const title = `Describe ${resourceType}`;
|
||||
|
||||
const { data, isLoading, isError } = useDescribeResource(
|
||||
name,
|
||||
resourceType,
|
||||
namespace
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal onDismiss={onDismiss} size="lg" aria-label={title}>
|
||||
<ModalHeader title={title} />
|
||||
<ModalBody>
|
||||
{isLoading ? (
|
||||
<InlineLoader>Loading...</InlineLoader>
|
||||
) : (
|
||||
<>
|
||||
{isError ? (
|
||||
<Alert color="error" title="Error">
|
||||
Error loading resource details
|
||||
</Alert>
|
||||
) : (
|
||||
<CodeEditor
|
||||
id="describe-resource"
|
||||
data-cy="describe-resource"
|
||||
readonly
|
||||
value={data?.describe}
|
||||
type="yaml"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
|
||||
import { GenericResource } from '../../../types';
|
||||
|
||||
import { ResourcesTable } from './ResourcesTable';
|
||||
|
||||
const successResources = [
|
||||
{
|
||||
kind: 'ValidatingWebhookConfiguration',
|
||||
apiVersion: 'admissionregistration.k8s.io/v1',
|
||||
metadata: {
|
||||
name: 'ingress-nginx-1743063493-admission',
|
||||
uid: 'e5388792-c184-479d-9133-390759c4bded',
|
||||
labels: {
|
||||
'app.kubernetes.io/name': 'ingress-nginx',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
healthSummary: {
|
||||
status: 'Healthy',
|
||||
reason: 'Exists',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Deployment',
|
||||
apiVersion: 'apps/v1',
|
||||
metadata: {
|
||||
name: 'ingress-nginx-1743063493-controller2',
|
||||
namespace: 'default',
|
||||
uid: 'dcfe325b-7065-47ed-91e3-47f60301cf2e',
|
||||
labels: {
|
||||
'app.kubernetes.io/name': 'ingress-nginx',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
healthSummary: {
|
||||
status: 'Healthy',
|
||||
reason: 'MinimumReplicasAvailable',
|
||||
message: 'Deployment has minimum availability.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Pod',
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
name: 'ingress-nginx-1743063493-controller2-54d8f7d8c5-lsf9p',
|
||||
generateName: 'ingress-nginx-1743063493-controller2-54d8f7d8c5-',
|
||||
namespace: 'default',
|
||||
uid: '7176ad7c-0f83-4a65-a45e-d40076adc302',
|
||||
labels: {
|
||||
'app.kubernetes.io/name': 'ingress-nginx',
|
||||
'pod-template-hash': '54d8f7d8c5',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
phase: 'Running',
|
||||
healthSummary: {
|
||||
status: 'Unknown',
|
||||
reason: 'Running',
|
||||
},
|
||||
hostIP: '198.19.249.2',
|
||||
startTime: '2025-03-27T20:39:05Z',
|
||||
},
|
||||
},
|
||||
];
|
||||
const failedResources = [
|
||||
{
|
||||
kind: 'PodDisruptionBudget',
|
||||
metadata: {
|
||||
name: 'probe-failure-nginx-bad',
|
||||
namespace: 'my-namespace',
|
||||
uid: 'e4e15f7a-9a68-448e-86b3-d74ef29c718c',
|
||||
labels: {
|
||||
'app.kubernetes.io/name': 'nginx',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
healthSummary: {
|
||||
status: 'Unhealthy',
|
||||
reason: 'InsufficientPods',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Service',
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
name: 'probe-failure-nginx',
|
||||
namespace: 'my-namespace',
|
||||
uid: 'de9cdffc-6af8-43b2-9750-3ac764b25627',
|
||||
labels: {
|
||||
'app.kubernetes.io/name': 'nginx',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
healthSummary: {
|
||||
status: 'Healthy',
|
||||
reason: 'Exists',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function renderResourcesTable(resources: GenericResource[]) {
|
||||
const Wrapped = withTestQueryProvider(withTestRouter(ResourcesTable));
|
||||
return render(<Wrapped resources={resources} />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ResourcesTable', () => {
|
||||
it('should show successful resources, including a link for the deployment and a message', () => {
|
||||
renderResourcesTable(successResources);
|
||||
|
||||
// Check that the deployment is rendered with a link
|
||||
const deploymentLink = screen.getByText(
|
||||
'ingress-nginx-1743063493-controller2'
|
||||
);
|
||||
expect(deploymentLink).toBeInTheDocument();
|
||||
expect(deploymentLink.closest('a')).toHaveTextContent(
|
||||
'ingress-nginx-1743063493-controller2'
|
||||
);
|
||||
|
||||
// Check that success badge is rendered
|
||||
const successBadge = screen.getByText('MinimumReplicasAvailable');
|
||||
expect(successBadge).toBeInTheDocument();
|
||||
expect(successBadge.className).toContain('bg-success');
|
||||
});
|
||||
|
||||
it('should show error badges for failed resources', () => {
|
||||
renderResourcesTable(failedResources);
|
||||
expect(screen.getByText('probe-failure-nginx-bad')).toBeInTheDocument();
|
||||
|
||||
// Check for the unhealthy status badge and make sure it has the error styling
|
||||
const errorBadge = screen.getByText('InsufficientPods');
|
||||
expect(errorBadge).toBeInTheDocument();
|
||||
expect(errorBadge.className).toContain('bg-error');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
import { Datatable } from '@@/datatables';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { GenericResource } from '../../../types';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { useResourceRows } from './useResourceRows';
|
||||
|
||||
type Props = {
|
||||
resources: GenericResource[];
|
||||
};
|
||||
|
||||
const storageKey = 'helm-resources';
|
||||
const settingsStore = createPersistedStore(storageKey, 'resourceType');
|
||||
|
||||
export function ResourcesTable({ resources }: Props) {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
const rows = useResourceRows(resources);
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<Datatable
|
||||
// no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
|
||||
noWidget
|
||||
dataset={rows}
|
||||
columns={columns}
|
||||
includeSearch
|
||||
settingsManager={tableState}
|
||||
emptyContentLabel="No resources found"
|
||||
disableSelect
|
||||
getRowId={(row) => row.id}
|
||||
data-cy="helm-resources-datatable"
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import { useState } from 'react';
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { ResourceRow } from '../types';
|
||||
import { DescribeModal } from '../DescribeModal';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const actions = columnHelper.accessor((row) => row.status.label, {
|
||||
header: 'Actions',
|
||||
id: 'actions',
|
||||
cell: Cell,
|
||||
enableSorting: false,
|
||||
});
|
||||
|
||||
function Cell({ row }: CellContext<ResourceRow, string>) {
|
||||
const { describe } = row.original;
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
color="link"
|
||||
data-cy="helm-resource-describe"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="pl-0 !ml-0"
|
||||
>
|
||||
<Icon icon={FileText} />
|
||||
Describe
|
||||
</Button>
|
||||
|
||||
{modalOpen && (
|
||||
<DescribeModal
|
||||
name={describe.name}
|
||||
resourceType={describe.resourceType}
|
||||
namespace={describe.namespace}
|
||||
onDismiss={() => setModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { ResourceRow } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<ResourceRow>();
|
|
@ -0,0 +1,7 @@
|
|||
import { name } from './name';
|
||||
import { resourceType } from './resourceType';
|
||||
import { status } from './status';
|
||||
import { statusMessage } from './statusMessage';
|
||||
import { actions } from './actions';
|
||||
|
||||
export const columns = [name, resourceType, status, statusMessage, actions];
|
|
@ -0,0 +1,33 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { ResourceRow } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const name = columnHelper.accessor((row) => row.name.label, {
|
||||
header: 'Name',
|
||||
cell: Cell,
|
||||
id: 'name',
|
||||
});
|
||||
|
||||
function Cell({ row }: CellContext<ResourceRow, string>) {
|
||||
const { name } = row.original;
|
||||
|
||||
if (name.link && name.link.to) {
|
||||
return (
|
||||
<Link
|
||||
to={name.link.to}
|
||||
params={name.link.params}
|
||||
title={name.label}
|
||||
className="w-fit max-w-xs truncate xl:max-w-sm 2xl:max-w-md"
|
||||
data-cy={`helm-resource-link-${name.label}`}
|
||||
>
|
||||
{name.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return name.label;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { columnHelper } from './helper';
|
||||
|
||||
export const resourceType = columnHelper.accessor((row) => row.resourceType, {
|
||||
header: 'Resource type',
|
||||
id: 'resourceType',
|
||||
});
|
|
@ -0,0 +1,18 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { StatusBadge } from '@@/StatusBadge';
|
||||
|
||||
import { ResourceRow } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const status = columnHelper.accessor((row) => row.status.label, {
|
||||
header: 'Status',
|
||||
id: 'status',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({ row }: CellContext<ResourceRow, string>) {
|
||||
const { status } = row.original;
|
||||
return <StatusBadge color={status.type}>{status.label}</StatusBadge>;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { columnHelper } from './helper';
|
||||
|
||||
export const statusMessage = columnHelper.accessor((row) => row.statusMessage, {
|
||||
header: 'Status message',
|
||||
id: 'statusMessage',
|
||||
cell: ({ row }) => (
|
||||
<div className="whitespace-pre-wrap">
|
||||
<span>{row.original.statusMessage || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
});
|
|
@ -0,0 +1,62 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
|
||||
type DescribeAPIParams = {
|
||||
name: string;
|
||||
kind: string;
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
type DescribeResourceResponse = {
|
||||
describe: string;
|
||||
};
|
||||
|
||||
async function getDescribeResource(
|
||||
environmentId: number,
|
||||
name: string,
|
||||
resourceType?: string,
|
||||
namespace?: string
|
||||
) {
|
||||
try {
|
||||
// This should never happen, but to keep the linter happy...
|
||||
if (!name || !resourceType) {
|
||||
throw new Error('Name and kind are required');
|
||||
}
|
||||
|
||||
const params: DescribeAPIParams = {
|
||||
name,
|
||||
namespace,
|
||||
kind: resourceType,
|
||||
};
|
||||
|
||||
const { data } = await axios.get<DescribeResourceResponse>(
|
||||
`kubernetes/${environmentId}/describe`,
|
||||
{
|
||||
params,
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Unable to retrieve resource details');
|
||||
}
|
||||
}
|
||||
|
||||
export function useDescribeResource(
|
||||
name: string,
|
||||
resourceType?: string,
|
||||
namespace?: string
|
||||
) {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
return useQuery(
|
||||
[environmentId, 'kubernetes', 'describe', namespace, resourceType, name],
|
||||
() => getDescribeResource(environmentId, name, resourceType, namespace),
|
||||
{
|
||||
enabled: !!environmentId && !!name && !!resourceType,
|
||||
...withGlobalError('Enable to retrieve data for resource'),
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import { StatusBadgeType } from '@@/StatusBadge';
|
||||
|
||||
export type ResourceLink = {
|
||||
to?: string;
|
||||
params?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ResourceRow = {
|
||||
// for the table row id
|
||||
id: string;
|
||||
// for the table row name (link to resource if available)
|
||||
name: {
|
||||
label: string;
|
||||
link: ResourceLink | null;
|
||||
};
|
||||
resourceType: string;
|
||||
describe: {
|
||||
name: string;
|
||||
resourceType?: string;
|
||||
namespace?: string;
|
||||
};
|
||||
status: {
|
||||
label: string;
|
||||
type: StatusBadgeType;
|
||||
};
|
||||
statusMessage: string;
|
||||
};
|
|
@ -0,0 +1,93 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { StatusBadgeType } from '@@/StatusBadge';
|
||||
|
||||
import { GenericResource } from '../../../types';
|
||||
|
||||
import { ResourceLink, ResourceRow } from './types';
|
||||
|
||||
// from defined routes in app/kubernetes/__module.js
|
||||
const kindToUrlMap = {
|
||||
Deployment: 'kubernetes.applications.application',
|
||||
DaemonSet: 'kubernetes.applications.application',
|
||||
StatefulSet: 'kubernetes.applications.application',
|
||||
Pod: 'kubernetes.applications.application',
|
||||
Ingress: 'kubernetes.ingresses',
|
||||
ConfigMap: 'kubernetes.configmaps.configmap',
|
||||
Secret: 'kubernetes.secrets.secret',
|
||||
PersistentVolumeClaim: 'kubernetes.volumes.volume',
|
||||
};
|
||||
|
||||
const statusToColorMap: Record<string, StatusBadgeType> = {
|
||||
Healthy: 'success',
|
||||
Progressing: 'warning',
|
||||
Degraded: 'danger',
|
||||
Failed: 'danger',
|
||||
Unhealthy: 'danger',
|
||||
Unknown: 'mutedLite',
|
||||
};
|
||||
|
||||
export function useResourceRows(resources: GenericResource[]): ResourceRow[] {
|
||||
return useMemo(() => getResourceRows(resources), [resources]);
|
||||
}
|
||||
|
||||
function getResourceRows(resources: GenericResource[]): ResourceRow[] {
|
||||
return resources.map(getResourceRow);
|
||||
}
|
||||
|
||||
function getResourceRow(resource: GenericResource): ResourceRow {
|
||||
const {
|
||||
reason = '',
|
||||
status = '',
|
||||
message = '',
|
||||
} = resource.status.healthSummary || {};
|
||||
|
||||
return {
|
||||
id: `${resource.kind}/${resource.metadata.name}/${resource.metadata.namespace}`,
|
||||
name: {
|
||||
label: resource.metadata.name,
|
||||
link: getResourceLink(resource),
|
||||
},
|
||||
resourceType: resource.kind ?? '-',
|
||||
describe: {
|
||||
name: resource.metadata.name,
|
||||
namespace: resource.metadata.namespace,
|
||||
resourceType: resource.kind,
|
||||
},
|
||||
status: {
|
||||
label: reason ?? 'Unknown',
|
||||
type: statusToColorMap[status] ?? 'default',
|
||||
},
|
||||
statusMessage: message ?? '-',
|
||||
};
|
||||
}
|
||||
|
||||
function getResourceLink(resource: GenericResource): ResourceLink | null {
|
||||
const { namespace, name } = resource.metadata;
|
||||
|
||||
const to = kindToUrlMap[resource.kind as keyof typeof kindToUrlMap];
|
||||
|
||||
// If the resource kind is not supported, return null
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the resource is not namespaced, return the link to the resource with the name only
|
||||
if (!namespace) {
|
||||
return {
|
||||
to,
|
||||
params: {
|
||||
name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// If the resource is namespaced, return the link to the resource with the namespace and name
|
||||
return {
|
||||
to,
|
||||
params: {
|
||||
namespace,
|
||||
name,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
import { CodeEditor } from '@@/CodeEditor';
|
||||
|
||||
import { Values } from '../../types';
|
||||
|
||||
interface Props {
|
||||
values?: Values;
|
||||
isUserSupplied: boolean;
|
||||
setIsUserSupplied: (isUserSupplied: boolean) => void;
|
||||
}
|
||||
|
||||
const noValuesMessage = 'No values found';
|
||||
|
||||
export function ValuesDetails({
|
||||
values,
|
||||
isUserSupplied,
|
||||
setIsUserSupplied,
|
||||
}: 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"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
|
@ -2,55 +2,33 @@ import { useQuery } from '@tanstack/react-query';
|
|||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import PortainerError from 'Portainer/error';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
interface HelmRelease {
|
||||
name: string;
|
||||
chart: string;
|
||||
app_version: string;
|
||||
}
|
||||
/**
|
||||
* List all helm releases based on passed in options
|
||||
* @param environmentId - Environment ID
|
||||
* @param options - Options for filtering releases
|
||||
* @returns List of helm releases
|
||||
*/
|
||||
export async function listReleases(
|
||||
environmentId: EnvironmentId,
|
||||
options: {
|
||||
namespace?: string;
|
||||
filter?: string;
|
||||
selector?: string;
|
||||
output?: string;
|
||||
} = {}
|
||||
): Promise<HelmRelease[]> {
|
||||
try {
|
||||
const { namespace, filter, selector, output } = options;
|
||||
const url = `endpoints/${environmentId}/kubernetes/helm`;
|
||||
const { data } = await axios.get<HelmRelease[]>(url, {
|
||||
params: { namespace, filter, selector, output },
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve release list');
|
||||
}
|
||||
}
|
||||
import { HelmRelease } from '../../types';
|
||||
|
||||
/**
|
||||
* React hook to fetch a specific Helm release
|
||||
*/
|
||||
export function useHelmRelease(
|
||||
export function useHelmRelease<T = HelmRelease>(
|
||||
environmentId: EnvironmentId,
|
||||
name: string,
|
||||
namespace: string
|
||||
namespace: string,
|
||||
options: {
|
||||
select?: (data: HelmRelease) => T;
|
||||
showResources?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
return useQuery(
|
||||
[environmentId, 'helm', namespace, name],
|
||||
() => getHelmRelease(environmentId, name, namespace),
|
||||
[environmentId, 'helm', 'releases', namespace, name, options.showResources],
|
||||
() =>
|
||||
getHelmRelease(environmentId, name, {
|
||||
namespace,
|
||||
showResources: options.showResources,
|
||||
}),
|
||||
{
|
||||
enabled: !!environmentId,
|
||||
enabled: !!environmentId && !!name && !!namespace,
|
||||
...withGlobalError('Unable to retrieve helm application details'),
|
||||
select: options.select,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -61,23 +39,20 @@ export function useHelmRelease(
|
|||
async function getHelmRelease(
|
||||
environmentId: EnvironmentId,
|
||||
name: string,
|
||||
namespace: string
|
||||
): Promise<HelmRelease> {
|
||||
params: {
|
||||
namespace: string;
|
||||
showResources?: boolean;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const releases = await listReleases(environmentId, {
|
||||
filter: `^${name}$`,
|
||||
namespace,
|
||||
});
|
||||
|
||||
if (releases.length > 0) {
|
||||
return releases[0];
|
||||
}
|
||||
|
||||
throw new PortainerError(`Release ${name} not found`);
|
||||
} catch (err) {
|
||||
throw new PortainerError(
|
||||
'Unable to retrieve helm application details',
|
||||
err as Error
|
||||
const { data } = await axios.get<HelmRelease>(
|
||||
`endpoints/${environmentId}/kubernetes/helm/${name}`,
|
||||
{
|
||||
params,
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Unable to retrieve helm application details');
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue