diff --git a/store/helm.go b/store/helm.go
index 92274449..849a1b07 100644
--- a/store/helm.go
+++ b/store/helm.go
@@ -44,13 +44,15 @@ import (
)
const (
- helmOperationTimeout = 5 * time.Minute
- helmInstallTimeout = 10 * time.Minute
- helmCompatibilityTimeout = 2 * time.Minute
- helmDiagnosticsTimeout = 15 * time.Second
- helmDiagnosticsMaxEvents = 20
- helmDiagnosticsMessageLen = 240
- helmDiagnosticsEventLen = 360
+ helmOperationTimeout = 5 * time.Minute
+ helmInstallTimeout = 10 * time.Minute
+ helmCompatibilityTimeout = 2 * time.Minute
+ helmChartLoadTimeout = 2 * time.Minute
+ helmDiagnosticsTimeout = 15 * time.Second
+ helmInstallOperationTimeout = helmChartLoadTimeout + helmCompatibilityTimeout + helmInstallTimeout + helmDiagnosticsTimeout
+ helmDiagnosticsMaxEvents = 20
+ helmDiagnosticsMessageLen = 240
+ helmDiagnosticsEventLen = 360
)
// ---------- Types ----------
@@ -150,8 +152,8 @@ func newHelmConfigWithLog(cfg *rest.Config, namespace string, logFn func(string,
return actionConfig, nil
}
-func attachHelmCapabilities(actionConfig *action.Configuration, cfg *rest.Config, namespace string, logFn func(string, ...interface{})) {
- capabilities, err := buildHelmCapabilities(cfg, namespace, logFn)
+func attachHelmCapabilities(ctx context.Context, actionConfig *action.Configuration, cfg *rest.Config, logFn func(string, ...interface{})) {
+ capabilities, err := buildHelmCapabilities(ctx, cfg, logFn)
if err != nil {
logFn("WARNING: failed to build helm capabilities, using defaults: %v", err)
capabilities = chartutil.DefaultCapabilities
@@ -163,11 +165,19 @@ func helmWarningLog(format string, args ...interface{}) {
logrus.Warnf(format, args...)
}
-func buildHelmCapabilities(cfg *rest.Config, namespace string, logFn func(string, ...interface{})) (*chartutil.Capabilities, error) {
- dc, err := newRESTClientGetter(cfg, namespace).ToDiscoveryClient()
+func buildHelmCapabilities(ctx context.Context, cfg *rest.Config, logFn func(string, ...interface{})) (*chartutil.Capabilities, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ httpClient, err := rest.HTTPClientFor(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("helm discovery HTTP client: %w", err)
+ }
+ discoveryClient, err := discovery.NewDiscoveryClientForConfigAndClient(cfg, httpClientWithContext(ctx, httpClient))
if err != nil {
return nil, fmt.Errorf("helm discovery client: %w", err)
}
+ dc := memory.NewMemCacheClient(discoveryClient)
dc.Invalidate()
kubeVersion, err := dc.ServerVersion()
@@ -248,8 +258,33 @@ func shouldKeepHelmKubePrerelease(preRelease string) bool {
// ---------- HTTP helper ----------
-func helmGet(url string) (*http.Response, error) {
- req, err := http.NewRequest(http.MethodGet, url, nil)
+type contextRoundTripper struct {
+ ctx context.Context
+ next http.RoundTripper
+}
+
+func (t contextRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ return t.next.RoundTrip(req.Clone(t.ctx))
+}
+
+func httpClientWithContext(ctx context.Context, base *http.Client) *http.Client {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if base == nil {
+ base = http.DefaultClient
+ }
+ client := *base
+ transport := base.Transport
+ if transport == nil {
+ transport = http.DefaultTransport
+ }
+ client.Transport = contextRoundTripper{ctx: ctx, next: transport}
+ return &client
+}
+
+func helmGet(ctx context.Context, url string) (*http.Response, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
@@ -259,9 +294,9 @@ func helmGet(url string) (*http.Response, error) {
// ---------- Repo index ----------
-func fetchIndexFile(repoURL string) (*repo.IndexFile, error) {
+func fetchIndexFile(ctx context.Context, repoURL string) (*repo.IndexFile, error) {
indexURL := strings.TrimRight(repoURL, "/") + "/index.yaml"
- resp, err := helmGet(indexURL)
+ resp, err := helmGet(ctx, indexURL)
if err != nil {
return nil, fmt.Errorf(
"fetch index %q: %w",
@@ -304,11 +339,13 @@ func fetchIndexFile(repoURL string) (*repo.IndexFile, error) {
// FetchRepoIndex returns all charts listed in a Helm repo's index.yaml, or, for an
// "oci://" repoURL, the single chart hosted at that OCI reference.
func FetchRepoIndex(repoURL string) ([]HelmChartSummary, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), helmChartLoadTimeout)
+ defer cancel()
if isOCIRepo(repoURL) {
- return fetchOCIChartSummary(repoURL)
+ return fetchOCIChartSummary(ctx, repoURL)
}
- idx, err := fetchIndexFile(repoURL)
+ idx, err := fetchIndexFile(ctx, repoURL)
if err != nil {
return nil, err
}
@@ -388,16 +425,16 @@ func isOCIChartTag(tag string) bool {
return true
}
-func newOCIRegistryClient() (*registry.Client, error) {
- return registry.NewClient(registry.ClientOptHTTPClient(proxypkg.ProxyHttpClient))
+func newOCIRegistryClient(ctx context.Context) (*registry.Client, error) {
+ return registry.NewClient(registry.ClientOptHTTPClient(httpClientWithContext(ctx, proxypkg.ProxyHttpClient)))
}
// pullOCIChart pulls the chart hosted at repoURL, resolving to the newest published
// semver tag when version is empty.
-func pullOCIChart(repoURL, version string) (*registry.PullResult, error) {
+func pullOCIChart(ctx context.Context, repoURL, version string) (*registry.PullResult, error) {
ref, resolvedVersion := resolveOCIChartRef(repoURL, version)
- rc, err := newOCIRegistryClient()
+ rc, err := newOCIRegistryClient(ctx)
if err != nil {
return nil, fmt.Errorf("oci registry client: %w", err)
}
@@ -465,16 +502,16 @@ func latestOCISemverTag(tags []string) string {
return versionedTags[0].tag
}
-func loadOCIChart(repoURL, version string) (*chart.Chart, error) {
- pull, err := pullOCIChart(repoURL, version)
+func loadOCIChart(ctx context.Context, repoURL, version string) (*chart.Chart, error) {
+ pull, err := pullOCIChart(ctx, repoURL, version)
if err != nil {
return nil, err
}
return loader.LoadArchive(bytes.NewReader(pull.Chart.Data))
}
-func fetchOCIChartSummary(repoURL string) ([]HelmChartSummary, error) {
- pull, err := pullOCIChart(repoURL, "")
+func fetchOCIChartSummary(ctx context.Context, repoURL string) ([]HelmChartSummary, error) {
+ pull, err := pullOCIChart(ctx, repoURL, "")
if err != nil {
return nil, err
}
@@ -682,8 +719,18 @@ func redactionCandidates(raw string) []string {
}
func loadChart(chartName, repoURL, version string) (*chart.Chart, error) {
+ return loadChartWithContext(context.Background(), chartName, repoURL, version)
+}
+
+func loadChartWithContext(parent context.Context, chartName, repoURL, version string) (*chart.Chart, error) {
+ if parent == nil {
+ parent = context.Background()
+ }
+ ctx, cancel := context.WithTimeout(parent, helmChartLoadTimeout)
+ defer cancel()
+
if isOCIRepo(repoURL) {
- ch, err := loadOCIChart(repoURL, version)
+ ch, err := loadOCIChart(ctx, repoURL, version)
if err != nil {
return nil, fmt.Errorf(
"load chart %q from OCI repo %q version %q: %w",
@@ -696,7 +743,7 @@ func loadChart(chartName, repoURL, version string) (*chart.Chart, error) {
return ch, nil
}
- idx, err := fetchIndexFile(repoURL)
+ idx, err := fetchIndexFile(ctx, repoURL)
if err != nil {
return nil, fmt.Errorf("load chart %q from repo %q version %q: fetch index.yaml failed: %w", chartName, redactURLForError(repoURL), version, err)
}
@@ -726,7 +773,7 @@ func loadChart(chartName, repoURL, version string) (*chart.Chart, error) {
if ociVersion == "" {
ociVersion = entry.Version
}
- ch, err := loadOCIChart(chartURL, ociVersion)
+ ch, err := loadOCIChart(ctx, chartURL, ociVersion)
if err != nil {
return nil, fmt.Errorf(
"load chart %q from repo %q version %q: index.yaml resolved to OCI chart URL %q: %w",
@@ -743,7 +790,7 @@ func loadChart(chartName, repoURL, version string) (*chart.Chart, error) {
chartURL = strings.TrimRight(repoURL, "/") + "/" + strings.TrimLeft(chartURL, "/")
}
- resp, err := helmGet(chartURL)
+ resp, err := helmGet(ctx, chartURL)
if err != nil {
return nil, fmt.Errorf(
"load chart %q from repo %q version %q: download chart archive %q failed: %w",
@@ -1218,8 +1265,11 @@ func InstallHelmChart(cfg *rest.Config, releaseName, namespace, chartName, repoU
return err
}
- attachHelmCapabilities(actionConfig, cfg, namespace, helmWarningLog)
- if err := validateHelmChartCompatibility(context.Background(), cfg, actionConfig, releaseName, namespace, ch, vals); err != nil {
+ compatibilityCtx, cancelCompatibility := context.WithTimeout(context.Background(), helmCompatibilityTimeout)
+ attachHelmCapabilities(compatibilityCtx, actionConfig, cfg, helmWarningLog)
+ err = validateHelmChartCompatibility(compatibilityCtx, cfg, actionConfig, releaseName, namespace, ch, vals)
+ cancelCompatibility()
+ if err != nil {
return err
}
install := action.NewInstall(actionConfig)
@@ -1264,7 +1314,8 @@ func InstallHelmChartStream(ctx context.Context, lifecycle HelmInstallLifecycle,
if streamCtx == nil {
streamCtx = context.Background()
}
- installCtx := context.WithoutCancel(streamCtx)
+ installCtx, cancelInstall := context.WithTimeout(context.WithoutCancel(streamCtx), helmInstallOperationTimeout)
+ defer cancelInstall()
send := func(line string) bool {
if err := lifecycle.RecordLog(line); err != nil {
logrus.Warnf("failed to persist Helm operation log: %v", err)
@@ -1294,7 +1345,7 @@ func InstallHelmChartStream(ctx context.Context, lifecycle HelmInstallLifecycle,
}
return
}
- helmChart, err := loadChart(chartName, repoURL, version)
+ helmChart, err := loadChartWithContext(installCtx, chartName, repoURL, version)
if err != nil {
send("ERROR: " + err.Error())
if finishErr := lifecycle.Finish(err); finishErr != nil {
@@ -1310,8 +1361,11 @@ func InstallHelmChartStream(ctx context.Context, lifecycle HelmInstallLifecycle,
}
return
}
- attachHelmCapabilities(actionConfig, cfg, namespace, logFn)
- if err := validateHelmChartCompatibility(installCtx, cfg, actionConfig, releaseName, namespace, helmChart, vals); err != nil {
+ compatibilityCtx, cancelCompatibility := context.WithTimeout(installCtx, helmCompatibilityTimeout)
+ attachHelmCapabilities(compatibilityCtx, actionConfig, cfg, logFn)
+ err = validateHelmChartCompatibility(compatibilityCtx, cfg, actionConfig, releaseName, namespace, helmChart, vals)
+ cancelCompatibility()
+ if err != nil {
send("ERROR: " + err.Error())
_ = lifecycle.Finish(err)
return
@@ -1374,8 +1428,11 @@ func UpgradeHelmRelease(cfg *rest.Config, releaseName, namespace, chartName, rep
return err
}
- attachHelmCapabilities(actionConfig, cfg, namespace, helmWarningLog)
- if err := validateHelmReleaseCompatibility(context.Background(), actionConfig, releaseName, namespace, ch, vals); err != nil {
+ compatibilityCtx, cancelCompatibility := context.WithTimeout(context.Background(), helmCompatibilityTimeout)
+ attachHelmCapabilities(compatibilityCtx, actionConfig, cfg, helmWarningLog)
+ err = validateHelmReleaseCompatibility(compatibilityCtx, actionConfig, releaseName, namespace, ch, vals)
+ cancelCompatibility()
+ if err != nil {
return err
}
upgrade := action.NewUpgrade(actionConfig)
diff --git a/web/src/HelmInstallModal.form.test.js b/web/src/HelmInstallModal.form.test.js
new file mode 100644
index 00000000..785019f2
--- /dev/null
+++ b/web/src/HelmInstallModal.form.test.js
@@ -0,0 +1,227 @@
+/* eslint-env jest */
+
+import React, {act} from "react";
+import {createRoot} from "react-dom/client";
+import HelmInstallModal from "./HelmInstallModal";
+import * as HelmBackend from "./backend/HelmBackend";
+import * as NamespaceBackend from "./backend/NamespaceBackend";
+
+let mockForm;
+
+jest.mock("antd", () => {
+ const React = require("react");
+ const component = tag => ({children}) => React.createElement(tag, null, children);
+ const Form = component("form");
+ Form.useForm = () => [mockForm];
+ Form.Item = component("div");
+ const Typography = {Text: component("span")};
+ return {
+ Alert: component("div"),
+ Button: ({children, onClick}) => React.createElement("button", {onClick}, children),
+ Form,
+ Input: component("input"),
+ Modal: ({children, footer}) => React.createElement("div", null, children, footer),
+ Select: component("select"),
+ Spin: component("span"),
+ Typography,
+ };
+});
+
+jest.mock("react-i18next", () => ({
+ useTranslation: () => ({t: key => key}),
+}));
+
+jest.mock("./backend/HelmBackend");
+jest.mock("./backend/NamespaceBackend");
+
+describe("HelmInstallModal form initialization", () => {
+ let container;
+ let root;
+ let formValues;
+ let touchedFields;
+ let resolveNamespaces;
+
+ beforeEach(() => {
+ global.IS_REACT_ACT_ENVIRONMENT = true;
+ Element.prototype.scrollIntoView = jest.fn();
+ formValues = {};
+ touchedFields = new Set();
+ mockForm = {
+ getFieldValue: jest.fn(name => formValues[name]),
+ isFieldTouched: jest.fn(name => touchedFields.has(name)),
+ setFieldsValue: jest.fn(values => Object.assign(formValues, values)),
+ validateFields: jest.fn(),
+ };
+ NamespaceBackend.getNamespaces.mockReturnValue(new Promise(resolve => {
+ resolveNamespaces = resolve;
+ }));
+ HelmBackend.getHelmChartValues.mockResolvedValue({status: "ok", data: ""});
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(async() => {
+ await act(async() => root.unmount());
+ container.remove();
+ jest.clearAllMocks();
+ jest.useRealTimers();
+ });
+
+ test("a late namespace response does not overwrite a release name entered by the user", async() => {
+ await act(async() => {
+ root.render(
+
+ );
+ });
+
+ formValues.releaseName = "e2e-casdoor-12345678";
+ touchedFields.add("releaseName");
+
+ await act(async() => {
+ resolveNamespaces({status: "ok", data: [{name: "default"}]});
+ await Promise.resolve();
+ });
+
+ expect(formValues.releaseName).toBe("e2e-casdoor-12345678");
+ expect(formValues.namespace).toBe("default");
+ });
+
+ test("a response from the previous chart does not replace the current chart values", async() => {
+ let resolveOldValues;
+ let resolveNewValues;
+ NamespaceBackend.getNamespaces.mockResolvedValue({status: "ok", data: [{name: "default"}]});
+ HelmBackend.getHelmChartValues
+ .mockReturnValueOnce(new Promise(resolve => {
+ resolveOldValues = resolve;
+ }))
+ .mockReturnValueOnce(new Promise(resolve => {
+ resolveNewValues = resolve;
+ }));
+
+ await act(async() => {
+ root.render(
+
+ );
+ });
+ await act(async() => {
+ root.render(
+
+ );
+ });
+
+ await act(async() => {
+ resolveNewValues({status: "ok", data: "current: true"});
+ await Promise.resolve();
+ });
+ expect(container.querySelector("textarea").value).toBe("current: true");
+
+ await act(async() => {
+ resolveOldValues({status: "ok", data: "stale: true"});
+ await Promise.resolve();
+ });
+ expect(container.querySelector("textarea").value).toBe("current: true");
+ });
+
+ test("a stalled install stream falls back to the persisted task", async() => {
+ jest.useFakeTimers();
+ NamespaceBackend.getNamespaces.mockResolvedValue({status: "ok", data: [{name: "default"}]});
+ mockForm.validateFields.mockResolvedValue({
+ releaseName: "demo-release",
+ namespace: "default",
+ version: "1.0.0",
+ });
+ HelmBackend.installHelmChartStream.mockImplementation((_payload, onLine) => {
+ onLine("TASK_ID:42");
+ return new Promise(() => {});
+ });
+ HelmBackend.getHelmOperationTask.mockReturnValue(new Promise(() => {}));
+
+ await act(async() => {
+ root.render(
+
+ );
+ });
+ const installButton = [...container.querySelectorAll("button")]
+ .find(button => button.textContent === "helm:Install");
+ await act(async() => {
+ installButton.click();
+ await Promise.resolve();
+ });
+
+ await act(async() => {
+ jest.advanceTimersByTime(60_000);
+ await Promise.resolve();
+ });
+
+ expect(HelmBackend.getHelmOperationTask).toHaveBeenCalledWith("42");
+ });
+
+ test("completion from a previous chart stream does not finish the current modal", async() => {
+ let resolveOldStream;
+ NamespaceBackend.getNamespaces.mockResolvedValue({status: "ok", data: [{name: "default"}]});
+ mockForm.validateFields.mockResolvedValue({
+ releaseName: "old-release",
+ namespace: "default",
+ version: "1.0.0",
+ });
+ HelmBackend.installHelmChartStream.mockReturnValue(new Promise(resolve => {
+ resolveOldStream = resolve;
+ }));
+
+ await act(async() => {
+ root.render(
+
+ );
+ });
+ const installButton = [...container.querySelectorAll("button")]
+ .find(button => button.textContent === "helm:Install");
+ await act(async() => {
+ installButton.click();
+ await Promise.resolve();
+ });
+ await act(async() => {
+ root.render(
+
+ );
+ });
+
+ await act(async() => {
+ resolveOldStream("DONE");
+ await Promise.resolve();
+ });
+
+ expect([...container.querySelectorAll("button")]
+ .some(button => button.textContent === "general:Done")).toBe(false);
+ });
+});
diff --git a/web/src/HelmInstallModal.js b/web/src/HelmInstallModal.js
index 02390f3a..3352e0f3 100644
--- a/web/src/HelmInstallModal.js
+++ b/web/src/HelmInstallModal.js
@@ -14,6 +14,7 @@ import {
const {Text} = Typography;
const helmOperationTaskNotFoundCode = "helm_task_not_found";
+const helmInstallStreamIdleTimeout = 30 * 1000;
export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
const {t} = useTranslation();
@@ -34,7 +35,10 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
const taskIdentityRef = useRef(null);
const pollTimerRef = useRef(null);
const pollGenerationRef = useRef(0);
+ const initializationGenerationRef = useRef(0);
const streamAbortRef = useRef(null);
+ const streamIdleTimerRef = useRef(null);
+ const streamIdleControllerRef = useRef(null);
const mountedRef = useRef(true);
const submittingRef = useRef(false);
@@ -46,6 +50,15 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
}
};
+ const stopStreamIdleTimer = (controller = null) => {
+ if (controller && streamIdleControllerRef.current !== controller) {return;}
+ if (streamIdleTimerRef.current) {
+ clearTimeout(streamIdleTimerRef.current);
+ streamIdleTimerRef.current = null;
+ }
+ streamIdleControllerRef.current = null;
+ };
+
const forgetTask = (storageKey = taskStorageKeyRef.current) => {
removeStoredHelmTask(storageKey);
if (!storageKey || taskStorageKeyRef.current === storageKey) {
@@ -148,7 +161,12 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
};
useEffect(() => {
+ const generation = initializationGenerationRef.current + 1;
+ initializationGenerationRef.current = generation;
if (!open || !chart) {return;}
+ const isCurrentInitialization = () => (
+ mountedRef.current && generation === initializationGenerationRef.current
+ );
setError(null);
setStorageWarning(null);
setLogs([]);
@@ -161,10 +179,19 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
taskIdentityRef.current = null;
submittingRef.current = false;
stopTaskPolling();
+ stopStreamIdleTimer();
streamAbortRef.current?.abort();
streamAbortRef.current = null;
const savedTask = findStoredHelmTask(chart.chartName);
+ const initialFields = {
+ releaseName: savedTask?.releaseName || chart.chartName,
+ version: chart.version ?? "",
+ };
+ if (savedTask?.namespace) {
+ initialFields.namespace = savedTask.namespace;
+ }
+ form.setFieldsValue(initialFields);
if (savedTask) {
taskIdRef.current = savedTask.taskId;
setActiveTaskId(savedTask.taskId);
@@ -176,16 +203,14 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
}
NamespaceBackend.getNamespaces().then(res => {
- if (!mountedRef.current) {return;}
+ if (!isCurrentInitialization()) {return;}
if (res.status === "ok") {
const ns = res.data ?? [];
setNamespaces(ns);
const def = ns.find(n => n.name === "default") ? "default" : (ns[0]?.name ?? "default");
- form.setFieldsValue({
- releaseName: savedTask?.releaseName || chart.chartName,
- namespace: savedTask?.namespace || def,
- version: chart.version ?? "",
- });
+ if (!form.isFieldTouched("namespace") && !form.getFieldValue("namespace")) {
+ form.setFieldsValue({namespace: def});
+ }
}
});
@@ -194,7 +219,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
setValuesYAML("");
HelmBackend.getHelmChartValues(chart.chartName, chart.repoURL, chart.version ?? "")
.then(res => {
- if (!mountedRef.current) {return;}
+ if (!isCurrentInitialization()) {return;}
if (res.status === "ok") {
setValuesYAML(res.data ?? "");
} else {
@@ -202,7 +227,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
}
})
.finally(() => {
- if (mountedRef.current) {setValuesLoading(false);}
+ if (isCurrentInitialization()) {setValuesLoading(false);}
});
}
}, [open, chart, form]);
@@ -212,6 +237,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
return () => {
mountedRef.current = false;
stopTaskPolling();
+ stopStreamIdleTimer();
streamAbortRef.current?.abort();
streamAbortRef.current = null;
};
@@ -225,6 +251,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
const handleClose = () => {
stopTaskPolling();
+ stopStreamIdleTimer();
streamAbortRef.current?.abort();
streamAbortRef.current = null;
taskIdRef.current = null;
@@ -258,8 +285,21 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
setError(null);
setLogs([]);
const streamController = new AbortController();
+ stopStreamIdleTimer();
streamAbortRef.current?.abort();
streamAbortRef.current = streamController;
+ const fallBackToTaskPolling = () => {
+ stopStreamIdleTimer(streamController);
+ if (!mountedRef.current || streamAbortRef.current !== streamController || !taskIdRef.current) {return;}
+ streamController.abort();
+ monitorTask(taskIdRef.current, taskStorageKeyRef.current, taskIdentityRef.current);
+ };
+ const resetStreamIdleTimer = () => {
+ if (!taskIdRef.current) {return;}
+ stopStreamIdleTimer();
+ streamIdleControllerRef.current = streamController;
+ streamIdleTimerRef.current = setTimeout(fallBackToTaskPolling, helmInstallStreamIdleTimeout);
+ };
HelmBackend.installHelmChartStream(
{
@@ -271,7 +311,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
valuesYAML,
},
line => {
- if (!mountedRef.current) {return;}
+ if (!mountedRef.current || streamAbortRef.current !== streamController) {return;}
if (line.startsWith("TASK_ID:")) {
const taskId = line.slice("TASK_ID:".length).trim();
const storageKey = helmTaskStorageKey(chart.chartName, values.namespace, values.releaseName);
@@ -298,12 +338,14 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
} else {
setLogs(prev => [...prev, line]);
}
+ resetStreamIdleTimer();
},
streamController.signal
)
.then(status => {
- if (!mountedRef.current) {return;}
+ if (!mountedRef.current || streamAbortRef.current !== streamController) {return;}
if (status === "DONE") {
+ stopStreamIdleTimer(streamController);
setDone(true);
setInstalling(false);
setPollingPaused(false);
@@ -313,7 +355,8 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
}
})
.catch(e => {
- if (!mountedRef.current) {return;}
+ if (!mountedRef.current || streamAbortRef.current !== streamController) {return;}
+ stopStreamIdleTimer(streamController);
if (streamController.signal.aborted) {return;}
if (taskIdRef.current) {
monitorTask(taskIdRef.current, taskStorageKeyRef.current, taskIdentityRef.current);
@@ -326,6 +369,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
})
.finally(() => {
if (streamAbortRef.current === streamController) {
+ stopStreamIdleTimer(streamController);
streamAbortRef.current = null;
}
});
diff --git a/web/tests/ui/app-store-helpers.js b/web/tests/ui/app-store-helpers.js
index 16e8d973..e87bc3f1 100644
--- a/web/tests/ui/app-store-helpers.js
+++ b/web/tests/ui/app-store-helpers.js
@@ -3,6 +3,7 @@ const {expect} = require("@playwright/test");
const {expectOkJson} = require("./e2e-helpers");
const API_UNINSTALL_HELM_RELEASE = "/api/uninstall-helm-release";
+const API_INSTALL_HELM_CHART_STREAM = "/api/install-helm-chart-stream";
const API_ADD_HELM_REPO = "/api/add-helm-repo";
const API_GET_HELM_REPOS = "/api/get-helm-repos";
const API_DELETE_HELM_REPO = "/api/delete-helm-repo";
@@ -10,6 +11,19 @@ const INSTALL_DONE_TIMEOUT_MS = Number(process.env.E2E_APP_INSTALL_DONE_TIMEOUT_
const HTTP_CHECK_TIMEOUT_MS = Number(process.env.E2E_APP_HTTP_TIMEOUT_MS) || 180_000;
const HTTP_CHECK_INTERVAL_MS = 3_000;
+async function expectHelmCleanup(response) {
+ expect(response.ok()).toBeTruthy();
+ const body = await response.json();
+ const releaseMissing = body.status === "error" &&
+ typeof body.msg === "string" &&
+ body.msg.includes("Release not loaded:") &&
+ body.msg.includes("release: not found");
+ if (!releaseMissing) {
+ expect(body.status).toBe("ok");
+ }
+ return body;
+}
+
const installedReleasesFixture = async({page}, use) => {
const installedReleases = [];
await use(installedReleases);
@@ -20,7 +34,7 @@ const installedReleasesFixture = async({page}, use) => {
const uninstall = await page.context().request.post(API_UNINSTALL_HELM_RELEASE, {
data: {releaseName: release.name, namespace: release.namespace},
});
- await expectOkJson(uninstall);
+ await expectHelmCleanup(uninstall);
} catch (error) {
cleanupErrors.push(`${release.namespace}/${release.name}: ${error.message}`);
}
@@ -151,8 +165,15 @@ async function installAppFromAppStore(page, {repoName, chartName, releaseName, n
await dialog.getByLabel("Release name").fill(releaseName);
await textarea.fill(valuesYAML);
+ const installRequestPromise = page.waitForRequest(request =>
+ request.url().includes(API_INSTALL_HELM_CHART_STREAM) && request.method() === "POST"
+ );
await dialog.getByRole("button", {name: "Install"}).click();
- installedReleases.push({name: releaseName, namespace});
+ const submittedInstall = (await installRequestPromise).postDataJSON();
+ if (submittedInstall?.releaseName && submittedInstall?.namespace) {
+ installedReleases.push({name: submittedInstall.releaseName, namespace: submittedInstall.namespace});
+ }
+ expect(submittedInstall).toMatchObject({releaseName, namespace});
await waitForInstallDone(dialog);
await dialog.getByRole("button", {name: "Done"}).click();