Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a978f7e5f7 | |||
| 9d3653387d | |||
| e78cb388e3 | |||
| bbd1c8c662 | |||
| d54ee1280f | |||
| 68cfcfd0a4 | |||
| 27dc454271 |
@@ -46,18 +46,24 @@ var errChartNameInvalid = errors.New("invalid chart name. " +
|
|||||||
// @produce json
|
// @produce json
|
||||||
// @param id path int true "Environment(Endpoint) identifier"
|
// @param id path int true "Environment(Endpoint) identifier"
|
||||||
// @param payload body installChartPayload true "Chart details"
|
// @param payload body installChartPayload true "Chart details"
|
||||||
|
// @param dryRun query bool false "Dry run"
|
||||||
// @success 201 {object} release.Release "Created"
|
// @success 201 {object} release.Release "Created"
|
||||||
// @failure 401 "Unauthorized"
|
// @failure 401 "Unauthorized"
|
||||||
// @failure 404 "Environment(Endpoint) or ServiceAccount not found"
|
// @failure 404 "Environment(Endpoint) or ServiceAccount not found"
|
||||||
// @failure 500 "Server error"
|
// @failure 500 "Server error"
|
||||||
// @router /endpoints/{id}/kubernetes/helm [post]
|
// @router /endpoints/{id}/kubernetes/helm [post]
|
||||||
func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
dryRun, err := request.RetrieveBooleanQueryParameter(r, "dryRun", true)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.BadRequest("Invalid dryRun query parameter", err)
|
||||||
|
}
|
||||||
|
|
||||||
var payload installChartPayload
|
var payload installChartPayload
|
||||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||||
return httperror.BadRequest("Invalid Helm install payload", err)
|
return httperror.BadRequest("Invalid Helm install payload", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := handler.installChart(r, payload)
|
release, err := handler.installChart(r, payload, dryRun)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to install a chart", err)
|
return httperror.InternalServerError("Unable to install a chart", err)
|
||||||
}
|
}
|
||||||
@@ -94,7 +100,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*release.Release, error) {
|
func (handler *Handler) installChart(r *http.Request, p installChartPayload, dryRun bool) (*release.Release, error) {
|
||||||
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
||||||
if httperr != nil {
|
if httperr != nil {
|
||||||
return nil, httperr.Err
|
return nil, httperr.Err
|
||||||
@@ -107,6 +113,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
|
|||||||
Namespace: p.Namespace,
|
Namespace: p.Namespace,
|
||||||
Repo: p.Repo,
|
Repo: p.Repo,
|
||||||
Atomic: p.Atomic,
|
Atomic: p.Atomic,
|
||||||
|
DryRun: dryRun,
|
||||||
KubernetesClusterAccess: clusterAccess,
|
KubernetesClusterAccess: clusterAccess,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,13 +141,14 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
|
if !installOpts.DryRun {
|
||||||
if err != nil {
|
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil {
|
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return release, nil
|
return release, nil
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if csp {
|
if csp {
|
||||||
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud; frame-ancestors 'none';")
|
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud js.hsforms.net; frame-ancestors 'none';")
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|||||||
@@ -16,19 +16,19 @@
|
|||||||
<span class="input-group-btn" style="padding: 0px 5px">
|
<span class="input-group-btn" style="padding: 0px 5px">
|
||||||
<span style="margin: 0px 5px" authorization="DockerImagePush">
|
<span style="margin: 0px 5px" authorization="DockerImagePush">
|
||||||
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Push to registry" ng-click="pushTag(tag)">
|
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Push to registry" ng-click="pushTag(tag)">
|
||||||
<pr-icon icon="'upload'" class="text-white"></pr-icon>
|
<pr-icon icon="'upload'"></pr-icon>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="mx-1 my-0" authorization="DockerImageCreate">
|
<span class="mx-1 my-0" authorization="DockerImageCreate">
|
||||||
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Pull from registry" ng-click="pullTag(tag)">
|
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Pull from registry" ng-click="pullTag(tag)">
|
||||||
<pr-icon icon="'download'" class="text-white"></pr-icon>
|
<pr-icon icon="'download'"></pr-icon>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="mx-1 my-0" authorization="DockerImageDelete">
|
<span class="mx-1 my-0" authorization="DockerImageDelete">
|
||||||
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Remove tag" ng-click="removeTag(tag)">
|
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Remove tag" ng-click="removeTag(tag)">
|
||||||
<pr-icon icon="'trash-2'" class="text-white"></pr-icon>
|
<pr-icon icon="'trash-2'"></pr-icon>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class KubernetesApplicationLogsController {
|
|||||||
await this.getApplicationLogsAsync();
|
await this.getApplicationLogsAsync();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve application logs');
|
this.Notifications.error('Failure', err, 'Unable to retrieve application logs');
|
||||||
|
this.stopRepeater();
|
||||||
} finally {
|
} finally {
|
||||||
this.state.viewReady = true;
|
this.state.viewReady = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ class KubernetesStackLogsController {
|
|||||||
await this.getStackLogsAsync();
|
await this.getStackLogsAsync();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve stack logs');
|
this.Notifications.error('Failure', err, 'Unable to retrieve stack logs');
|
||||||
|
this.stopRepeater();
|
||||||
} finally {
|
} finally {
|
||||||
this.state.viewReady = true;
|
this.state.viewReady = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ test('should apply custom height', async () => {
|
|||||||
<CodeEditor {...defaultProps} height={customHeight} />
|
<CodeEditor {...defaultProps} height={customHeight} />
|
||||||
);
|
);
|
||||||
|
|
||||||
const editor = (await findByRole('textbox')).parentElement?.parentElement;
|
const editor = await findByRole('textbox');
|
||||||
expect(editor).toHaveStyle({ height: customHeight });
|
expect(editor).toHaveStyle({ height: customHeight });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function yamlLanguage(schema?: JSONSchema7) {
|
|||||||
syntaxHighlighting(oneDarkHighlightStyle),
|
syntaxHighlighting(oneDarkHighlightStyle),
|
||||||
// explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor
|
// explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
lintGutter(),
|
!!schema && lintGutter(),
|
||||||
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
|
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
|
||||||
// only show completions when a schema is provided
|
// only show completions when a schema is provided
|
||||||
!!schema &&
|
!!schema &&
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export { TableHeaderRow } from './TableHeaderRow';
|
|||||||
export { TableRow } from './TableRow';
|
export { TableRow } from './TableRow';
|
||||||
export { TableContent } from './TableContent';
|
export { TableContent } from './TableContent';
|
||||||
export { TableFooter } from './TableFooter';
|
export { TableFooter } from './TableFooter';
|
||||||
|
export { TableSettingsMenuAutoRefresh } from './TableSettingsMenuAutoRefresh';
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface Props {
|
|||||||
titleClassName?: string;
|
titleClassName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
htmlFor?: string;
|
htmlFor?: string;
|
||||||
|
setIsDefaultFolded?: (isDefaultFolded: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormSection({
|
export function FormSection({
|
||||||
@@ -23,6 +24,7 @@ export function FormSection({
|
|||||||
titleClassName,
|
titleClassName,
|
||||||
className,
|
className,
|
||||||
htmlFor = '',
|
htmlFor = '',
|
||||||
|
setIsDefaultFolded,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
|
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
|
||||||
const id = `foldingButton${title}`;
|
const id = `foldingButton${title}`;
|
||||||
@@ -39,7 +41,10 @@ export function FormSection({
|
|||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
data-cy={id}
|
data-cy={id}
|
||||||
id={id}
|
id={id}
|
||||||
onClick={() => setIsExpanded((isExpanded) => !isExpanded)}
|
onClick={() => {
|
||||||
|
setIsExpanded((isExpanded) => !isExpanded);
|
||||||
|
setIsDefaultFolded?.(isExpanded);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+33
-25
@@ -1,14 +1,18 @@
|
|||||||
import { Server } from 'lucide-react';
|
import { Server } from 'lucide-react';
|
||||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ContainerStatus, Pod } from 'kubernetes-types/core/v1';
|
import { Pod } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
import { IndexOptional } from '@/react/kubernetes/configs/types';
|
import { IndexOptional } from '@/react/kubernetes/configs/types';
|
||||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
import { useEnvironment } from '@/react/portainer/environments/queries';
|
import { useEnvironment } from '@/react/portainer/environments/queries';
|
||||||
|
|
||||||
import { Datatable } from '@@/datatables';
|
import {
|
||||||
|
Datatable,
|
||||||
|
TableSettingsMenu,
|
||||||
|
TableSettingsMenuAutoRefresh,
|
||||||
|
} from '@@/datatables';
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
|
|
||||||
import { useApplication } from '../../queries/useApplication';
|
import { useApplication } from '../../queries/useApplication';
|
||||||
@@ -16,6 +20,7 @@ import { useApplicationPods } from '../../queries/useApplicationPods';
|
|||||||
|
|
||||||
import { ContainerRowData } from './types';
|
import { ContainerRowData } from './types';
|
||||||
import { getColumns } from './columns';
|
import { getColumns } from './columns';
|
||||||
|
import { computeContainerStatus } from './computeContainerStatus';
|
||||||
|
|
||||||
const storageKey = 'k8sContainersDatatable';
|
const storageKey = 'k8sContainersDatatable';
|
||||||
const settingsStore = createStore(storageKey);
|
const settingsStore = createStore(storageKey);
|
||||||
@@ -36,13 +41,19 @@ export function ApplicationContainersDatatable() {
|
|||||||
environmentId,
|
environmentId,
|
||||||
namespace,
|
namespace,
|
||||||
name,
|
name,
|
||||||
resourceType
|
resourceType,
|
||||||
|
{
|
||||||
|
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const podsQuery = useApplicationPods(
|
const podsQuery = useApplicationPods(
|
||||||
environmentId,
|
environmentId,
|
||||||
namespace,
|
namespace,
|
||||||
name,
|
name,
|
||||||
applicationQuery.data
|
applicationQuery.data,
|
||||||
|
{
|
||||||
|
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const appContainers = useContainersRowData(podsQuery.data);
|
const appContainers = useContainersRowData(podsQuery.data);
|
||||||
|
|
||||||
@@ -61,6 +72,14 @@ export function ApplicationContainersDatatable() {
|
|||||||
getRowId={(row) => row.podName} // use pod name because it's unique (name is not unique)
|
getRowId={(row) => row.podName} // use pod name because it's unique (name is not unique)
|
||||||
disableSelect
|
disableSelect
|
||||||
data-cy="k8s-application-containers-datatable"
|
data-cy="k8s-application-containers-datatable"
|
||||||
|
renderTableSettings={() => (
|
||||||
|
<TableSettingsMenu>
|
||||||
|
<TableSettingsMenuAutoRefresh
|
||||||
|
value={tableState.autoRefreshRate}
|
||||||
|
onChange={(value) => tableState.setAutoRefreshRate(value)}
|
||||||
|
/>
|
||||||
|
</TableSettingsMenu>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -73,8 +92,14 @@ function useContainersRowData(pods?: Pod[]): ContainerRowData[] {
|
|||||||
() =>
|
() =>
|
||||||
pods?.flatMap((pod) => {
|
pods?.flatMap((pod) => {
|
||||||
const containers = [
|
const containers = [
|
||||||
...(pod.spec?.containers || []),
|
...(pod.spec?.containers?.map((c) => ({ ...c, isInit: false })) ||
|
||||||
...(pod.spec?.initContainers || []),
|
[]),
|
||||||
|
...(pod.spec?.initContainers?.map((c) => ({
|
||||||
|
...c,
|
||||||
|
isInit: true,
|
||||||
|
// https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/#sidecar-containers-and-pod-lifecycle
|
||||||
|
isSidecar: c.restartPolicy === 'Always',
|
||||||
|
})) || []),
|
||||||
];
|
];
|
||||||
return containers.map((container) => ({
|
return containers.map((container) => ({
|
||||||
...container,
|
...container,
|
||||||
@@ -84,7 +109,8 @@ function useContainersRowData(pods?: Pod[]): ContainerRowData[] {
|
|||||||
creationDate: pod.status?.startTime ?? '',
|
creationDate: pod.status?.startTime ?? '',
|
||||||
status: computeContainerStatus(
|
status: computeContainerStatus(
|
||||||
container.name,
|
container.name,
|
||||||
pod.status?.containerStatuses
|
pod.status?.containerStatuses,
|
||||||
|
pod.status?.initContainerStatuses
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}) || [],
|
}) || [],
|
||||||
@@ -92,21 +118,3 @@ function useContainersRowData(pods?: Pod[]): ContainerRowData[] {
|
|||||||
) || []
|
) || []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeContainerStatus(
|
|
||||||
containerName: string,
|
|
||||||
statuses?: ContainerStatus[]
|
|
||||||
) {
|
|
||||||
const status = statuses?.find((status) => status.name === containerName);
|
|
||||||
if (!status) {
|
|
||||||
return 'Terminated';
|
|
||||||
}
|
|
||||||
const { state } = status;
|
|
||||||
if (state?.waiting) {
|
|
||||||
return 'Waiting';
|
|
||||||
}
|
|
||||||
if (!state?.running) {
|
|
||||||
return 'Terminated';
|
|
||||||
}
|
|
||||||
return 'Running';
|
|
||||||
}
|
|
||||||
|
|||||||
+18
-15
@@ -13,27 +13,30 @@ export function getActions(isServerMetricsEnabled: boolean) {
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row: { original: container } }) => (
|
cell: ({ row: { original: container } }) => (
|
||||||
<div className="flex gap-x-2">
|
<div className="flex gap-x-2">
|
||||||
{container.status === 'Running' && isServerMetricsEnabled && (
|
{container.status.status.includes('Running') &&
|
||||||
|
isServerMetricsEnabled && (
|
||||||
|
<Link
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
to="kubernetes.applications.application.stats"
|
||||||
|
params={{ pod: container.podName, container: container.name }}
|
||||||
|
data-cy={`application-container-stats-${container.name}`}
|
||||||
|
>
|
||||||
|
<Icon icon={BarChart} />
|
||||||
|
Stats
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{container.status.hasLogs !== false && (
|
||||||
<Link
|
<Link
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
to="kubernetes.applications.application.stats"
|
to="kubernetes.applications.application.logs"
|
||||||
params={{ pod: container.podName, container: container.name }}
|
params={{ pod: container.podName, container: container.name }}
|
||||||
data-cy={`application-container-stats-${container.name}`}
|
data-cy={`application-container-logs-${container.name}`}
|
||||||
>
|
>
|
||||||
<Icon icon={BarChart} />
|
<Icon icon={FileText} />
|
||||||
Stats
|
Logs
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link
|
{container.status.status.includes('Running') && (
|
||||||
className="flex items-center gap-1"
|
|
||||||
to="kubernetes.applications.application.logs"
|
|
||||||
params={{ pod: container.podName, container: container.name }}
|
|
||||||
data-cy={`application-container-logs-${container.name}`}
|
|
||||||
>
|
|
||||||
<Icon icon={FileText} />
|
|
||||||
Logs
|
|
||||||
</Link>
|
|
||||||
{container.status === 'Running' && (
|
|
||||||
<Authorized authorizations="K8sApplicationConsoleRW">
|
<Authorized authorizations="K8sApplicationConsoleRW">
|
||||||
<Link
|
<Link
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
|
|||||||
+59
@@ -1,6 +1,65 @@
|
|||||||
|
import { Badge } from '@@/Badge';
|
||||||
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
|
import { ExternalLink } from '@@/ExternalLink';
|
||||||
|
|
||||||
|
import { ContainerRowData } from '../types';
|
||||||
|
|
||||||
import { columnHelper } from './helper';
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
export const name = columnHelper.accessor('name', {
|
export const name = columnHelper.accessor('name', {
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
id: 'name',
|
id: 'name',
|
||||||
|
cell: ({ row: { original: container } }) => (
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span>{container.name}</span>
|
||||||
|
<ContainerTypeBadge container={container} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function ContainerTypeBadge({ container }: { container: ContainerRowData }) {
|
||||||
|
if (container.isSidecar) {
|
||||||
|
return (
|
||||||
|
<Badge type="info">
|
||||||
|
Sidecar
|
||||||
|
<Tooltip
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<ExternalLink
|
||||||
|
to="https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/"
|
||||||
|
data-cy="sidecar-link"
|
||||||
|
>
|
||||||
|
Sidecar containers
|
||||||
|
</ExternalLink>{' '}
|
||||||
|
run continuously alongside the main application, starting before
|
||||||
|
other containers.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container.isInit) {
|
||||||
|
return (
|
||||||
|
<Badge type="info">
|
||||||
|
Init
|
||||||
|
<Tooltip
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<ExternalLink
|
||||||
|
to="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
|
||||||
|
data-cy="init-link"
|
||||||
|
>
|
||||||
|
Init containers
|
||||||
|
</ExternalLink>{' '}
|
||||||
|
run and complete before the main application containers start.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
+23
-15
@@ -1,6 +1,9 @@
|
|||||||
import { CellContext } from '@tanstack/react-table';
|
import { CellContext } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Badge, BadgeType } from '@@/Badge';
|
import { pluralize } from '@/react/common/string-utils';
|
||||||
|
|
||||||
|
import { Badge } from '@@/Badge';
|
||||||
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
|
|
||||||
import { ContainerRowData } from '../types';
|
import { ContainerRowData } from '../types';
|
||||||
|
|
||||||
@@ -11,19 +14,24 @@ export const status = columnHelper.accessor('status', {
|
|||||||
cell: StatusCell,
|
cell: StatusCell,
|
||||||
});
|
});
|
||||||
|
|
||||||
function StatusCell({ getValue }: CellContext<ContainerRowData, string>) {
|
function StatusCell({
|
||||||
return <Badge type={getContainerStatusType(getValue())}>{getValue()}</Badge>;
|
getValue,
|
||||||
}
|
}: CellContext<ContainerRowData, ContainerRowData['status']>) {
|
||||||
|
const statusData = getValue();
|
||||||
|
|
||||||
function getContainerStatusType(status: string): BadgeType {
|
return (
|
||||||
switch (status.toLowerCase()) {
|
<Badge type={statusData.type}>
|
||||||
case 'running':
|
<div className="flex items-center gap-1">
|
||||||
return 'success';
|
<span>
|
||||||
case 'waiting':
|
{statusData.status}
|
||||||
return 'warn';
|
{statusData.restartCount &&
|
||||||
case 'terminated':
|
` (Restarted ${statusData.restartCount} ${pluralize(
|
||||||
return 'info';
|
statusData.restartCount,
|
||||||
default:
|
'time'
|
||||||
return 'danger';
|
)})`}
|
||||||
}
|
</span>
|
||||||
|
</div>
|
||||||
|
{statusData.message && <Tooltip message={statusData.message} />}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+483
@@ -0,0 +1,483 @@
|
|||||||
|
import { ContainerStatus } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { computeContainerStatus } from './computeContainerStatus';
|
||||||
|
|
||||||
|
// Helper to create a base ContainerStatus with required properties
|
||||||
|
function createContainerStatus(
|
||||||
|
overrides: Partial<ContainerStatus>
|
||||||
|
): ContainerStatus {
|
||||||
|
return {
|
||||||
|
name: 'test-container',
|
||||||
|
ready: false,
|
||||||
|
restartCount: 0,
|
||||||
|
image: 'test-image:latest',
|
||||||
|
imageID: 'sha256:test123',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeContainerStatus', () => {
|
||||||
|
describe('Critical Container States', () => {
|
||||||
|
test('ImagePullBackOff should return danger type with no logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'ImagePullBackOff',
|
||||||
|
message: 'Failed to pull image',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('ImagePullBackOff');
|
||||||
|
expect(result.type).toBe('danger');
|
||||||
|
expect(result.hasLogs).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ImagePullBackOff with containerID should return danger type with logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
containerID: 'docker://abc123def456',
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'ImagePullBackOff',
|
||||||
|
message: 'Failed to pull image',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('ImagePullBackOff');
|
||||||
|
expect(result.type).toBe('danger');
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CrashLoopBackOff should return danger type with logs if container started', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
restartCount: 5,
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'CrashLoopBackOff',
|
||||||
|
message: 'Back-off restarting failed container',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastState: {
|
||||||
|
terminated: {
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
exitCode: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('CrashLoopBackOff');
|
||||||
|
expect(result.type).toBe('danger');
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CrashLoopBackOff with only containerID should return danger type with logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
containerID: 'docker://crashed123',
|
||||||
|
restartCount: 3,
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'CrashLoopBackOff',
|
||||||
|
message: 'Back-off restarting failed container',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('CrashLoopBackOff');
|
||||||
|
expect(result.type).toBe('danger');
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Running and ready should return success type with logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
ready: true,
|
||||||
|
state: {
|
||||||
|
running: {
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('Running');
|
||||||
|
expect(result.type).toBe('success');
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Running but not ready should return warn type with logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {
|
||||||
|
running: {
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('Running (not ready)');
|
||||||
|
expect(result.type).toBe('warn');
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OOMKilled should return danger type with logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
restartCount: 2,
|
||||||
|
state: {
|
||||||
|
terminated: {
|
||||||
|
reason: 'OOMKilled',
|
||||||
|
exitCode: 137,
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
finishedAt: '2023-01-01T10:05:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('OOMKilled');
|
||||||
|
expect(result.type).toBe('danger');
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Completed successfully should return success type with logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {
|
||||||
|
terminated: {
|
||||||
|
reason: 'Completed',
|
||||||
|
exitCode: 0,
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
finishedAt: '2023-01-01T10:05:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('Completed');
|
||||||
|
expect(result.type).toBe('success');
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ContainerCreating should return info type with no logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'ContainerCreating',
|
||||||
|
message: 'Container is being created',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('ContainerCreating');
|
||||||
|
expect(result.type).toBe('info');
|
||||||
|
expect(result.hasLogs).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ContainerCreating with containerID should return info type with logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
containerID: 'docker://creating123',
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'ContainerCreating',
|
||||||
|
message: 'Container is being created',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('ContainerCreating');
|
||||||
|
expect(result.type).toBe('info');
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PodInitializing should return info type with prefixed status', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'PodInitializing',
|
||||||
|
message: 'Waiting for init containers',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('Waiting (PodInitializing)');
|
||||||
|
expect(result.type).toBe('info');
|
||||||
|
expect(result.hasLogs).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Container not found should return unknown with muted type', () => {
|
||||||
|
const result = computeContainerStatus('nonexistent-container', []);
|
||||||
|
|
||||||
|
expect(result.status).toBe('Unknown');
|
||||||
|
expect(result.type).toBe('muted');
|
||||||
|
expect(result.hasLogs).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Container with no state should return unknown with muted type', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('Unknown');
|
||||||
|
expect(result.type).toBe('muted');
|
||||||
|
expect(result.hasLogs).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Container with no state but with containerID should have logs available', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
containerID: 'docker://unknown123',
|
||||||
|
state: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.status).toBe('Unknown');
|
||||||
|
expect(result.type).toBe('muted');
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sidecar container should be handled like regular container', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
ready: true,
|
||||||
|
state: {
|
||||||
|
running: {
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus(
|
||||||
|
'test-container',
|
||||||
|
[],
|
||||||
|
[containerStatus]
|
||||||
|
); // Sidecar containers are found in initContainerStatuses
|
||||||
|
|
||||||
|
expect(result.status).toBe('Running');
|
||||||
|
expect(result.type).toBe('success');
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Container Log Availability Tests', () => {
|
||||||
|
test('Container with running state should have logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {
|
||||||
|
running: {
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Container with terminated state should have logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {
|
||||||
|
terminated: {
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
exitCode: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Container with lastState running should have logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'CrashLoopBackOff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastState: {
|
||||||
|
running: {
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Container with lastState terminated should have logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'CrashLoopBackOff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastState: {
|
||||||
|
terminated: {
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
exitCode: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Container with only containerID should have logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
containerID: 'docker://abc123def456',
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'Unknown',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Container with empty containerID should not have logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
containerID: '',
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'ImagePullBackOff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.hasLogs).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Container without containerID or start times should not have logs', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'ImagePullBackOff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.hasLogs).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Container with terminated state but no startedAt should rely on containerID', () => {
|
||||||
|
const containerStatus = createContainerStatus({
|
||||||
|
containerID: 'docker://terminated123',
|
||||||
|
state: {
|
||||||
|
terminated: {
|
||||||
|
exitCode: 0,
|
||||||
|
// No startedAt field
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeContainerStatus('test-container', [
|
||||||
|
containerStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.hasLogs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Multiple containers with different log availability', () => {
|
||||||
|
const containerWithLogs = createContainerStatus({
|
||||||
|
name: 'container-with-logs',
|
||||||
|
containerID: 'docker://logs123',
|
||||||
|
state: {
|
||||||
|
running: {
|
||||||
|
startedAt: '2023-01-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerWithoutLogs = createContainerStatus({
|
||||||
|
name: 'container-without-logs',
|
||||||
|
state: {
|
||||||
|
waiting: {
|
||||||
|
reason: 'ImagePullBackOff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultWithLogs = computeContainerStatus('container-with-logs', [
|
||||||
|
containerWithLogs,
|
||||||
|
containerWithoutLogs,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resultWithoutLogs = computeContainerStatus(
|
||||||
|
'container-without-logs',
|
||||||
|
[containerWithLogs, containerWithoutLogs]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultWithLogs.hasLogs).toBe(true);
|
||||||
|
expect(resultWithoutLogs.hasLogs).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+360
@@ -0,0 +1,360 @@
|
|||||||
|
import { ContainerStatus } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { ContainerRowData } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the status of a container, with translated messages.
|
||||||
|
*
|
||||||
|
* The cases are hardcoded, because there is not a single source that enumerates
|
||||||
|
* all the possible states.
|
||||||
|
* @param containerName - The name of the container
|
||||||
|
* @param containerStatuses - The statuses of the container
|
||||||
|
* @param initContainerStatuses - The statuses of the init container
|
||||||
|
* @returns The status of the container
|
||||||
|
*/
|
||||||
|
export function computeContainerStatus(
|
||||||
|
containerName: string,
|
||||||
|
containerStatuses?: ContainerStatus[],
|
||||||
|
initContainerStatuses?: ContainerStatus[]
|
||||||
|
): ContainerRowData['status'] {
|
||||||
|
// Choose the correct status array based on container type
|
||||||
|
const statuses = [
|
||||||
|
...(containerStatuses || []),
|
||||||
|
...(initContainerStatuses || []),
|
||||||
|
];
|
||||||
|
const status = statuses?.find((status) => status.name === containerName);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return {
|
||||||
|
status: 'Unknown',
|
||||||
|
type: 'muted',
|
||||||
|
message: 'Container status information is not available',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLogs = hasContainerEverStarted(status);
|
||||||
|
const { state, restartCount = 0 } = status;
|
||||||
|
|
||||||
|
// Handle waiting state with more specific reasons
|
||||||
|
if (state?.waiting) {
|
||||||
|
const { reason, message } = state.waiting;
|
||||||
|
if (reason) {
|
||||||
|
// Return specific waiting reasons that match kubectl output
|
||||||
|
switch (reason) {
|
||||||
|
case 'ImagePullBackOff':
|
||||||
|
case 'ErrImagePull':
|
||||||
|
case 'ImageInspectError':
|
||||||
|
case 'ErrImageNeverPull':
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'danger',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Failed to pull container image. Check image name, registry access, and network connectivity.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'ContainerCreating':
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'info',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Container is being created. This may take a few moments.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'info')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'PodInitializing':
|
||||||
|
return {
|
||||||
|
status: `Waiting (${reason})`,
|
||||||
|
type: 'info',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Waiting for init containers to complete. Wait a few moments or check the logs of any init containers that failed to complete.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'info')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'CreateContainerConfigError':
|
||||||
|
case 'CreateContainerError':
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'danger',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Failed to create container. Check resource limits, security contexts, and volume mounts.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'InvalidImageName':
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'danger',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'The specified container image name is invalid or malformed.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'CrashLoopBackOff':
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'danger',
|
||||||
|
message: `Container keeps crashing after startup. Check application logs and startup configuration. ${
|
||||||
|
message ? `Details: '${message}'` : ''
|
||||||
|
}`,
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'RunContainerError':
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'danger',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Failed to start container process. Check command, arguments, and environment variables.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'KillContainerError':
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'danger',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Failed to stop container gracefully. Container may be unresponsive.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'VerifyNonRootError':
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'danger',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Container is trying to run as root but security policy requires non-root execution.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'ConfigError':
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'danger',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Container configuration is invalid. Check resource requirements and security settings.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'muted',
|
||||||
|
message: message || `Container is waiting: ${reason}`,
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'muted')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'Waiting',
|
||||||
|
type: 'muted',
|
||||||
|
message: message || 'Container is waiting to be scheduled or started.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'muted')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle terminated state
|
||||||
|
if (state?.terminated) {
|
||||||
|
const { exitCode = 0, reason, message } = state.terminated;
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
switch (reason) {
|
||||||
|
case 'Error':
|
||||||
|
return {
|
||||||
|
status: 'Error',
|
||||||
|
type: 'danger',
|
||||||
|
message: `Container exited with code ${exitCode}${
|
||||||
|
restartCount > 0 ? ` (restarted ${restartCount} times)` : ''
|
||||||
|
}. ${message || 'Check application logs for error details.'}`,
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'Completed':
|
||||||
|
return {
|
||||||
|
status: 'Completed',
|
||||||
|
type: 'success',
|
||||||
|
message,
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'success')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'OOMKilled':
|
||||||
|
return {
|
||||||
|
status: 'OOMKilled',
|
||||||
|
type: 'danger',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Container was killed due to out-of-memory. Consider increasing memory limits.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'DeadlineExceeded':
|
||||||
|
return {
|
||||||
|
status: 'DeadlineExceeded',
|
||||||
|
type: 'danger',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Container was terminated because it exceeded the active deadline.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'Evicted':
|
||||||
|
return {
|
||||||
|
status: 'Evicted',
|
||||||
|
type: 'warn',
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
'Container was evicted due to resource pressure on the node.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'warn')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
case 'NodeLost':
|
||||||
|
return {
|
||||||
|
status: 'NodeLost',
|
||||||
|
type: 'danger',
|
||||||
|
message:
|
||||||
|
message || 'Container was lost when the node became unreachable.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
status: reason,
|
||||||
|
type: 'muted',
|
||||||
|
message: `Container terminated: ${reason}${
|
||||||
|
restartCount > 0 ? ` (restarted ${restartCount} times)` : ''
|
||||||
|
}. ${message || ''}`,
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'muted')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitCode === 0) {
|
||||||
|
return {
|
||||||
|
status: 'Completed',
|
||||||
|
type: 'success',
|
||||||
|
message,
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'success')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'Error',
|
||||||
|
type: 'danger',
|
||||||
|
message: `Container exited with code ${exitCode}${
|
||||||
|
restartCount > 0 ? ` (restarted ${restartCount} times)` : ''
|
||||||
|
}. ${message || 'Check application logs for error details.'}`,
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'danger')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle running state
|
||||||
|
if (state?.running) {
|
||||||
|
// Check if container is ready
|
||||||
|
if (status.ready === false) {
|
||||||
|
return {
|
||||||
|
status: 'Running (not ready)',
|
||||||
|
type: 'warn',
|
||||||
|
message:
|
||||||
|
'Container is running but not ready. Check readiness probe configuration.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'warn')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'Running',
|
||||||
|
type: 'success',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'success')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return {
|
||||||
|
status: 'Unknown',
|
||||||
|
type: 'muted',
|
||||||
|
message:
|
||||||
|
'Container state cannot be determined. Status information may be incomplete.',
|
||||||
|
hasLogs,
|
||||||
|
restartCount: shouldShowRestartCount(restartCount, 'muted')
|
||||||
|
? restartCount
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to determine if restart count should be shown
|
||||||
|
function shouldShowRestartCount(
|
||||||
|
restartCount: number,
|
||||||
|
type: ContainerRowData['status']['type']
|
||||||
|
) {
|
||||||
|
return restartCount >= 1 && (type === 'danger' || type === 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasContainerEverStarted(status: ContainerStatus): boolean {
|
||||||
|
return (
|
||||||
|
!!status.state?.running?.startedAt ||
|
||||||
|
!!status.state?.terminated?.startedAt ||
|
||||||
|
!!status.lastState?.running?.startedAt ||
|
||||||
|
!!status.lastState?.terminated?.startedAt ||
|
||||||
|
!!status.containerID
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
-1
@@ -1,9 +1,20 @@
|
|||||||
import { Container } from 'kubernetes-types/core/v1';
|
import { Container } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { BadgeType } from '@@/Badge';
|
||||||
|
|
||||||
export interface ContainerRowData extends Container {
|
export interface ContainerRowData extends Container {
|
||||||
podName: string;
|
podName: string;
|
||||||
nodeName: string;
|
nodeName: string;
|
||||||
podIp: string;
|
podIp: string;
|
||||||
creationDate: string;
|
creationDate: string;
|
||||||
status: string;
|
status: {
|
||||||
|
status: string;
|
||||||
|
type: BadgeType;
|
||||||
|
message?: string;
|
||||||
|
hasLogs?: boolean;
|
||||||
|
startedAt?: string;
|
||||||
|
restartCount?: number;
|
||||||
|
};
|
||||||
|
isInit?: boolean;
|
||||||
|
isSidecar?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getAllSettledItems } from '@/portainer/helpers/promise-utils';
|
|||||||
import { withGlobalError } from '@/react-tools/react-query';
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||||
import { pluralize } from '@/portainer/helpers/strings';
|
import { pluralize } from '@/portainer/helpers/strings';
|
||||||
import { uninstallHelmApplication } from '@/react/kubernetes/helm/HelmApplicationView/queries/useUninstallHelmAppMutation';
|
import { uninstallHelmApplication } from '@/react/kubernetes/helm/helmReleaseQueries/useUninstallHelmAppMutation';
|
||||||
|
|
||||||
import { parseKubernetesAxiosError } from '../../axiosError';
|
import { parseKubernetesAxiosError } from '../../axiosError';
|
||||||
import { ApplicationRowData } from '../ListView/ApplicationsDatatable/types';
|
import { ApplicationRowData } from '../ListView/ApplicationsDatatable/types';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { buildConfirmButton } from '@@/modals/utils';
|
|||||||
import { confirm } from '@@/modals/confirm';
|
import { confirm } from '@@/modals/confirm';
|
||||||
import { ModalType } from '@@/modals';
|
import { ModalType } from '@@/modals';
|
||||||
|
|
||||||
import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation';
|
import { useHelmRollbackMutation } from '../../helmReleaseQueries/useHelmRollbackMutation';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
latestRevision: number;
|
latestRevision: number;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { notifySuccess } from '@/portainer/services/notifications';
|
|||||||
|
|
||||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||||
|
|
||||||
import { useUninstallHelmAppMutation } from '../queries/useUninstallHelmAppMutation';
|
import { useUninstallHelmAppMutation } from '../../helmReleaseQueries/useUninstallHelmAppMutation';
|
||||||
|
|
||||||
export function UninstallButton({
|
export function UninstallButton({
|
||||||
environmentId,
|
environmentId,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
|||||||
import {
|
import {
|
||||||
useHelmRepoVersions,
|
useHelmRepoVersions,
|
||||||
ChartVersion,
|
ChartVersion,
|
||||||
} from '../../queries/useHelmRepoVersions';
|
} from '../../helmChartSourceQueries/useHelmRepoVersions';
|
||||||
import { HelmRelease } from '../../types';
|
import { HelmRelease } from '../../types';
|
||||||
|
|
||||||
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||||
@@ -25,32 +25,56 @@ vi.mock('@/portainer/services/notifications', () => ({
|
|||||||
notifySuccess: vi.fn(),
|
notifySuccess: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../queries/useHelmRepositories', () => ({
|
vi.mock('../../helmChartSourceQueries/useHelmRepositories', async () => {
|
||||||
useUserHelmRepositories: vi.fn(() => ({
|
const actual = await vi.importActual(
|
||||||
data: ['repo1', 'repo2'],
|
'../../helmChartSourceQueries/useHelmRepositories'
|
||||||
isInitialLoading: false,
|
);
|
||||||
isError: false,
|
return {
|
||||||
})),
|
...actual,
|
||||||
}));
|
useUserHelmRepositories: vi.fn(() => ({
|
||||||
|
data: ['repo1', 'repo2'],
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../queries/useHelmRepoVersions', () => ({
|
vi.mock('../../helmChartSourceQueries/useHelmRepoVersions', async () => {
|
||||||
useHelmRepoVersions: vi.fn(),
|
const actual = await vi.importActual(
|
||||||
}));
|
'../../helmChartSourceQueries/useHelmRepoVersions'
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useHelmRepoVersions: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the useHelmRelease hook
|
// Mock the useHelmRelease hook
|
||||||
vi.mock('../queries/useHelmRelease', () => ({
|
vi.mock('../../helmReleaseQueries/useHelmRelease', async () => {
|
||||||
useHelmRelease: vi.fn(() => ({
|
const actual = await vi.importActual(
|
||||||
data: '1.0.0',
|
'../../helmReleaseQueries/useHelmRelease'
|
||||||
})),
|
);
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
useHelmRelease: vi.fn(() => ({
|
||||||
|
data: '1.0.0',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the useUpdateHelmReleaseMutation hook
|
// Mock the useUpdateHelmReleaseMutation hook
|
||||||
vi.mock('../../queries/useUpdateHelmReleaseMutation', () => ({
|
vi.mock('../../helmReleaseQueries/useUpdateHelmReleaseMutation', async () => {
|
||||||
useUpdateHelmReleaseMutation: vi.fn(() => ({
|
const actual = await vi.importActual(
|
||||||
mutate: vi.fn(),
|
'../../helmReleaseQueries/useUpdateHelmReleaseMutation'
|
||||||
isLoading: false,
|
);
|
||||||
})),
|
return {
|
||||||
}));
|
...actual,
|
||||||
|
useUpdateHelmReleaseMutation: vi.fn(() => ({
|
||||||
|
mutate: vi.fn(),
|
||||||
|
isLoading: false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function renderButton(props = {}) {
|
function renderButton(props = {}) {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -157,12 +181,14 @@ describe('UpgradeButton', () => {
|
|||||||
metadata: {
|
metadata: {
|
||||||
name: 'test-chart',
|
name: 'test-chart',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
appVersion: '1.0.0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
userSuppliedValues: '{}',
|
userSuppliedValues: '{}',
|
||||||
},
|
},
|
||||||
manifest: '',
|
manifest: '',
|
||||||
|
namespace: 'default',
|
||||||
} as HelmRelease;
|
} as HelmRelease;
|
||||||
|
|
||||||
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
vi.mocked(useHelmRepoVersions).mockReturnValue({
|
||||||
@@ -193,7 +219,9 @@ describe('UpgradeButton', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
{ Version: '1.0.0', Repo: 'stable' },
|
{ Version: '1.0.0', Repo: 'stable' },
|
||||||
{ Version: '1.1.0', Repo: 'stable' },
|
{ Version: '1.1.0', Repo: 'stable' },
|
||||||
])
|
]),
|
||||||
|
'', // releaseManifest
|
||||||
|
1 // environmentId
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import { Tooltip } from '@@/Tip/Tooltip';
|
|||||||
import { Link } from '@@/Link';
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
|
import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
|
||||||
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation';
|
import { useUpdateHelmReleaseMutation } from '../../helmReleaseQueries/useUpdateHelmReleaseMutation';
|
||||||
import { useHelmRepoVersions } from '../../queries/useHelmRepoVersions';
|
import { useHelmRepoVersions } from '../../helmChartSourceQueries/useHelmRepoVersions';
|
||||||
import { useHelmRelease } from '../queries/useHelmRelease';
|
import { useHelmRelease } from '../../helmReleaseQueries/useHelmRelease';
|
||||||
import { useUserHelmRepositories } from '../../queries/useHelmRepositories';
|
import {
|
||||||
|
flattenHelmRegistries,
|
||||||
|
useUserHelmRepositories,
|
||||||
|
} from '../../helmChartSourceQueries/useHelmRepositories';
|
||||||
|
|
||||||
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||||
|
|
||||||
@@ -36,7 +39,9 @@ export function UpgradeButton({
|
|||||||
const [useCache, setUseCache] = useState(true);
|
const [useCache, setUseCache] = useState(true);
|
||||||
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
|
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
|
||||||
|
|
||||||
const userRepositoriesQuery = useUserHelmRepositories();
|
const userRepositoriesQuery = useUserHelmRepositories({
|
||||||
|
select: flattenHelmRegistries,
|
||||||
|
});
|
||||||
const helmRepoVersionsQuery = useHelmRepoVersions(
|
const helmRepoVersionsQuery = useHelmRepoVersions(
|
||||||
release?.chart.metadata?.name || '',
|
release?.chart.metadata?.name || '',
|
||||||
60 * 60 * 1000, // 1 hour
|
60 * 60 * 1000, // 1 hour
|
||||||
@@ -164,7 +169,9 @@ export function UpgradeButton({
|
|||||||
async function handleUpgrade() {
|
async function handleUpgrade() {
|
||||||
const submittedUpgradeValues = await openUpgradeHelmModal(
|
const submittedUpgradeValues = await openUpgradeHelmModal(
|
||||||
editableHelmRelease,
|
editableHelmRelease,
|
||||||
filteredVersions
|
filteredVersions,
|
||||||
|
release?.manifest || '',
|
||||||
|
environmentId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (submittedUpgradeValues) {
|
if (submittedUpgradeValues) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ArrowUp } from 'lucide-react';
|
import { ArrowUp } from 'lucide-react';
|
||||||
|
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { ChartVersion } from '@/react/kubernetes/helm/queries/useHelmRepoVersions';
|
import { ChartVersion } from '@/react/kubernetes/helm/helmChartSourceQueries/useHelmRepoVersions';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
import { Modal, OnSubmit, openModal } from '@@/modals';
|
import { Modal, OnSubmit, openModal } from '@@/modals';
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
@@ -15,26 +16,32 @@ import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
|||||||
|
|
||||||
import { UpdateHelmReleasePayload } from '../../types';
|
import { UpdateHelmReleasePayload } from '../../types';
|
||||||
import { HelmValuesInput } from '../../components/HelmValuesInput';
|
import { HelmValuesInput } from '../../components/HelmValuesInput';
|
||||||
import { useHelmChartValues } from '../../queries/useHelmChartValues';
|
import { useHelmChartValues } from '../../helmChartSourceQueries/useHelmChartValues';
|
||||||
|
import { ManifestPreviewFormSection } from '../../components/ManifestPreviewFormSection';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
|
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
|
||||||
payload: UpdateHelmReleasePayload;
|
helmReleaseInitialValues: UpdateHelmReleasePayload;
|
||||||
|
releaseManifest: string;
|
||||||
versions: ChartVersion[];
|
versions: ChartVersion[];
|
||||||
chartName: string;
|
chartName: string;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UpgradeHelmModal({
|
export function UpgradeHelmModal({
|
||||||
payload,
|
helmReleaseInitialValues,
|
||||||
|
releaseManifest,
|
||||||
versions,
|
versions,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
chartName,
|
chartName,
|
||||||
|
environmentId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
|
const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
|
||||||
const repo = payload.repo === version.Repo ? version.Repo : '';
|
const repo =
|
||||||
|
helmReleaseInitialValues.repo === version.Repo ? version.Repo : '';
|
||||||
const isCurrentVersion =
|
const isCurrentVersion =
|
||||||
version.AppVersion === payload.appVersion &&
|
version.AppVersion === helmReleaseInitialValues.appVersion &&
|
||||||
version.Version === payload.version;
|
version.Version === helmReleaseInitialValues.version;
|
||||||
|
|
||||||
const label = `${repo}@${version.Version}${
|
const label = `${repo}@${version.Version}${
|
||||||
isCurrentVersion ? ' (current)' : ''
|
isCurrentVersion ? ' (current)' : ''
|
||||||
@@ -50,13 +57,16 @@ export function UpgradeHelmModal({
|
|||||||
const defaultVersion =
|
const defaultVersion =
|
||||||
versionOptions.find(
|
versionOptions.find(
|
||||||
(v) =>
|
(v) =>
|
||||||
v.value.AppVersion === payload.appVersion &&
|
v.value.AppVersion === helmReleaseInitialValues.appVersion &&
|
||||||
v.value.Version === payload.version &&
|
v.value.Version === helmReleaseInitialValues.version &&
|
||||||
v.value.Repo === payload.repo
|
v.value.Repo === helmReleaseInitialValues.repo
|
||||||
)?.value || versionOptions[0]?.value;
|
)?.value || versionOptions[0]?.value;
|
||||||
const [version, setVersion] = useState<ChartVersion>(defaultVersion);
|
const [version, setVersion] = useState<ChartVersion>(defaultVersion);
|
||||||
const [userValues, setUserValues] = useState<string>(payload.values || '');
|
const [userValues, setUserValues] = useState<string>(
|
||||||
|
helmReleaseInitialValues.values || ''
|
||||||
|
);
|
||||||
const [atomic, setAtomic] = useState<boolean>(true);
|
const [atomic, setAtomic] = useState<boolean>(true);
|
||||||
|
const [previewIsValid, setPreviewIsValid] = useState<boolean>(false);
|
||||||
|
|
||||||
const chartValuesRefQuery = useHelmChartValues({
|
const chartValuesRefQuery = useHelmChartValues({
|
||||||
chart: chartName,
|
chart: chartName,
|
||||||
@@ -64,6 +74,19 @@ export function UpgradeHelmModal({
|
|||||||
version: version.Version,
|
version: version.Version,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const submitPayload = useMemo(
|
||||||
|
() => ({
|
||||||
|
name: helmReleaseInitialValues.name,
|
||||||
|
values: userValues,
|
||||||
|
namespace: helmReleaseInitialValues.namespace,
|
||||||
|
chart: helmReleaseInitialValues.chart,
|
||||||
|
repo: version.Repo,
|
||||||
|
version: version.Version,
|
||||||
|
atomic,
|
||||||
|
}),
|
||||||
|
[helmReleaseInitialValues, userValues, version, atomic]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
onDismiss={() => onSubmit()}
|
onDismiss={() => onSubmit()}
|
||||||
@@ -84,7 +107,7 @@ export function UpgradeHelmModal({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="release-name-input"
|
id="release-name-input"
|
||||||
value={payload.name}
|
value={helmReleaseInitialValues.name}
|
||||||
readOnly
|
readOnly
|
||||||
disabled
|
disabled
|
||||||
data-cy="helm-release-name-input"
|
data-cy="helm-release-name-input"
|
||||||
@@ -97,7 +120,7 @@ export function UpgradeHelmModal({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="namespace-input"
|
id="namespace-input"
|
||||||
value={payload.namespace}
|
value={helmReleaseInitialValues.namespace}
|
||||||
readOnly
|
readOnly
|
||||||
disabled
|
disabled
|
||||||
data-cy="helm-namespace-input"
|
data-cy="helm-namespace-input"
|
||||||
@@ -134,6 +157,15 @@ export function UpgradeHelmModal({
|
|||||||
valuesRef={chartValuesRefQuery.data?.values ?? ''}
|
valuesRef={chartValuesRefQuery.data?.values ?? ''}
|
||||||
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
|
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
|
||||||
/>
|
/>
|
||||||
|
<div className="mb-10">
|
||||||
|
<ManifestPreviewFormSection
|
||||||
|
payload={submitPayload}
|
||||||
|
onChangePreviewValidation={setPreviewIsValid}
|
||||||
|
title="Manifest changes"
|
||||||
|
currentManifest={releaseManifest}
|
||||||
|
environmentId={environmentId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,17 +181,8 @@ export function UpgradeHelmModal({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() => onSubmit(submitPayload)}
|
||||||
onSubmit({
|
disabled={!previewIsValid}
|
||||||
name: payload.name,
|
|
||||||
values: userValues,
|
|
||||||
namespace: payload.namespace,
|
|
||||||
chart: payload.chart,
|
|
||||||
repo: version.Repo,
|
|
||||||
version: version.Version,
|
|
||||||
atomic,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
color="primary"
|
color="primary"
|
||||||
key="update-button"
|
key="update-button"
|
||||||
size="medium"
|
size="medium"
|
||||||
@@ -174,12 +197,16 @@ export function UpgradeHelmModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openUpgradeHelmModal(
|
export async function openUpgradeHelmModal(
|
||||||
payload: UpdateHelmReleasePayload,
|
helmReleaseInitialValues: UpdateHelmReleasePayload,
|
||||||
versions: ChartVersion[]
|
versions: ChartVersion[],
|
||||||
|
releaseManifest: string,
|
||||||
|
environmentId: EnvironmentId
|
||||||
) {
|
) {
|
||||||
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
|
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
|
||||||
payload,
|
helmReleaseInitialValues,
|
||||||
versions,
|
versions,
|
||||||
chartName: payload.chart,
|
chartName: helmReleaseInitialValues.chart,
|
||||||
|
releaseManifest,
|
||||||
|
environmentId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import { Alert } from '@@/Alert';
|
|||||||
|
|
||||||
import { HelmRelease } from '../types';
|
import { HelmRelease } from '../types';
|
||||||
import { useIsSystemNamespace } from '../../namespaces/queries/useIsSystemNamespace';
|
import { useIsSystemNamespace } from '../../namespaces/queries/useIsSystemNamespace';
|
||||||
|
import { useHelmRelease } from '../helmReleaseQueries/useHelmRelease';
|
||||||
|
import { useHelmHistory } from '../helmReleaseQueries/useHelmHistory';
|
||||||
|
|
||||||
import { HelmSummary } from './HelmSummary';
|
import { HelmSummary } from './HelmSummary';
|
||||||
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
|
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
|
||||||
import { useHelmRelease } from './queries/useHelmRelease';
|
|
||||||
import { ChartActions } from './ChartActions/ChartActions';
|
import { ChartActions } from './ChartActions/ChartActions';
|
||||||
import { HelmRevisionList } from './HelmRevisionList';
|
import { HelmRevisionList } from './HelmRevisionList';
|
||||||
import { HelmRevisionListSheet } from './HelmRevisionListSheet';
|
import { HelmRevisionListSheet } from './HelmRevisionListSheet';
|
||||||
import { useHelmHistory } from './queries/useHelmHistory';
|
|
||||||
|
|
||||||
export function HelmApplicationView() {
|
export function HelmApplicationView() {
|
||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Badge } from '@@/Badge';
|
|||||||
import { Icon } from '@@/Icon';
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
import { HelmRelease } from '../../types';
|
import { HelmRelease } from '../../types';
|
||||||
import { useHelmHistory } from '../queries/useHelmHistory';
|
import { useHelmHistory } from '../../helmReleaseQueries/useHelmHistory';
|
||||||
|
|
||||||
import { ManifestDetails } from './ManifestDetails';
|
import { ManifestDetails } from './ManifestDetails';
|
||||||
import { NotesDetails } from './NotesDetails';
|
import { NotesDetails } from './NotesDetails';
|
||||||
|
|||||||
+2
-3
@@ -3,8 +3,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
|
|
||||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { GenericResource } from '@/react/kubernetes/helm/types';
|
||||||
import { GenericResource } from '../../../types';
|
|
||||||
|
|
||||||
import { ResourcesTable } from './ResourcesTable';
|
import { ResourcesTable } from './ResourcesTable';
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
|||||||
useEnvironmentId: () => mockUseEnvironmentId(),
|
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../queries/useHelmRelease', () => ({
|
vi.mock('@/react/kubernetes/helm/helmReleaseQueries/useHelmRelease', () => ({
|
||||||
useHelmRelease: () => mockUseHelmRelease(),
|
useHelmRelease: () => mockUseHelmRelease(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,7 @@
|
|||||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
|
|
||||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useHelmRelease } from '@/react/kubernetes/helm/helmReleaseQueries/useHelmRelease';
|
||||||
|
|
||||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||||
import {
|
import {
|
||||||
@@ -13,8 +14,6 @@ import { Widget } from '@@/Widget';
|
|||||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
import { useHelmRelease } from '../../queries/useHelmRelease';
|
|
||||||
|
|
||||||
import { columns } from './columns';
|
import { columns } from './columns';
|
||||||
import { useResourceRows } from './useResourceRows';
|
import { useResourceRows } from './useResourceRows';
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { StatusBadgeType } from '@@/StatusBadge';
|
import { GenericResource } from '@/react/kubernetes/helm/types';
|
||||||
|
|
||||||
import { GenericResource } from '../../../types';
|
import { StatusBadgeType } from '@@/StatusBadge';
|
||||||
|
|
||||||
import { ResourceLink, ResourceRow } from './types';
|
import { ResourceLink, ResourceRow } from './types';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
import { HelmRelease } from '../../types';
|
import { HelmRelease } from '../../types';
|
||||||
import { useHelmRelease } from '../queries/useHelmRelease';
|
import { useHelmRelease } from '../../helmReleaseQueries/useHelmRelease';
|
||||||
|
|
||||||
import { DiffViewMode } from './DiffControl';
|
import { DiffViewMode } from './DiffControl';
|
||||||
|
|
||||||
|
|||||||
@@ -36,14 +36,15 @@ vi.mock('@/portainer/services/notifications', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../queries/useUpdateHelmReleaseMutation', () => ({
|
vi.mock('../helmReleaseQueries/useUpdateHelmReleaseMutation', () => ({
|
||||||
useUpdateHelmReleaseMutation: vi.fn(() => ({
|
useUpdateHelmReleaseMutation: vi.fn(() => ({
|
||||||
mutateAsync: vi.fn((...args) => mockMutate(...args)),
|
mutateAsync: vi.fn((...args) => mockMutate(...args)),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
})),
|
})),
|
||||||
|
updateHelmRelease: vi.fn(() => Promise.resolve({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../queries/useHelmRepoVersions', () => ({
|
vi.mock('../helmChartSourceQueries/useHelmRepoVersions', () => ({
|
||||||
useHelmRepoVersions: vi.fn(() => ({
|
useHelmRepoVersions: vi.fn(() => ({
|
||||||
data: [
|
data: [
|
||||||
{ Version: '1.0.0', AppVersion: '1.0.0' },
|
{ Version: '1.0.0', AppVersion: '1.0.0' },
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import { confirmGenericDiscard } from '@@/modals/confirm';
|
|||||||
import { Option } from '@@/form-components/PortainerSelect';
|
import { Option } from '@@/form-components/PortainerSelect';
|
||||||
|
|
||||||
import { Chart } from '../types';
|
import { Chart } from '../types';
|
||||||
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation';
|
import { useUpdateHelmReleaseMutation } from '../helmReleaseQueries/useUpdateHelmReleaseMutation';
|
||||||
import {
|
import {
|
||||||
ChartVersion,
|
ChartVersion,
|
||||||
useHelmRepoVersions,
|
useHelmRepoVersions,
|
||||||
} from '../queries/useHelmRepoVersions';
|
} from '../helmChartSourceQueries/useHelmRepoVersions';
|
||||||
|
|
||||||
import { HelmInstallInnerForm } from './HelmInstallInnerForm';
|
import { HelmInstallInnerForm } from './HelmInstallInnerForm';
|
||||||
import { HelmInstallFormValues } from './types';
|
import { HelmInstallFormValues } from './types';
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Form, useFormikContext } from 'formik';
|
import { Form, useFormikContext } from 'formik';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
@@ -7,9 +9,10 @@ import { FormSection } from '@@/form-components/FormSection';
|
|||||||
import { LoadingButton } from '@@/buttons';
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
|
||||||
import { Chart } from '../types';
|
import { Chart } from '../types';
|
||||||
import { useHelmChartValues } from '../queries/useHelmChartValues';
|
import { useHelmChartValues } from '../helmChartSourceQueries/useHelmChartValues';
|
||||||
import { HelmValuesInput } from '../components/HelmValuesInput';
|
import { HelmValuesInput } from '../components/HelmValuesInput';
|
||||||
import { ChartVersion } from '../queries/useHelmRepoVersions';
|
import { ChartVersion } from '../helmChartSourceQueries/useHelmRepoVersions';
|
||||||
|
import { ManifestPreviewFormSection } from '../components/ManifestPreviewFormSection';
|
||||||
|
|
||||||
import { HelmInstallFormValues } from './types';
|
import { HelmInstallFormValues } from './types';
|
||||||
|
|
||||||
@@ -30,6 +33,8 @@ export function HelmInstallInnerForm({
|
|||||||
isVersionsLoading,
|
isVersionsLoading,
|
||||||
isRepoAvailable,
|
isRepoAvailable,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const [previewIsValid, setPreviewIsValid] = useState(false);
|
||||||
const { values, setFieldValue, isSubmitting } =
|
const { values, setFieldValue, isSubmitting } =
|
||||||
useFormikContext<HelmInstallFormValues>();
|
useFormikContext<HelmInstallFormValues>();
|
||||||
|
|
||||||
@@ -62,6 +67,25 @@ export function HelmInstallInnerForm({
|
|||||||
isLatestVersionFetched
|
isLatestVersionFetched
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const payload = useMemo(
|
||||||
|
() => ({
|
||||||
|
name: name || '',
|
||||||
|
namespace: namespace || '',
|
||||||
|
chart: selectedChart.name,
|
||||||
|
version: values?.version,
|
||||||
|
repo: selectedChart.repo,
|
||||||
|
values: values.values,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
name,
|
||||||
|
namespace,
|
||||||
|
selectedChart.name,
|
||||||
|
values?.version,
|
||||||
|
selectedChart.repo,
|
||||||
|
values.values,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form className="form-horizontal">
|
<Form className="form-horizontal">
|
||||||
<div className="form-group !m-0">
|
<div className="form-group !m-0">
|
||||||
@@ -93,13 +117,19 @@ export function HelmInstallInnerForm({
|
|||||||
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
|
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
|
||||||
/>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
<ManifestPreviewFormSection
|
||||||
|
payload={payload}
|
||||||
|
onChangePreviewValidation={setPreviewIsValid}
|
||||||
|
title="Manifest preview"
|
||||||
|
environmentId={environmentId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
className="!ml-0"
|
className="!ml-0 mt-5"
|
||||||
loadingText="Installing Helm chart"
|
loadingText="Installing Helm chart"
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
disabled={!namespace || !name || !isRepoAvailable}
|
disabled={!namespace || !name || !isRepoAvailable || !previewIsValid}
|
||||||
data-cy="helm-install"
|
data-cy="helm-install"
|
||||||
>
|
>
|
||||||
Install
|
Install
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useCurrentUser } from '@/react/hooks/useUser';
|
|||||||
|
|
||||||
import { FormSection } from '@@/form-components/FormSection';
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
|
||||||
import { useHelmHTTPChartList } from '../queries/useHelmChartList';
|
import { useHelmHTTPChartList } from '../helmChartSourceQueries/useHelmChartList';
|
||||||
import { Chart } from '../types';
|
import { Chart } from '../types';
|
||||||
import {
|
import {
|
||||||
HelmRegistrySelect,
|
HelmRegistrySelect,
|
||||||
RepoValue,
|
RepoValue,
|
||||||
} from '../components/HelmRegistrySelect';
|
} from '../components/HelmRegistrySelect';
|
||||||
import { useHelmRepoOptions } from '../queries/useHelmRepositories';
|
import { useHelmRepoOptions } from '../helmChartSourceQueries/useHelmRepositories';
|
||||||
|
|
||||||
import { HelmInstallForm } from './HelmInstallForm';
|
import { HelmInstallForm } from './HelmInstallForm';
|
||||||
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
|
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
|
||||||
|
import { ManifestPreviewFormSection } from './ManifestPreviewFormSection';
|
||||||
|
|
||||||
|
// Mock the necessary hooks
|
||||||
|
const mockUseHelmDryRun = vi.fn();
|
||||||
|
const mockUseDebouncedValue = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../helmReleaseQueries/useHelmDryRun', () => ({
|
||||||
|
useHelmDryRun: (...args: unknown[]) => mockUseHelmDryRun(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useDebouncedValue', () => ({
|
||||||
|
useDebouncedValue: (value: unknown, delay: number) =>
|
||||||
|
mockUseDebouncedValue(value, delay),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the CodeEditor and DiffViewer components
|
||||||
|
vi.mock('@@/CodeEditor', () => ({
|
||||||
|
CodeEditor: ({
|
||||||
|
'data-cy': dataCy,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
'data-cy'?: string;
|
||||||
|
value: string;
|
||||||
|
}) => (
|
||||||
|
<div data-cy={dataCy} data-testid="code-editor">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@@/CodeEditor/DiffViewer', () => ({
|
||||||
|
DiffViewer: ({
|
||||||
|
'data-cy': dataCy,
|
||||||
|
originalCode,
|
||||||
|
newCode,
|
||||||
|
}: {
|
||||||
|
'data-cy'?: string;
|
||||||
|
originalCode: string;
|
||||||
|
newCode: string;
|
||||||
|
}) => (
|
||||||
|
<div data-cy={dataCy} data-testid="diff-viewer">
|
||||||
|
<div data-testid="original-code">{originalCode}</div>
|
||||||
|
<div data-testid="new-code">{newCode}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockOnChangePreviewValidation = vi.fn();
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
payload: {
|
||||||
|
name: 'test-release',
|
||||||
|
namespace: 'test-namespace',
|
||||||
|
chart: 'test-chart',
|
||||||
|
version: '1.0.0',
|
||||||
|
repo: 'test-repo',
|
||||||
|
},
|
||||||
|
onChangePreviewValidation: mockOnChangePreviewValidation,
|
||||||
|
title: 'Manifest Preview',
|
||||||
|
environmentId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderComponent(props = {}) {
|
||||||
|
const user = new UserViewModel({ Username: 'user', Role: 1 });
|
||||||
|
|
||||||
|
const Component = withTestQueryProvider(
|
||||||
|
withUserProvider(
|
||||||
|
withTestRouter(() => (
|
||||||
|
<ManifestPreviewFormSection {...defaultProps} {...props} />
|
||||||
|
)),
|
||||||
|
user
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(<Component />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ManifestPreviewFormSection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Default mock for useDebouncedValue - returns the value as-is
|
||||||
|
mockUseDebouncedValue.mockImplementation((value) => value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show loading and no form section when loading', () => {
|
||||||
|
mockUseHelmDryRun.mockReturnValue({
|
||||||
|
isInitialLoading: true,
|
||||||
|
isError: false,
|
||||||
|
data: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Generating manifest preview...')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Manifest Preview')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error and no form section when error', () => {
|
||||||
|
mockUseHelmDryRun.mockReturnValue({
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: true,
|
||||||
|
error: { message: 'Invalid chart configuration' },
|
||||||
|
data: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Error with Helm chart configuration')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Invalid chart configuration')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Manifest Preview')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show single code editor when only the generated manifest is available', async () => {
|
||||||
|
const mockManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: test';
|
||||||
|
|
||||||
|
mockUseHelmDryRun.mockReturnValue({
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: { manifest: mockManifest },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(screen.getByText('Manifest Preview')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Expand the FormSection to see the content
|
||||||
|
const expandButton = screen.getByLabelText('Expand');
|
||||||
|
await userEvent.click(expandButton);
|
||||||
|
|
||||||
|
// Check that the manifest content is rendered (from the HTML, we can see it's there)
|
||||||
|
expect(
|
||||||
|
screen.getByText(/apiVersion/, { exact: false })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/test/, { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the diff when the current and generated manifest are available', async () => {
|
||||||
|
const currentManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: old';
|
||||||
|
const newManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: new';
|
||||||
|
|
||||||
|
mockUseHelmDryRun.mockReturnValue({
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: { manifest: newManifest },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent({ currentManifest });
|
||||||
|
|
||||||
|
expect(screen.getByText('Manifest Preview')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Expand the FormSection to see the content
|
||||||
|
const expandButton = screen.getByLabelText('Expand');
|
||||||
|
await userEvent.click(expandButton);
|
||||||
|
|
||||||
|
// Check that both old and new manifest content is rendered
|
||||||
|
expect(screen.getByText(/old/, { exact: false })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/new/, { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onChangePreviewValidation with correct validation state', () => {
|
||||||
|
mockUseHelmDryRun.mockReturnValue({
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: { manifest: 'test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(mockOnChangePreviewValidation).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onChangePreviewValidation with false when error occurs', () => {
|
||||||
|
mockUseHelmDryRun.mockReturnValue({
|
||||||
|
isInitialLoading: false,
|
||||||
|
isError: true,
|
||||||
|
error: { message: 'Error' },
|
||||||
|
data: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(mockOnChangePreviewValidation).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { CodeEditor } from '@@/CodeEditor';
|
||||||
|
import { DiffViewer } from '@@/CodeEditor/DiffViewer';
|
||||||
|
import { InlineLoader } from '@@/InlineLoader';
|
||||||
|
import { Alert } from '@@/Alert';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
import { useHelmDryRun } from '../helmReleaseQueries/useHelmDryRun';
|
||||||
|
import { UpdateHelmReleasePayload } from '../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
payload: UpdateHelmReleasePayload;
|
||||||
|
onChangePreviewValidation: (isValid: boolean) => void;
|
||||||
|
currentManifest?: string; // only true on upgrade, not install
|
||||||
|
title: string;
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ManifestPreviewFormSection({
|
||||||
|
payload,
|
||||||
|
currentManifest,
|
||||||
|
onChangePreviewValidation,
|
||||||
|
title,
|
||||||
|
environmentId,
|
||||||
|
}: Props) {
|
||||||
|
const debouncedPayload = useDebouncedValue(payload, 500);
|
||||||
|
const manifestPreviewQuery = useHelmDryRun(environmentId, debouncedPayload);
|
||||||
|
const [isFolded, setIsFolded] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChangePreviewValidation(!manifestPreviewQuery.isError);
|
||||||
|
}, [manifestPreviewQuery.isError, onChangePreviewValidation]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!debouncedPayload.name ||
|
||||||
|
!debouncedPayload.namespace ||
|
||||||
|
!debouncedPayload.chart
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// only show loading state or the error to keep the view simple (omitting the preview section because there is nothing to preview)
|
||||||
|
if (manifestPreviewQuery.isInitialLoading) {
|
||||||
|
return <InlineLoader>Generating manifest preview...</InlineLoader>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifestPreviewQuery.isError) {
|
||||||
|
return (
|
||||||
|
<Alert color="error" title="Error with Helm chart configuration">
|
||||||
|
{manifestPreviewQuery.error?.message ||
|
||||||
|
'Error generating manifest preview'}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection
|
||||||
|
title={title}
|
||||||
|
isFoldable
|
||||||
|
defaultFolded={isFolded}
|
||||||
|
setIsDefaultFolded={setIsFolded}
|
||||||
|
>
|
||||||
|
<ManifestPreview
|
||||||
|
currentManifest={currentManifest}
|
||||||
|
newManifest={manifestPreviewQuery.data?.manifest ?? ''}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManifestPreview({
|
||||||
|
currentManifest,
|
||||||
|
newManifest,
|
||||||
|
}: {
|
||||||
|
currentManifest?: string;
|
||||||
|
newManifest: string;
|
||||||
|
}) {
|
||||||
|
if (!newManifest) {
|
||||||
|
return <TextTip color="blue">No manifest preview available</TextTip>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentManifest) {
|
||||||
|
return (
|
||||||
|
<DiffViewer
|
||||||
|
originalCode={currentManifest}
|
||||||
|
newCode={newManifest}
|
||||||
|
id="manifest-preview"
|
||||||
|
data-cy="manifest-diff-preview"
|
||||||
|
type="yaml"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeEditor
|
||||||
|
id="manifest-preview"
|
||||||
|
value={newManifest}
|
||||||
|
data-cy="manifest-preview"
|
||||||
|
type="yaml"
|
||||||
|
readonly
|
||||||
|
showToolbar={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
// Environment-scoped Helm queries (following kubernetes pattern)
|
||||||
|
base: (environmentId: EnvironmentId) =>
|
||||||
|
['environments', environmentId, 'kubernetes', 'helm'] as const,
|
||||||
|
|
||||||
|
// User's helm repositories/registries
|
||||||
|
registries: (userId: UserId) => ['helm', 'registries', userId] as const,
|
||||||
|
|
||||||
|
// Chart repository searches (global, not environment-specific)
|
||||||
|
repositories: (chart: string, repo?: string, useCache?: boolean) =>
|
||||||
|
['helm', 'repositories', chart, repo, useCache] as const,
|
||||||
|
|
||||||
|
// Chart listings from repositories (user-specific)
|
||||||
|
charts: (userId: UserId, repository: string) =>
|
||||||
|
['helm', 'charts', userId, repository] as const,
|
||||||
|
|
||||||
|
// Chart values (global, cached by chart/version)
|
||||||
|
chartValues: (repo: string, chart: string, version: string | 'latest') =>
|
||||||
|
['helm', 'chart-values', repo, chart, version] as const,
|
||||||
|
|
||||||
|
chartVersions: (
|
||||||
|
sourceId: number | string,
|
||||||
|
chart: string,
|
||||||
|
useCache?: boolean
|
||||||
|
) => ['helm', 'registries', sourceId, chart, 'versions', useCache] as const,
|
||||||
|
};
|
||||||
+4
-1
@@ -6,6 +6,8 @@ import { withGlobalError } from '@/react-tools/react-query';
|
|||||||
|
|
||||||
import { Chart, HelmChartsResponse } from '../types';
|
import { Chart, HelmChartsResponse } from '../types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook to fetch helm charts from the provided HTTP repository.
|
* React hook to fetch helm charts from the provided HTTP repository.
|
||||||
* Charts are loaded from the specified repository URL.
|
* Charts are loaded from the specified repository URL.
|
||||||
@@ -21,12 +23,13 @@ export function useHelmHTTPChartList(
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
) {
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [userId, repository, 'helm-charts'],
|
queryKey: queryKeys.charts(userId, repository),
|
||||||
queryFn: () => getChartsFromRepo(repository),
|
queryFn: () => getChartsFromRepo(repository),
|
||||||
enabled: !!userId && !!repository && enabled,
|
enabled: !!userId && !!repository && enabled,
|
||||||
// one request takes a long time, so fail early to get feedback to the user faster
|
// one request takes a long time, so fail early to get feedback to the user faster
|
||||||
retry: false,
|
retry: false,
|
||||||
...withGlobalError(`Unable to retrieve Helm charts from ${repository}`),
|
...withGlobalError(`Unable to retrieve Helm charts from ${repository}`),
|
||||||
|
cacheTime: 1000 * 60 * 60 * 8, // 8 hours so that the chart list populates faster (keep stale time the same to always revalidate)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
+22
-21
@@ -3,6 +3,8 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { withGlobalError } from '@/react-tools/react-query';
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
/** The name of the chart to get the values for */
|
/** The name of the chart to get the values for */
|
||||||
chart: string;
|
chart: string;
|
||||||
@@ -12,6 +14,26 @@ type Params = {
|
|||||||
version?: string;
|
version?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function useHelmChartValues(params: Params, isLatestVersion = false) {
|
||||||
|
const hasValidRepoUrl = !!params.repo;
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.chartValues(
|
||||||
|
params.repo,
|
||||||
|
params.chart,
|
||||||
|
// if the latest version is fetched, use the latest version key to cache the latest version
|
||||||
|
isLatestVersion ? 'latest' : params.version || 'latest'
|
||||||
|
),
|
||||||
|
queryFn: () => getHelmChartValues(params),
|
||||||
|
enabled: !!params.chart && hasValidRepoUrl,
|
||||||
|
select: (data) => ({
|
||||||
|
values: data,
|
||||||
|
}),
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often
|
||||||
|
...withGlobalError('Unable to get Helm chart values'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function getHelmChartValues(params: Params) {
|
async function getHelmChartValues(params: Params) {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get<string>(`/templates/helm/values`, {
|
const response = await axios.get<string>(`/templates/helm/values`, {
|
||||||
@@ -22,24 +44,3 @@ async function getHelmChartValues(params: Params) {
|
|||||||
throw parseAxiosError(err, 'Unable to get Helm chart values');
|
throw parseAxiosError(err, 'Unable to get Helm chart values');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHelmChartValues(params: Params, isLatestVersion = false) {
|
|
||||||
const hasValidRepoUrl = !!params.repo;
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [
|
|
||||||
'helm-chart-values',
|
|
||||||
params.repo,
|
|
||||||
params.chart,
|
|
||||||
// if the latest version is fetched, use the latest version key to cache the latest version
|
|
||||||
isLatestVersion ? 'latest' : params.version,
|
|
||||||
],
|
|
||||||
queryFn: () => getHelmChartValues(params),
|
|
||||||
enabled: !!params.chart && hasValidRepoUrl,
|
|
||||||
select: (data) => ({
|
|
||||||
values: data,
|
|
||||||
}),
|
|
||||||
retry: 1,
|
|
||||||
staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often
|
|
||||||
...withGlobalError('Unable to get Helm chart values'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+4
-2
@@ -5,6 +5,8 @@ import { compact, flatMap } from 'lodash';
|
|||||||
import { withGlobalError } from '@/react-tools/react-query';
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
interface HelmSearch {
|
interface HelmSearch {
|
||||||
entries: Entries;
|
entries: Entries;
|
||||||
}
|
}
|
||||||
@@ -44,9 +46,9 @@ export function useHelmRepoVersions(
|
|||||||
queries: useMemo(
|
queries: useMemo(
|
||||||
() =>
|
() =>
|
||||||
repoSources.map(({ repo }) => ({
|
repoSources.map(({ repo }) => ({
|
||||||
queryKey: ['helm', 'repositories', chart, repo, useCache],
|
queryKey: queryKeys.chartVersions(repo || '', chart),
|
||||||
queryFn: () => getSearchHelmRepo({ repo, chart, useCache }),
|
queryFn: () => getSearchHelmRepo({ repo, chart, useCache }),
|
||||||
enabled: !!chart && repoSources.length > 0,
|
enabled: !!chart && !!repo,
|
||||||
staleTime,
|
staleTime,
|
||||||
...withGlobalError(`Unable to retrieve versions from ${repo}`),
|
...withGlobalError(`Unable to retrieve versions from ${repo}`),
|
||||||
})),
|
})),
|
||||||
+17
-9
@@ -10,17 +10,19 @@ import { Option } from '@/react/components/form-components/PortainerSelect';
|
|||||||
import { HelmRegistriesResponse } from '../types';
|
import { HelmRegistriesResponse } from '../types';
|
||||||
import { RepoValue } from '../components/HelmRegistrySelect';
|
import { RepoValue } from '../components/HelmRegistrySelect';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch all Helm registries for the current user
|
* Hook to fetch all Helm registries for the current user
|
||||||
*/
|
*/
|
||||||
export function useUserHelmRepositories<T = string[]>({
|
export function useUserHelmRepositories<T = string[]>({
|
||||||
select,
|
select,
|
||||||
}: {
|
}: {
|
||||||
select?: (registries: string[]) => T;
|
select?: (registries: HelmRegistriesResponse) => T;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
return useQuery(
|
return useQuery(
|
||||||
['helm', 'registries'],
|
queryKeys.registries(user.Id),
|
||||||
async () => getUserHelmRepositories(user.Id),
|
async () => getUserHelmRepositories(user.Id),
|
||||||
{
|
{
|
||||||
enabled: !!user.Id,
|
enabled: !!user.Id,
|
||||||
@@ -33,7 +35,8 @@ export function useUserHelmRepositories<T = string[]>({
|
|||||||
export function useHelmRepoOptions() {
|
export function useHelmRepoOptions() {
|
||||||
return useUserHelmRepositories({
|
return useUserHelmRepositories({
|
||||||
select: (registries) => {
|
select: (registries) => {
|
||||||
const repoOptions = registries
|
const registryArray = flattenHelmRegistries(registries);
|
||||||
|
const repoOptions = registryArray
|
||||||
.map<Option<RepoValue>>((registry) => ({
|
.map<Option<RepoValue>>((registry) => ({
|
||||||
label: registry,
|
label: registry,
|
||||||
value: {
|
value: {
|
||||||
@@ -72,13 +75,18 @@ async function getUserHelmRepositories(userId: UserId) {
|
|||||||
const { data } = await axios.get<HelmRegistriesResponse>(
|
const { data } = await axios.get<HelmRegistriesResponse>(
|
||||||
`users/${userId}/helm/repositories`
|
`users/${userId}/helm/repositories`
|
||||||
);
|
);
|
||||||
// compact will remove the global repository if it's empty
|
return data;
|
||||||
const repos = compact([
|
|
||||||
data.GlobalRepository.toLowerCase(),
|
|
||||||
...data.UserRepositories.map((repo) => repo.URL.toLowerCase()),
|
|
||||||
]);
|
|
||||||
return [...new Set(repos)];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
|
throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** get the unique global and user registries in one array */
|
||||||
|
export function flattenHelmRegistries(registries: HelmRegistriesResponse) {
|
||||||
|
// compact will remove the global repository if it's empty
|
||||||
|
const repos = compact([
|
||||||
|
registries.GlobalRepository.toLowerCase(),
|
||||||
|
...registries.UserRepositories.map((repo) => repo.URL.toLowerCase()),
|
||||||
|
]);
|
||||||
|
return [...new Set(repos)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
|
||||||
|
|
||||||
|
import { UpdateHelmReleasePayload } from '../types';
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
// Environment-scoped Helm queries (following kubernetes pattern)
|
||||||
|
base: (environmentId: EnvironmentId) =>
|
||||||
|
[
|
||||||
|
...environmentQueryKeys.item(environmentId),
|
||||||
|
'kubernetes',
|
||||||
|
'helm',
|
||||||
|
] as const,
|
||||||
|
|
||||||
|
// Helm releases (environment-specific)
|
||||||
|
releases: (environmentId: EnvironmentId) =>
|
||||||
|
[...queryKeys.base(environmentId), 'releases'] as const,
|
||||||
|
|
||||||
|
release: (
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
namespace: string,
|
||||||
|
name: string,
|
||||||
|
revision?: number,
|
||||||
|
showResources?: boolean
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
...queryKeys.releases(environmentId),
|
||||||
|
namespace,
|
||||||
|
name,
|
||||||
|
revision,
|
||||||
|
showResources,
|
||||||
|
] as const,
|
||||||
|
|
||||||
|
releaseHistory: (
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
namespace: string,
|
||||||
|
name: string
|
||||||
|
) =>
|
||||||
|
[...queryKeys.release(environmentId, namespace, name), 'history'] as const,
|
||||||
|
|
||||||
|
// Environment-specific install operations
|
||||||
|
installDryRun: (
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
payload: UpdateHelmReleasePayload
|
||||||
|
) =>
|
||||||
|
[...queryKeys.base(environmentId), 'install', 'dry-run', payload] as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import PortainerError from '@/portainer/error';
|
||||||
|
|
||||||
|
import { HelmRelease, UpdateHelmReleasePayload } from '../types';
|
||||||
|
|
||||||
|
import { updateHelmRelease } from './useUpdateHelmReleaseMutation';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useHelmDryRun(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
payload: UpdateHelmReleasePayload
|
||||||
|
): UseQueryResult<HelmRelease, PortainerError> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.installDryRun(environmentId, payload),
|
||||||
|
queryFn: () =>
|
||||||
|
// use updateHelmRelease as if it were a get request with dryRun. The payload is debounced to prevent too many requests.
|
||||||
|
updateHelmRelease(
|
||||||
|
environmentId,
|
||||||
|
payload,
|
||||||
|
{ dryRun: true },
|
||||||
|
{
|
||||||
|
errorMessage: 'Unable to get Helm manifest preview',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
// don't display error toast, handle it within the component
|
||||||
|
enabled:
|
||||||
|
!!payload.repo &&
|
||||||
|
!!payload.chart &&
|
||||||
|
!!payload.name &&
|
||||||
|
!!payload.namespace &&
|
||||||
|
!!payload.version,
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 1000 * 60, // small 1 minute stale time to reduce the number of requests
|
||||||
|
});
|
||||||
|
}
|
||||||
+4
-2
@@ -4,7 +4,9 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
|||||||
import { withGlobalError } from '@/react-tools/react-query';
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
import { HelmRelease } from '../../types';
|
import { HelmRelease } from '../types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
export function useHelmHistory(
|
export function useHelmHistory(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
@@ -12,7 +14,7 @@ export function useHelmHistory(
|
|||||||
namespace: string
|
namespace: string
|
||||||
) {
|
) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[environmentId, 'helm', 'releases', namespace, name, 'history'],
|
queryKeys.releaseHistory(environmentId, namespace, name),
|
||||||
() => getHelmHistory(environmentId, name, namespace),
|
() => getHelmHistory(environmentId, name, namespace),
|
||||||
{
|
{
|
||||||
enabled: !!environmentId && !!name && !!namespace,
|
enabled: !!environmentId && !!name && !!namespace,
|
||||||
+4
-10
@@ -4,7 +4,9 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
|||||||
import { withGlobalError } from '@/react-tools/react-query';
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
import { HelmRelease } from '../../types';
|
import { HelmRelease } from '../types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
type Options<T> = {
|
type Options<T> = {
|
||||||
select?: (data: HelmRelease) => T;
|
select?: (data: HelmRelease) => T;
|
||||||
@@ -27,15 +29,7 @@ export function useHelmRelease<T = HelmRelease>(
|
|||||||
const { select, showResources, refetchInterval, revision, staleTime } =
|
const { select, showResources, refetchInterval, revision, staleTime } =
|
||||||
options;
|
options;
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[
|
queryKeys.release(environmentId, namespace, name, revision, showResources),
|
||||||
environmentId,
|
|
||||||
'helm',
|
|
||||||
'releases',
|
|
||||||
namespace,
|
|
||||||
name,
|
|
||||||
revision,
|
|
||||||
showResources,
|
|
||||||
],
|
|
||||||
() =>
|
() =>
|
||||||
getHelmRelease(environmentId, name, {
|
getHelmRelease(environmentId, name, {
|
||||||
namespace,
|
namespace,
|
||||||
+5
-3
@@ -7,7 +7,9 @@ import {
|
|||||||
withGlobalError,
|
withGlobalError,
|
||||||
} from '@/react-tools/react-query';
|
} from '@/react-tools/react-query';
|
||||||
import axios from '@/portainer/services/axios';
|
import axios from '@/portainer/services/axios';
|
||||||
import { queryKeys } from '@/react/kubernetes/applications/queries/query-keys';
|
import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameters for helm rollback operation
|
* Parameters for helm rollback operation
|
||||||
@@ -54,8 +56,8 @@ export function useHelmRollbackMutation(environmentId: EnvironmentId) {
|
|||||||
rollbackRelease({ releaseName, params, environmentId }),
|
rollbackRelease({ releaseName, params, environmentId }),
|
||||||
...withGlobalError('Unable to rollback Helm release'),
|
...withGlobalError('Unable to rollback Helm release'),
|
||||||
...withInvalidate(queryClient, [
|
...withInvalidate(queryClient, [
|
||||||
[environmentId, 'helm', 'releases'],
|
queryKeys.releases(environmentId),
|
||||||
queryKeys.applications(environmentId),
|
applicationsQueryKeys.applications(environmentId),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+3
@@ -5,6 +5,8 @@ import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
|||||||
import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys';
|
import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
export function useUninstallHelmAppMutation(environmentId: EnvironmentId) {
|
export function useUninstallHelmAppMutation(environmentId: EnvironmentId) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -16,6 +18,7 @@ export function useUninstallHelmAppMutation(environmentId: EnvironmentId) {
|
|||||||
namespace?: string;
|
namespace?: string;
|
||||||
}) => uninstallHelmApplication(environmentId, releaseName, namespace),
|
}) => uninstallHelmApplication(environmentId, releaseName, namespace),
|
||||||
...withInvalidate(queryClient, [
|
...withInvalidate(queryClient, [
|
||||||
|
queryKeys.releases(environmentId),
|
||||||
applicationsQueryKeys.applications(environmentId),
|
applicationsQueryKeys.applications(environmentId),
|
||||||
]),
|
]),
|
||||||
...withGlobalError('Unable to uninstall helm application'),
|
...withGlobalError('Unable to uninstall helm application'),
|
||||||
+24
-6
@@ -7,30 +7,48 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
|||||||
|
|
||||||
import { HelmRelease, UpdateHelmReleasePayload } from '../types';
|
import { HelmRelease, UpdateHelmReleasePayload } from '../types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) {
|
export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: UpdateHelmReleasePayload) =>
|
mutationFn: (payload: UpdateHelmReleasePayload) =>
|
||||||
updateHelmRelease(environmentId, payload),
|
updateHelmRelease(environmentId, payload),
|
||||||
...withInvalidate(queryClient, [
|
...withInvalidate(queryClient, [
|
||||||
[environmentId, 'helm', 'releases'],
|
queryKeys.releases(environmentId),
|
||||||
applicationsQueryKeys.applications(environmentId),
|
applicationsQueryKeys.applications(environmentId),
|
||||||
]),
|
]),
|
||||||
...withGlobalError('Unable to uninstall helm application'),
|
...withGlobalError('Unable to update Helm release'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateHelmRelease(
|
type UpdateHelmReleaseParams = {
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateHelmReleaseOptions = {
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateHelmRelease(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
payload: UpdateHelmReleasePayload
|
payload: UpdateHelmReleasePayload,
|
||||||
|
params: UpdateHelmReleaseParams = {},
|
||||||
|
options: UpdateHelmReleaseOptions = {}
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.post<HelmRelease>(
|
const { data } = await axios.post<HelmRelease>(
|
||||||
`endpoints/${environmentId}/kubernetes/helm`,
|
`endpoints/${environmentId}/kubernetes/helm`,
|
||||||
payload
|
payload,
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw parseAxiosError(err, 'Unable to update helm release');
|
throw parseAxiosError(
|
||||||
|
err,
|
||||||
|
options.errorMessage ?? 'Unable to update helm release'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-7
@@ -4,6 +4,8 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|||||||
import { success as notifySuccess } from '@/portainer/services/notifications';
|
import { success as notifySuccess } from '@/portainer/services/notifications';
|
||||||
import { withError } from '@/react-tools/react-query';
|
import { withError } from '@/react-tools/react-query';
|
||||||
import { pluralize } from '@/portainer/helpers/strings';
|
import { pluralize } from '@/portainer/helpers/strings';
|
||||||
|
import { queryKeys } from '@/react/kubernetes/helm/helmChartSourceQueries/query-keys';
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateHelmRepositoryPayload,
|
CreateHelmRepositoryPayload,
|
||||||
@@ -52,11 +54,12 @@ export async function deleteHelmRepositories(repos: HelmRepository[]) {
|
|||||||
|
|
||||||
export function useDeleteHelmRepositoryMutation() {
|
export function useDeleteHelmRepositoryMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
return useMutation(deleteHelmRepository, {
|
return useMutation(deleteHelmRepository, {
|
||||||
onSuccess: (_, helmRepository) => {
|
onSuccess: (_, helmRepository) => {
|
||||||
notifySuccess('Helm repository deleted successfully', helmRepository.URL);
|
notifySuccess('Helm repository deleted successfully', helmRepository.URL);
|
||||||
return queryClient.invalidateQueries(['helmrepositories']);
|
return queryClient.invalidateQueries(queryKeys.registries(user.Id));
|
||||||
},
|
},
|
||||||
...withError('Unable to delete Helm repository'),
|
...withError('Unable to delete Helm repository'),
|
||||||
});
|
});
|
||||||
@@ -64,6 +67,7 @@ export function useDeleteHelmRepositoryMutation() {
|
|||||||
|
|
||||||
export function useDeleteHelmRepositoriesMutation() {
|
export function useDeleteHelmRepositoriesMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
return useMutation(deleteHelmRepositories, {
|
return useMutation(deleteHelmRepositories, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -75,26 +79,31 @@ export function useDeleteHelmRepositoriesMutation() {
|
|||||||
'repositories'
|
'repositories'
|
||||||
)} deleted successfully`
|
)} deleted successfully`
|
||||||
);
|
);
|
||||||
return queryClient.invalidateQueries(['helmrepositories']);
|
return queryClient.invalidateQueries(queryKeys.registries(user.Id));
|
||||||
},
|
},
|
||||||
...withError('Unable to delete Helm repositories'),
|
...withError('Unable to delete Helm repositories'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHelmRepositories(userId: number) {
|
export function useHelmRepositories(userId: number) {
|
||||||
return useQuery(['helmrepositories'], () => getHelmRepositories(userId), {
|
return useQuery(
|
||||||
staleTime: 20,
|
queryKeys.registries(userId),
|
||||||
...withError('Unable to retrieve Helm repositories'),
|
() => getHelmRepositories(userId),
|
||||||
});
|
{
|
||||||
|
staleTime: 20,
|
||||||
|
...withError('Unable to retrieve Helm repositories'),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateHelmRepositoryMutation() {
|
export function useCreateHelmRepositoryMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
return useMutation(createHelmRepository, {
|
return useMutation(createHelmRepository, {
|
||||||
onSuccess: (_, payload) => {
|
onSuccess: (_, payload) => {
|
||||||
notifySuccess('Helm repository created successfully', payload.URL);
|
notifySuccess('Helm repository created successfully', payload.URL);
|
||||||
return queryClient.invalidateQueries(['helmrepositories']);
|
return queryClient.invalidateQueries(queryKeys.registries(user.Id));
|
||||||
},
|
},
|
||||||
...withError('Unable to create Helm repository'),
|
...withError('Unable to create Helm repository'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,36 @@
|
|||||||
export function mockCodeMirror() {
|
export function mockCodeMirror() {
|
||||||
vi.mock('@uiw/react-codemirror', () => ({
|
vi.mock('@uiw/react-codemirror', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: () => <div />,
|
default: ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
readOnly,
|
||||||
|
placeholder,
|
||||||
|
height,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
'data-cy': dataCy,
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
height?: string;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
'data-cy'?: string;
|
||||||
|
}) => (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={height ? { height } : undefined}
|
||||||
|
className={className}
|
||||||
|
id={id}
|
||||||
|
data-cy={dataCy}
|
||||||
|
/>
|
||||||
|
),
|
||||||
oneDarkHighlightStyle: {},
|
oneDarkHighlightStyle: {},
|
||||||
keymap: {
|
keymap: {
|
||||||
of: () => ({}),
|
of: () => ({}),
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import 'vitest-dom/extend-expect';
|
import 'vitest-dom/extend-expect';
|
||||||
|
|
||||||
|
import { mockCodeMirror } from './mock-codemirror';
|
||||||
|
|
||||||
|
// Initialize CodeMirror module mocks
|
||||||
|
mockCodeMirror();
|
||||||
|
|
||||||
// Mock Range APIs that CodeMirror needs but JSDOM doesn't provide
|
// Mock Range APIs that CodeMirror needs but JSDOM doesn't provide
|
||||||
Range.prototype.getBoundingClientRect = () => ({
|
Range.prototype.getBoundingClientRect = () => ({
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|||||||
+1
-1
@@ -199,7 +199,7 @@
|
|||||||
"html-loader": "^0.5.5",
|
"html-loader": "^0.5.5",
|
||||||
"html-webpack-plugin": "^5.5.3",
|
"html-webpack-plugin": "^5.5.3",
|
||||||
"husky": "^8.0.0",
|
"husky": "^8.0.0",
|
||||||
"kubernetes-types": "^1.26.0",
|
"kubernetes-types": "^1.30.0",
|
||||||
"lint-staged": "^14.0.1",
|
"lint-staged": "^14.0.1",
|
||||||
"lodash-webpack-plugin": "^0.11.6",
|
"lodash-webpack-plugin": "^0.11.6",
|
||||||
"mini-css-extract-plugin": "^2.7.6",
|
"mini-css-extract-plugin": "^2.7.6",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type InstallOptions struct {
|
|||||||
ValuesFile string
|
ValuesFile string
|
||||||
PostRenderer string
|
PostRenderer string
|
||||||
Atomic bool
|
Atomic bool
|
||||||
|
DryRun bool
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
KubernetesClusterAccess *KubernetesClusterAccess
|
KubernetesClusterAccess *KubernetesClusterAccess
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
|
|||||||
Str("namespace", installOpts.Namespace).
|
Str("namespace", installOpts.Namespace).
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("Failed to install helm chart for helm release installation")
|
Msg("Failed to install helm chart for helm release installation")
|
||||||
|
if installOpts.DryRun {
|
||||||
|
// remove installation wording for dry run. The inner error has enough context.
|
||||||
|
return nil, errors.Wrap(err, "dry-run failed")
|
||||||
|
}
|
||||||
return nil, errors.Wrap(err, "helm was not able to install the chart for helm release installation")
|
return nil, errors.Wrap(err, "helm was not able to install the chart for helm release installation")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +146,7 @@ func initInstallClient(actionConfig *action.Configuration, installOpts options.I
|
|||||||
installClient.Wait = installOpts.Wait
|
installClient.Wait = installOpts.Wait
|
||||||
installClient.Timeout = installOpts.Timeout
|
installClient.Timeout = installOpts.Timeout
|
||||||
installClient.Version = installOpts.Version
|
installClient.Version = installOpts.Version
|
||||||
|
installClient.DryRun = installOpts.DryRun
|
||||||
err := configureChartPathOptions(&installClient.ChartPathOptions, installOpts.Version, installOpts.Repo, installOpts.Registry)
|
err := configureChartPathOptions(&installClient.ChartPathOptions, installOpts.Version, installOpts.Repo, installOpts.Registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "failed to configure chart path options for helm release installation")
|
return nil, errors.Wrap(err, "failed to configure chart path options for helm release installation")
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.I
|
|||||||
upgradeClient.Atomic = upgradeOpts.Atomic
|
upgradeClient.Atomic = upgradeOpts.Atomic
|
||||||
upgradeClient.Wait = upgradeOpts.Wait
|
upgradeClient.Wait = upgradeOpts.Wait
|
||||||
upgradeClient.Version = upgradeOpts.Version
|
upgradeClient.Version = upgradeOpts.Version
|
||||||
|
upgradeClient.DryRun = upgradeOpts.DryRun
|
||||||
err := configureChartPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Version, upgradeOpts.Repo, upgradeOpts.Registry)
|
err := configureChartPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Version, upgradeOpts.Repo, upgradeOpts.Registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "failed to configure chart path options for helm release upgrade")
|
return nil, errors.Wrap(err, "failed to configure chart path options for helm release upgrade")
|
||||||
|
|||||||
@@ -12868,10 +12868,10 @@ klona@^2.0.6:
|
|||||||
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22"
|
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22"
|
||||||
integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==
|
integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==
|
||||||
|
|
||||||
kubernetes-types@^1.26.0:
|
kubernetes-types@^1.30.0:
|
||||||
version "1.26.0"
|
version "1.30.0"
|
||||||
resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.26.0.tgz#47b7db20eb084931cfebf67937cc6b9091dc3da3"
|
resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz#f686cacb08ffc5f7e89254899c2153c723420116"
|
||||||
integrity sha512-jv0XaTIGW/p18jaiKRD85hLTYWx0yEj+cb6PDX3GdNa3dWoRxnD4Gv7+bE6C/ehcsp2skcdy34vT25jbPofDIQ==
|
integrity sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==
|
||||||
|
|
||||||
kuler@^2.0.0:
|
kuler@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user