1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +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

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