1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

feat(helm): helm actions [r8s-259] (#715)

Co-authored-by: James Player <james.player@portainer.io>
Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
Ali 2025-05-13 22:15:04 +12:00 committed by GitHub
parent dfa32b6755
commit 4ee349bd6b
117 changed files with 4161 additions and 696 deletions

View file

@ -0,0 +1,193 @@
import { fireEvent, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { DiffControl, DiffViewMode } from './DiffControl';
// Create a mock for useDebounce that directly passes the setter function
vi.mock('@/react/hooks/useDebounce', () => ({
useDebounce: (initialValue: number, setter: (value: number) => void) =>
// Return the initial value and a function that directly calls the setter
[initialValue, setter],
}));
function renderComponent({
selectedRevisionNumber = 5,
latestRevisionNumber = 10,
compareRevisionNumber = 4,
setCompareRevisionNumber = vi.fn(),
earliestRevisionNumber = 1,
diffViewMode = 'view' as DiffViewMode,
setDiffViewMode = vi.fn(),
isUserSupplied = false,
setIsUserSupplied = vi.fn(),
showUserSuppliedCheckbox = false,
} = {}) {
return render(
<DiffControl
selectedRevisionNumber={selectedRevisionNumber}
latestRevisionNumber={latestRevisionNumber}
compareRevisionNumber={compareRevisionNumber}
setCompareRevisionNumber={setCompareRevisionNumber}
earliestRevisionNumber={earliestRevisionNumber}
diffViewMode={diffViewMode}
setDiffViewMode={setDiffViewMode}
isUserSupplied={isUserSupplied}
setIsUserSupplied={setIsUserSupplied}
showUserSuppliedCheckbox={showUserSuppliedCheckbox}
/>
);
}
describe('DiffControl', () => {
it('should only render the user supplied checkbox when latestRevisionNumber is 1 and showUserSuppliedCheckbox is true', () => {
const { queryByLabelText } = renderComponent({
latestRevisionNumber: 1,
showUserSuppliedCheckbox: true,
setIsUserSupplied: vi.fn(),
});
expect(queryByLabelText('View')).toBeNull();
expect(queryByLabelText('Diff with previous')).toBeNull();
expect(queryByLabelText('Diff with specific revision:')).toBeNull();
expect(queryByLabelText('User defined only')).toBeInTheDocument();
});
it('should not render any controls when latestRevisionNumber is 1 and showUserSuppliedCheckbox is false', () => {
const { queryByLabelText } = renderComponent({
latestRevisionNumber: 1,
showUserSuppliedCheckbox: false,
});
expect(queryByLabelText('Diff with previous')).toBeNull();
expect(queryByLabelText('Diff with specific revision:')).toBeNull();
expect(queryByLabelText('View')).toBeNull();
expect(queryByLabelText('User defined only')).toBeNull();
});
it('should render view option', () => {
const { getByLabelText } = renderComponent();
expect(getByLabelText('View')).toBeInTheDocument();
});
it('should render "Diff with previous" option when earliestRevisionNumber < selectedRevisionNumber', () => {
const { getByLabelText } = renderComponent({
earliestRevisionNumber: 3,
selectedRevisionNumber: 5,
});
expect(getByLabelText('Diff with previous')).toBeInTheDocument();
});
it('should render "Diff with previous" option as disabled when earliestRevisionNumber >= selectedRevisionNumber', () => {
const { getByLabelText } = renderComponent({
earliestRevisionNumber: 5,
selectedRevisionNumber: 5,
});
expect(getByLabelText('View')).toBeInTheDocument();
expect(getByLabelText('Diff with specific revision:')).toBeInTheDocument();
// 'Diff with previous' should exist and be disabled
const diffWithPreviousOption = getByLabelText('Diff with previous');
expect(diffWithPreviousOption).toBeInTheDocument();
expect(diffWithPreviousOption).toBeDisabled();
});
it('should render "Diff with specific revision" option', () => {
const { getByLabelText } = renderComponent();
expect(getByLabelText('Diff with specific revision:')).toBeInTheDocument();
});
it('should render user supplied checkbox when showUserSuppliedCheckbox is true', () => {
const { getByLabelText } = renderComponent({
showUserSuppliedCheckbox: true,
});
expect(getByLabelText('User defined only')).toBeInTheDocument();
});
it('should not render user supplied checkbox when showUserSuppliedCheckbox is false', () => {
const { queryByLabelText } = renderComponent({
showUserSuppliedCheckbox: false,
});
expect(queryByLabelText('User defined only')).not.toBeInTheDocument();
});
it('should call setDiffViewMode when a radio option is selected', async () => {
const user = userEvent.setup();
const setDiffViewMode = vi.fn();
const { getByLabelText } = renderComponent({
setDiffViewMode,
diffViewMode: 'view',
});
await user.click(getByLabelText('Diff with specific revision:'));
expect(setDiffViewMode).toHaveBeenCalledWith('specific');
});
it('should call setIsUserSupplied when checkbox is clicked', async () => {
const user = userEvent.setup();
const setIsUserSupplied = vi.fn();
const { getByLabelText } = renderComponent({
setIsUserSupplied,
isUserSupplied: false,
showUserSuppliedCheckbox: true,
});
await user.click(getByLabelText('User defined only'));
expect(setIsUserSupplied).toHaveBeenCalledWith(true);
});
});
describe('DiffWithSpecificRevision', () => {
it('should display input with compareRevisionNumber value when not NaN', () => {
const compareRevisionNumber = 3;
const { getByRole } = renderComponent({
diffViewMode: 'specific',
compareRevisionNumber,
});
const input = getByRole('spinbutton');
expect(input).toHaveValue(compareRevisionNumber);
});
it('should handle input values and constraints properly', () => {
const setCompareRevisionNumber = vi.fn();
const earliestRevisionNumber = 2;
const latestRevisionNumber = 10;
const { getByRole } = renderComponent({
diffViewMode: 'specific',
earliestRevisionNumber,
latestRevisionNumber,
setCompareRevisionNumber,
compareRevisionNumber: 4,
});
// Check that input has the right min/max attributes
const input = getByRole('spinbutton');
expect(input).toHaveAttribute('min', earliestRevisionNumber.toString());
expect(input).toHaveAttribute('max', latestRevisionNumber.toString());
fireEvent.change(input, { target: { valueAsNumber: 11 } });
expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(
latestRevisionNumber
);
fireEvent.change(input, { target: { valueAsNumber: 1 } });
expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(
earliestRevisionNumber
);
fireEvent.change(input, { target: { valueAsNumber: 5 } });
expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(5);
});
it('should handle NaN values in the input as empty string', () => {
const { getByRole } = renderComponent({
diffViewMode: 'specific',
compareRevisionNumber: NaN,
});
const input = getByRole('spinbutton') as HTMLInputElement;
expect(input.value).toBe('');
});
});

View file

@ -0,0 +1,146 @@
import { ChangeEvent } from 'react';
import { useDebounce } from '@/react/hooks/useDebounce';
import { RadioGroup, RadioGroupOption } from '@@/RadioGroup/RadioGroup';
import { Input } from '@@/form-components/Input';
import { Checkbox } from '@@/form-components/Checkbox';
import {
LatestRevisionNumber,
EarliestRevisionNumber,
CompareRevisionNumber,
SelectedRevisionNumber,
} from './types';
export type DiffViewMode = 'view' | 'previous' | 'specific';
type Props = {
selectedRevisionNumber: SelectedRevisionNumber;
latestRevisionNumber: LatestRevisionNumber;
compareRevisionNumber: CompareRevisionNumber;
setCompareRevisionNumber: (
compareRevisionNumber: CompareRevisionNumber
) => void;
earliestRevisionNumber: EarliestRevisionNumber;
diffViewMode: DiffViewMode;
setDiffViewMode: (diffViewMode: DiffViewMode) => void;
isUserSupplied?: boolean;
setIsUserSupplied?: (isUserSupplied: boolean) => void;
showUserSuppliedCheckbox?: boolean;
};
export function DiffControl({
selectedRevisionNumber,
latestRevisionNumber,
compareRevisionNumber,
setCompareRevisionNumber,
earliestRevisionNumber,
diffViewMode,
setDiffViewMode,
isUserSupplied,
setIsUserSupplied,
showUserSuppliedCheckbox,
}: Props) {
// If there is a different version to compare, show view option radio group
const showViewOptions = latestRevisionNumber > earliestRevisionNumber;
// to show the previous option, the earliest revision number available must be less than the selected revision number. (compare is still allowed, because we can still compare with a later revision)
const disabledPreviousOption =
earliestRevisionNumber >= selectedRevisionNumber;
const options: Array<RadioGroupOption<DiffViewMode>> = [
{ label: 'View', value: 'view' },
{
label: 'Diff with previous',
value: 'previous',
disabled: disabledPreviousOption,
},
{
label: (
<DiffWithSpecificRevision
latestRevisionNumber={latestRevisionNumber}
earliestRevisionNumber={earliestRevisionNumber}
compareRevisionNumber={compareRevisionNumber}
setCompareRevisionNumber={setCompareRevisionNumber}
/>
),
value: 'specific',
},
];
return (
<div className="flex flex-wrap gap-x-16 gap-y-1 items-center">
{showViewOptions && (
<RadioGroup
options={options}
selectedOption={diffViewMode}
name="diffControl"
onOptionChange={setDiffViewMode}
groupClassName="inline-flex flex-wrap gap-x-16 gap-y-1"
itemClassName="control-label !p-0 text-left font-normal"
/>
)}
{!!showUserSuppliedCheckbox && !!setIsUserSupplied && (
<Checkbox
label="User defined only"
id="values-details-user-supplied"
checked={isUserSupplied}
onChange={() => setIsUserSupplied(!isUserSupplied)}
data-cy="values-details-user-supplied"
className="font-normal control-label"
bold={false}
/>
)}
</div>
);
}
function DiffWithSpecificRevision({
latestRevisionNumber,
earliestRevisionNumber,
compareRevisionNumber,
setCompareRevisionNumber,
}: {
latestRevisionNumber: LatestRevisionNumber;
earliestRevisionNumber: EarliestRevisionNumber;
compareRevisionNumber: CompareRevisionNumber;
setCompareRevisionNumber: (
compareRevisionNumber: CompareRevisionNumber
) => void;
}) {
// the revision number is debounced to avoid too many requests to the backend
const [
debouncedSetCompareRevisionNumber,
setDebouncedSetCompareRevisionNumber,
] = useDebounce(compareRevisionNumber, setCompareRevisionNumber, 500);
return (
<>
<span>Diff with specific revision:</span>
<Input
type="number"
min={earliestRevisionNumber}
max={latestRevisionNumber}
value={debouncedSetCompareRevisionNumber}
onChange={handleSpecificRevisionChange}
className="w-20 ml-2"
data-cy="revision-specific-input"
/>
</>
);
function handleSpecificRevisionChange(e: ChangeEvent<HTMLInputElement>) {
const inputNumber = e.target.valueAsNumber;
// handle out of range values
if (inputNumber > latestRevisionNumber) {
setCompareRevisionNumber(latestRevisionNumber);
return;
}
if (inputNumber < earliestRevisionNumber) {
setCompareRevisionNumber(earliestRevisionNumber);
return;
}
setDebouncedSetCompareRevisionNumber(inputNumber);
}
}

View file

@ -0,0 +1,55 @@
import { AutomationTestingProps } from '@/types';
import { DiffViewer } from '@@/CodeEditor/DiffViewer';
import { Loading } from '@@/Widget';
import { Alert } from '@@/Alert';
import { CompareRevisionNumberFetched, SelectedRevisionNumber } from './types';
interface Props extends AutomationTestingProps {
isCompareReleaseLoading: boolean;
isCompareReleaseError: boolean;
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
selectedRevisionNumber: SelectedRevisionNumber;
newText: string;
originalText: string;
id: string;
}
export function DiffViewSection({
isCompareReleaseLoading,
isCompareReleaseError,
compareRevisionNumberFetched,
selectedRevisionNumber,
newText,
originalText,
id,
'data-cy': dataCy,
}: Props) {
if (isCompareReleaseLoading) {
return <Loading />;
}
if (isCompareReleaseError) {
return <Alert color="error">Error loading compare values</Alert>;
}
return (
<DiffViewer
newCode={newText}
originalCode={originalText}
id={id}
data-cy={dataCy}
placeholder="No values found"
fileNames={{
original: compareRevisionNumberFetched
? `Revision #${compareRevisionNumberFetched}`
: 'No revision selected',
modified: `Revision #${selectedRevisionNumber}`,
}}
className="mt-2"
type="yaml"
height="60vh"
/>
);
}

View file

@ -0,0 +1,242 @@
import { render, screen, waitFor } from '@testing-library/react';
import { HttpResponse } from 'msw';
import { Event, EventList } from 'kubernetes-types/core/v1';
import { server, http } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
import { GenericResource } from '../../types';
import {
HelmEventsDatatable,
filterRelatedEvents,
} from './HelmEventsDatatable';
const mockUseEnvironmentId = vi.fn();
mockLocalizeDate();
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => mockUseEnvironmentId(),
}));
const testResources: GenericResource[] = [
{
kind: 'Deployment',
status: {
healthSummary: {
status: 'Healthy',
reason: 'Running',
message: 'All replicas are ready',
},
},
metadata: {
name: 'test-deployment',
namespace: 'default',
uid: 'test-deployment-uid',
},
},
{
kind: 'Service',
status: {
healthSummary: {
status: 'Healthy',
reason: 'Available',
message: 'Service is available',
},
},
metadata: {
name: 'test-service',
namespace: 'default',
uid: 'test-service-uid',
},
},
];
const mockEventsResponse: EventList = {
kind: 'EventList',
apiVersion: 'v1',
metadata: {
resourceVersion: '12345',
},
items: [
{
metadata: {
name: 'test-deployment-123456',
namespace: 'default',
uid: 'event-uid-1',
resourceVersion: '1000',
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: {
kind: 'Deployment',
namespace: 'default',
name: 'test-deployment',
uid: 'test-deployment-uid',
apiVersion: 'apps/v1',
resourceVersion: '2000',
},
reason: 'ScalingReplicaSet',
message: 'Scaled up replica set test-deployment-abc123 to 1',
source: {
component: 'deployment-controller',
},
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1,
type: 'Normal',
reportingComponent: 'deployment-controller',
reportingInstance: '',
},
{
metadata: {
name: 'test-service-123456',
namespace: 'default',
uid: 'event-uid-2',
resourceVersion: '1001',
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: {
kind: 'Service',
namespace: 'default',
name: 'test-service',
uid: 'test-service-uid',
apiVersion: 'v1',
resourceVersion: '2001',
},
reason: 'CreatedLoadBalancer',
message: 'Created load balancer',
source: {
component: 'service-controller',
},
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1,
type: 'Normal',
reportingComponent: 'service-controller',
reportingInstance: '',
},
],
};
const mixedEventsResponse: EventList = {
kind: 'EventList',
apiVersion: 'v1',
metadata: {
resourceVersion: '12345',
},
items: [
{
metadata: {
name: 'test-deployment-123456',
namespace: 'default',
uid: 'event-uid-1',
resourceVersion: '1000',
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: {
kind: 'Deployment',
namespace: 'default',
name: 'test-deployment',
uid: 'test-deployment-uid', // This matches a resource UID
apiVersion: 'apps/v1',
resourceVersion: '2000',
},
reason: 'ScalingReplicaSet',
message: 'Scaled up replica set test-deployment-abc123 to 1',
source: {
component: 'deployment-controller',
},
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1,
type: 'Normal',
reportingComponent: 'deployment-controller',
reportingInstance: '',
},
{
metadata: {
name: 'unrelated-pod-123456',
namespace: 'default',
uid: 'event-uid-3',
resourceVersion: '1002',
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: {
kind: 'Pod',
namespace: 'default',
name: 'unrelated-pod',
uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs
apiVersion: 'v1',
resourceVersion: '2002',
},
reason: 'Scheduled',
message: 'Successfully assigned unrelated-pod to node',
source: {
component: 'default-scheduler',
},
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1,
reportingComponent: 'scheduler',
reportingInstance: '',
},
],
};
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
mockUseEnvironmentId.mockReturnValue(3);
const HelmEventsDatatableWithProviders = withTestQueryProvider(
withUserProvider(withTestRouter(HelmEventsDatatable), user)
);
return render(
<HelmEventsDatatableWithProviders
namespace="default"
releaseResources={testResources}
/>
);
}
describe('HelmEventsDatatable', () => {
beforeEach(() => {
server.use(
http.get(
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
() => HttpResponse.json(mockEventsResponse)
)
);
});
it('should render events datatable with correct title', async () => {
renderComponent();
await waitFor(() => {
expect(
screen.getByText('Events reflect the latest revision only.')
).toBeInTheDocument();
});
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('should correctly filter related events using the filterRelatedEvents function', () => {
const filteredEvents = filterRelatedEvents(
mixedEventsResponse.items as Event[],
testResources
);
expect(filteredEvents.length).toBe(1);
expect(filteredEvents[0].involvedObject.uid).toBe('test-deployment-uid');
const unrelatedEvents = filteredEvents.filter(
(e) => e.involvedObject.uid === 'unrelated-pod-uid'
);
expect(unrelatedEvents.length).toBe(0);
});
});

View file

@ -0,0 +1,77 @@
import { compact } from 'lodash';
import { Event } from 'kubernetes-types/core/v1';
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { EventsDatatable } from '@/react/kubernetes/components/EventsDatatable';
import { useEvents } from '@/react/kubernetes/queries/useEvents';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useTableState } from '@@/datatables/useTableState';
import { Widget } from '@@/Widget';
import { TextTip } from '@@/Tip/TextTip';
import { GenericResource } from '../../types';
export const storageKey = 'k8sHelmEventsDatatable';
export const settingsStore = createStore(storageKey, {
id: 'Date',
desc: true,
});
export function HelmEventsDatatable({
namespace,
releaseResources,
}: {
namespace: string;
releaseResources: GenericResource[];
}) {
const environmentId = useEnvironmentId();
const tableState = useTableState(settingsStore, storageKey);
const eventsQuery = useEvents(environmentId, {
namespace,
queryOptions: {
autoRefreshRate: tableState.autoRefreshRate * 1000,
select: (data) => filterRelatedEvents(data, releaseResources),
},
});
return (
<Widget>
<EventsDatatable
dataset={eventsQuery.data || []}
title={
<TextTip inline color="blue" className="!text-xs">
Events reflect the latest revision only.
</TextTip>
}
titleIcon={null}
tableState={tableState}
isLoading={eventsQuery.isInitialLoading}
data-cy="k8sAppDetail-eventsTable"
// no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
noWidget
/>
</Widget>
);
}
export function useHelmEventsTableState() {
return useTableState(settingsStore, storageKey);
}
export function filterRelatedEvents(
events: Event[],
resources: GenericResource[]
) {
const relatedUids = getReleaseUids(resources);
const relatedUidsSet = new Set(relatedUids);
return events.filter(
(event) =>
event.involvedObject.uid && relatedUidsSet.has(event.involvedObject.uid)
);
}
function getReleaseUids(resources: GenericResource[]) {
return compact(resources.map((resource) => resource.metadata.uid));
}

View file

@ -1,18 +1,58 @@
import { ReactNode } from 'react';
import { CodeEditor } from '@@/CodeEditor';
import { DiffViewMode } from './DiffControl';
import { DiffViewSection } from './DiffViewSection';
import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
type Props = {
manifest: string;
selectedRevisionNumber: SelectedRevisionNumber;
diffViewMode: DiffViewMode;
compareManifest?: string;
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
isCompareReleaseLoading: boolean;
isCompareReleaseError: boolean;
diffControl: ReactNode;
};
export function ManifestDetails({ manifest }: Props) {
export function ManifestDetails({
manifest,
selectedRevisionNumber,
diffViewMode,
compareManifest,
compareRevisionNumberFetched,
isCompareReleaseLoading,
isCompareReleaseError,
diffControl,
}: Props) {
return (
<CodeEditor
id="helm-manifest"
type="yaml"
data-cy="helm-manifest"
value={manifest}
height="600px"
readonly
/>
<>
{diffControl}
{diffViewMode === 'view' ? (
<CodeEditor
id="helm-manifest"
type="yaml"
data-cy="helm-manifest"
value={manifest}
readonly
fileName={`Revision #${selectedRevisionNumber}`}
placeholder="No manifest found"
height="60vh"
/>
) : (
<DiffViewSection
isCompareReleaseLoading={isCompareReleaseLoading}
isCompareReleaseError={isCompareReleaseError}
compareRevisionNumberFetched={compareRevisionNumberFetched}
selectedRevisionNumber={selectedRevisionNumber}
newText={manifest}
originalText={compareManifest ?? ''}
id="helm-manifest-diff-viewer"
data-cy="helm-manifest-diff-viewer"
/>
)}
</>
);
}

View file

@ -1,9 +1,48 @@
import Markdown from 'markdown-to-jsx';
import { ReactNode } from 'react';
import { DiffViewMode } from './DiffControl';
import { DiffViewSection } from './DiffViewSection';
import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
type Props = {
notes: string;
selectedRevisionNumber: SelectedRevisionNumber;
diffViewMode: DiffViewMode;
compareNotes?: string;
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
isCompareReleaseLoading: boolean;
isCompareReleaseError: boolean;
diffControl: ReactNode;
};
export function NotesDetails({ notes }: Props) {
return <Markdown className="list-inside mt-6">{notes}</Markdown>;
export function NotesDetails({
notes,
selectedRevisionNumber,
diffViewMode,
compareNotes,
compareRevisionNumberFetched,
isCompareReleaseLoading,
isCompareReleaseError,
diffControl,
}: Props) {
return (
<>
{diffControl}
{diffViewMode === 'view' ? (
<Markdown className="list-inside mt-6">{notes}</Markdown>
) : (
<DiffViewSection
isCompareReleaseLoading={isCompareReleaseLoading}
isCompareReleaseError={isCompareReleaseError}
compareRevisionNumberFetched={compareRevisionNumberFetched}
selectedRevisionNumber={selectedRevisionNumber}
newText={notes}
originalText={compareNotes ?? ''}
id="helm-notes-diff-viewer"
data-cy="helm-notes-diff-viewer"
/>
)}
</>
);
}

View file

@ -1,32 +1,184 @@
import { useState } from 'react';
import { compact } from 'lodash';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { AlertTriangle } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useEvents } from '@/react/kubernetes/queries/useEvents';
import { NavTabs, Option } from '@@/NavTabs';
import { Badge } from '@@/Badge';
import { Icon } from '@@/Icon';
import { HelmRelease } from '../../types';
import { useHelmHistory } from '../queries/useHelmHistory';
import { ManifestDetails } from './ManifestDetails';
import { NotesDetails } from './NotesDetails';
import { ValuesDetails } from './ValuesDetails';
import { ResourcesTable } from './ResourcesTable/ResourcesTable';
import { DiffControl, DiffViewMode } from './DiffControl';
import { useHelmReleaseToCompare } from './useHelmReleaseToCompare';
import {
filterRelatedEvents,
HelmEventsDatatable,
useHelmEventsTableState,
} from './HelmEventsDatatable';
type Props = {
release: HelmRelease;
selectedRevision: number;
};
type Tab = 'values' | 'notes' | 'manifest' | 'resources';
type Tab = 'values' | 'notes' | 'manifest' | 'resources' | 'events';
export function ReleaseTabs({ release, selectedRevision }: Props) {
const {
params: { tab },
} = useCurrentStateAndParams();
const router = useRouter();
const environmentId = useEnvironmentId();
// state is here so that the state isn't lost when the tab changes
const [isUserSupplied, setIsUserSupplied] = useState(true);
// start with NaN so that the input is empty (see <Input /> for more details)
const [selectedCompareRevisionNumber, setSelectedCompareRevisionNumber] =
useState(NaN);
const [diffViewMode, setDiffViewMode] = useState<DiffViewMode>('view');
const historyQuery = useHelmHistory(
environmentId,
release.name,
release.namespace ?? ''
);
const earliestRevisionNumber =
historyQuery.data?.[historyQuery.data.length - 1]?.version ??
release.version ??
1;
const latestRevisionNumber =
historyQuery.data?.[0]?.version ?? release.version ?? 1;
const { compareRelease, isCompareReleaseLoading, isCompareReleaseError } =
useHelmReleaseToCompare(
release,
earliestRevisionNumber,
latestRevisionNumber,
diffViewMode,
selectedRevision,
selectedCompareRevisionNumber
);
const { autoRefreshRate } = useHelmEventsTableState();
const { data: eventWarningCount } = useEvents(environmentId, {
namespace: release.namespace ?? '',
queryOptions: {
autoRefreshRate: autoRefreshRate * 1000,
select: (data) => {
const relatedEvents = filterRelatedEvents(
data,
release.info?.resources ?? []
);
return relatedEvents.filter((e) => e.type === 'Warning').length;
},
},
});
return (
<NavTabs<Tab>
onSelect={setTab}
selectedId={parseValidTab(tab, !!release.info?.notes)}
type="pills"
justified
options={helmTabs(
release,
isUserSupplied,
setIsUserSupplied,
earliestRevisionNumber,
latestRevisionNumber,
selectedRevision,
selectedCompareRevisionNumber,
setSelectedCompareRevisionNumber,
diffViewMode,
handleDiffViewChange,
isCompareReleaseLoading,
isCompareReleaseError,
eventWarningCount ?? 0,
compareRelease
)}
/>
);
function handleDiffViewChange(diffViewMode: DiffViewMode) {
setDiffViewMode(diffViewMode);
if (latestRevisionNumber === earliestRevisionNumber) {
return;
}
// if the input for compare revision number is NaN, set it to the previous revision number
if (
Number.isNaN(selectedCompareRevisionNumber) &&
diffViewMode === 'specific'
) {
if (selectedRevision > earliestRevisionNumber) {
setSelectedCompareRevisionNumber(selectedRevision - 1);
return;
}
// it could be useful to compare to the latest revision number if the selected revision number is the earliest revision number
setSelectedCompareRevisionNumber(latestRevisionNumber);
}
}
function setTab(tab: Tab) {
router.stateService.go('kubernetes.helm', {
tab,
});
}
}
function helmTabs(
release: HelmRelease,
isUserSupplied: boolean,
setIsUserSupplied: (isUserSupplied: boolean) => void
setIsUserSupplied: (isUserSupplied: boolean) => void,
earliestRevisionNumber: number,
latestRevisionNumber: number,
selectedRevisionNumber: number,
compareRevisionNumber: number,
setCompareRevisionNumber: (compareRevisionNumber: number) => void,
diffViewMode: DiffViewMode,
setDiffViewMode: (diffViewMode: DiffViewMode) => void,
isCompareReleaseLoading: boolean,
isCompareReleaseError: boolean,
eventWarningCount: number,
compareRelease?: HelmRelease
): Option<Tab>[] {
// as long as the latest revision number is greater than the earliest revision number, there are changes to compare
const showDiffControl = latestRevisionNumber > earliestRevisionNumber;
return compact([
{
label: 'Resources',
id: 'resources',
children: <ResourcesTable />,
},
{
label: (
<>
Events
{eventWarningCount >= 1 && (
<Badge type="warnSecondary">
<Icon icon={AlertTriangle} className="!mr-1" />
{eventWarningCount}
</Badge>
)}
</>
),
id: 'events',
children: (
<HelmEventsDatatable
namespace={release.namespace ?? ''}
releaseResources={release.info?.resources ?? []}
/>
),
},
{
label: 'Values',
id: 'values',
@ -34,35 +186,97 @@ function helmTabs(
<ValuesDetails
values={release.values}
isUserSupplied={isUserSupplied}
setIsUserSupplied={setIsUserSupplied}
selectedRevisionNumber={selectedRevisionNumber}
diffViewMode={diffViewMode}
compareValues={compareRelease?.values}
compareRevisionNumberFetched={compareRelease?.version}
isCompareReleaseLoading={isCompareReleaseLoading}
isCompareReleaseError={isCompareReleaseError}
diffControl={
<DiffControl
selectedRevisionNumber={selectedRevisionNumber}
latestRevisionNumber={latestRevisionNumber}
earliestRevisionNumber={earliestRevisionNumber}
compareRevisionNumber={compareRevisionNumber}
setCompareRevisionNumber={setCompareRevisionNumber}
diffViewMode={diffViewMode}
setDiffViewMode={setDiffViewMode}
isUserSupplied={isUserSupplied}
setIsUserSupplied={setIsUserSupplied}
showUserSuppliedCheckbox
/>
}
/>
),
},
{
label: 'Manifest',
id: 'manifest',
children: <ManifestDetails manifest={release.manifest} />,
children: (
<ManifestDetails
manifest={release.manifest}
selectedRevisionNumber={selectedRevisionNumber}
diffViewMode={diffViewMode}
compareManifest={compareRelease?.manifest}
compareRevisionNumberFetched={compareRelease?.version}
isCompareReleaseLoading={isCompareReleaseLoading}
isCompareReleaseError={isCompareReleaseError}
diffControl={
showDiffControl && (
<DiffControl
selectedRevisionNumber={selectedRevisionNumber}
latestRevisionNumber={latestRevisionNumber}
earliestRevisionNumber={earliestRevisionNumber}
compareRevisionNumber={compareRevisionNumber}
setCompareRevisionNumber={setCompareRevisionNumber}
diffViewMode={diffViewMode}
setDiffViewMode={setDiffViewMode}
/>
)
}
/>
),
},
!!release.info?.notes && {
label: 'Notes',
id: 'notes',
children: <NotesDetails notes={release.info.notes} />,
children: (
<NotesDetails
notes={release.info.notes}
selectedRevisionNumber={selectedRevisionNumber}
diffViewMode={diffViewMode}
compareNotes={compareRelease?.info?.notes}
compareRevisionNumberFetched={compareRelease?.version}
isCompareReleaseLoading={isCompareReleaseLoading}
isCompareReleaseError={isCompareReleaseError}
diffControl={
showDiffControl && (
<DiffControl
selectedRevisionNumber={selectedRevisionNumber}
latestRevisionNumber={latestRevisionNumber}
earliestRevisionNumber={earliestRevisionNumber}
compareRevisionNumber={compareRevisionNumber}
setCompareRevisionNumber={setCompareRevisionNumber}
diffViewMode={diffViewMode}
setDiffViewMode={setDiffViewMode}
/>
)
}
/>
),
},
]);
}
export function ReleaseTabs({ release }: Props) {
const [tab, setTab] = useState<Tab>('resources');
// state is here so that the state isn't lost when the tab changes
const [isUserSupplied, setIsUserSupplied] = useState(true);
return (
<NavTabs<Tab>
onSelect={setTab}
selectedId={tab}
type="pills"
justified
options={helmTabs(release, isUserSupplied, setIsUserSupplied)}
/>
);
function parseValidTab(tab: string, hasNotes: boolean): Tab {
if (
tab === 'values' ||
(tab === 'notes' && hasNotes) ||
tab === 'manifest' ||
tab === 'resources' ||
tab === 'events'
) {
return tab;
}
return 'resources';
}

View file

@ -6,7 +6,17 @@ import { DescribeModal } from './DescribeModal';
const mockUseDescribeResource = vi.fn();
vi.mock('yaml-schema', () => ({}));
// Mock the CodeEditor component instead of yaml-schema
vi.mock('@@/CodeEditor', () => ({
CodeEditor: ({
value,
'data-cy': dataCy,
}: {
value: string;
'data-cy'?: string;
}) => <div data-cy={dataCy}>{value}</div>,
}));
vi.mock('./queries/useDescribeResource', () => ({
useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args),
}));

View file

@ -11,6 +11,7 @@ import {
import { useTableState } from '@@/datatables/useTableState';
import { Widget } from '@@/Widget';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { TextTip } from '@@/Tip/TextTip';
import { useHelmRelease } from '../../queries/useHelmRelease';
@ -34,12 +35,14 @@ const settingsStore = createStore('helm-resources');
export function ResourcesTable() {
const environmentId = useEnvironmentId();
const { params } = useCurrentStateAndParams();
const { name, namespace } = params;
const { name, namespace, revision } = params;
const revisionNumber = revision ? parseInt(revision, 10) : undefined;
const tableState = useTableState(settingsStore, storageKey);
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
showResources: true,
refetchInterval: tableState.autoRefreshRate * 1000,
revision: revisionNumber,
});
const rows = useResourceRows(helmReleaseQuery.data?.info?.resources);
@ -48,11 +51,17 @@ export function ResourcesTable() {
<Datatable
// no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
noWidget
isLoading={helmReleaseQuery.isLoading}
dataset={rows}
columns={columns}
includeSearch
settingsManager={tableState}
emptyContentLabel="No resources found"
title={
<TextTip inline color="blue" className="!text-xs">
Resources reflect the latest revision only.
</TextTip>
}
disableSelect
getRowId={(row) => row.id}
data-cy="helm-resources-datatable"

View file

@ -14,5 +14,9 @@ export const status = columnHelper.accessor((row) => row.status.label, {
function Cell({ row }: CellContext<ResourceRow, string>) {
const { status } = row.original;
if (!status.label) {
return '-';
}
return <StatusBadge color={status.type}>{status.label}</StatusBadge>;
}

View file

@ -1,44 +1,74 @@
import { Checkbox } from '@@/form-components/Checkbox';
import { ReactNode } from 'react';
import { CodeEditor } from '@@/CodeEditor';
import { Values } from '../../types';
import { DiffViewMode } from './DiffControl';
import { DiffViewSection } from './DiffViewSection';
import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
interface Props {
values?: Values;
isUserSupplied: boolean;
setIsUserSupplied: (isUserSupplied: boolean) => void;
selectedRevisionNumber: SelectedRevisionNumber;
diffViewMode: DiffViewMode;
compareValues?: Values;
compareRevisionNumberFetched?: CompareRevisionNumberFetched;
isCompareReleaseLoading: boolean;
isCompareReleaseError: boolean;
diffControl: ReactNode;
}
const noValuesMessage = 'No values found';
export function ValuesDetails({
values,
isUserSupplied,
setIsUserSupplied,
selectedRevisionNumber,
diffViewMode,
compareValues,
compareRevisionNumberFetched,
isCompareReleaseLoading,
isCompareReleaseError,
diffControl,
}: Props) {
return (
<div className="relative">
{/* bring in line with the code editor copy button */}
<div className="absolute top-1 left-0">
<Checkbox
label="User defined only"
id="values-details-user-supplied"
checked={isUserSupplied}
onChange={() => setIsUserSupplied(!isUserSupplied)}
data-cy="values-details-user-supplied"
<>
{diffControl}
{diffViewMode === 'view' ? (
<CodeEditor
type="yaml"
id="values-details-code-editor"
data-cy="values-details-code-editor"
value={
isUserSupplied
? values?.userSuppliedValues ?? ''
: values?.computedValues ?? ''
}
readonly
fileName={`Revision #${selectedRevisionNumber}`}
placeholder="No values found"
height="60vh"
/>
</div>
<CodeEditor
type="yaml"
id="values-details-code-editor"
data-cy="values-details-code-editor"
value={
isUserSupplied
? values?.userSuppliedValues ?? noValuesMessage
: values?.computedValues ?? noValuesMessage
}
readonly
/>
</div>
) : (
<DiffViewSection
isCompareReleaseLoading={isCompareReleaseLoading}
isCompareReleaseError={isCompareReleaseError}
compareRevisionNumberFetched={compareRevisionNumberFetched}
selectedRevisionNumber={selectedRevisionNumber}
newText={
isUserSupplied
? values?.userSuppliedValues ?? ''
: values?.computedValues ?? ''
}
originalText={
isUserSupplied
? compareValues?.userSuppliedValues ?? ''
: compareValues?.computedValues ?? ''
}
id="values-details-diff-viewer"
data-cy="values-details-diff-viewer"
/>
)}
</>
);
}

View file

@ -0,0 +1,26 @@
// exporting as types here allows the JSDocs to be reused, improving readability
/**
* The revision number of the latest release.
*/
export type LatestRevisionNumber = number;
/**
* The revision number selected in the UI.
*/
export type SelectedRevisionNumber = number;
/**
* The revision number to compare with.
*/
export type CompareRevisionNumber = number;
/**
* The earliest revision number available for the chart.
*/
export type EarliestRevisionNumber = number;
/**
* The revision number that's being fetched (instead of the form state).
*/
export type CompareRevisionNumberFetched = number;

View file

@ -0,0 +1,60 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { HelmRelease } from '../../types';
import { useHelmRelease } from '../queries/useHelmRelease';
import { DiffViewMode } from './DiffControl';
/** useHelmReleaseToCompare is a hook that returns the release to compare to based on the diffViewMode, selectedRevisionNumber and selectedCompareRevisionNumber */
export function useHelmReleaseToCompare(
release: HelmRelease,
earliestRevisionNumber: number,
latestRevisionNumber: number,
diffViewMode: DiffViewMode,
selectedRevisionNumber: number,
selectedCompareRevisionNumber: number
) {
const environmentId = useEnvironmentId();
// the selectedCompareRevisionNumber is the number selected in the input field, but the compareRevisionNumber is the revision number of the release to compare to
const compareRevisionNumber = getCompareReleaseVersion(
diffViewMode,
selectedRevisionNumber,
selectedCompareRevisionNumber
);
const enabled =
compareRevisionNumber <= latestRevisionNumber &&
compareRevisionNumber >= earliestRevisionNumber;
// a 1 hour stale time is nice because past releases are not likely to change
const compareReleaseQuery = useHelmRelease(
environmentId,
release.name,
release.namespace ?? '',
{
showResources: false,
enabled,
staleTime: 60 * 60 * 1000,
revision: compareRevisionNumber,
}
);
return {
compareRelease: compareReleaseQuery.data,
isCompareReleaseLoading: compareReleaseQuery.isInitialLoading,
isCompareReleaseError: compareReleaseQuery.isError,
};
}
// getCompareReleaseVersion is a helper function that returns the revision number that should be fetched based on the diffViewMode, selectedRevisionNumber and selectedCompareRevisionNumber
function getCompareReleaseVersion(
diffViewMode: DiffViewMode,
selectedRevisionNumber: number,
selectedCompareRevisionNumber: number
) {
if (diffViewMode === 'previous') {
return selectedRevisionNumber - 1;
}
if (diffViewMode === 'specific') {
return selectedCompareRevisionNumber;
}
return selectedRevisionNumber;
}