1
0
Fork 0
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:
Ali 2025-04-10 16:08:24 +12:00 committed by GitHub
parent 46eddbe7b9
commit 0ca9321db1
57 changed files with 2635 additions and 222 deletions

View file

@ -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();
});
});

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View 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';
}
}

View file

@ -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
/>
);
}

View file

@ -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>;
}

View file

@ -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)}
/>
);
}

View file

@ -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
);
});
});

View file

@ -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>
);
}

View file

@ -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');
});
});

View file

@ -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>
);
}

View file

@ -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)}
/>
)}
</>
);
}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { ResourceRow } from '../types';
export const columnHelper = createColumnHelper<ResourceRow>();

View file

@ -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];

View file

@ -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;
}

View file

@ -0,0 +1,6 @@
import { columnHelper } from './helper';
export const resourceType = columnHelper.accessor((row) => row.resourceType, {
header: 'Resource type',
id: 'resourceType',
});

View file

@ -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>;
}

View file

@ -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>
),
});

View file

@ -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'),
}
);
}

View file

@ -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;
};

View file

@ -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,
},
};
}

View file

@ -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>
);
}

View file

@ -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');
}
}