Skip to content

Commit b5499cb

Browse files
committed
fix: show helm install log in SSE
1 parent b8db273 commit b5499cb

7 files changed

Lines changed: 260 additions & 68 deletions

File tree

controllers/helm.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,41 @@ func (c *ApiController) InstallHelmChart() {
211211
c.ResponseOk()
212212
}
213213

214+
// InstallHelmChartStream streams helm install progress as Server-Sent Events.
215+
// @router /api/install-helm-chart-stream [post]
216+
func (c *ApiController) InstallHelmChartStream() {
217+
if c.RequireAdmin() {
218+
return
219+
}
220+
cfg := getAdminRestConfig()
221+
if cfg == nil {
222+
c.Ctx.ResponseWriter.ResponseWriter.Header().Set("Content-Type", "text/event-stream")
223+
fmt.Fprintf(c.Ctx.ResponseWriter.ResponseWriter, "data: ERROR: cluster not ready\n\n")
224+
return
225+
}
226+
var req helmInstallReq
227+
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &req); err != nil {
228+
c.Ctx.ResponseWriter.ResponseWriter.Header().Set("Content-Type", "text/event-stream")
229+
fmt.Fprintf(c.Ctx.ResponseWriter.ResponseWriter, "data: ERROR: %s\n\n", err.Error())
230+
return
231+
}
232+
233+
w := c.Ctx.ResponseWriter.ResponseWriter
234+
w.Header().Set("Content-Type", "text/event-stream")
235+
w.Header().Set("Cache-Control", "no-cache")
236+
w.Header().Set("X-Accel-Buffering", "no")
237+
w.WriteHeader(http.StatusOK)
238+
239+
flusher, canFlush := w.(http.Flusher)
240+
logCh := store.InstallHelmChartStream(cfg, req.ReleaseName, req.Namespace, req.ChartName, req.RepoURL, req.Version, req.ValuesYAML)
241+
for line := range logCh {
242+
fmt.Fprintf(w, "data: %s\n\n", line)
243+
if canFlush {
244+
flusher.Flush()
245+
}
246+
}
247+
}
248+
214249
// UpgradeHelmRelease upgrades an existing Helm release.
215250
// @router /api/upgrade-helm-release [post]
216251
func (c *ApiController) UpgradeHelmRelease() {

routers/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ func InitAPI() {
136136
beego.Router("/api/get-helm-chart-values", &controllers.ApiController{}, "GET:GetHelmChartValues")
137137
beego.Router("/api/get-helm-releases", &controllers.ApiController{}, "GET:GetHelmReleases")
138138
beego.Router("/api/install-helm-chart", &controllers.ApiController{}, "POST:InstallHelmChart")
139+
beego.Router("/api/install-helm-chart-stream", &controllers.ApiController{}, "POST:InstallHelmChartStream")
139140
beego.Router("/api/upgrade-helm-release", &controllers.ApiController{}, "POST:UpgradeHelmRelease")
140141
beego.Router("/api/rollback-helm-release", &controllers.ApiController{}, "POST:RollbackHelmRelease")
141142
beego.Router("/api/uninstall-helm-release", &controllers.ApiController{}, "POST:UninstallHelmRelease")

store/helm.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,12 @@ func (r *restClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
114114
// ---------- action.Configuration builder ----------
115115

116116
func newHelmConfig(cfg *rest.Config, namespace string) (*action.Configuration, error) {
117+
return newHelmConfigWithLog(cfg, namespace, func(string, ...interface{}) {})
118+
}
119+
120+
func newHelmConfigWithLog(cfg *rest.Config, namespace string, logFn func(string, ...interface{})) (*action.Configuration, error) {
117121
actionConfig := new(action.Configuration)
118-
if err := actionConfig.Init(newRESTClientGetter(cfg, namespace), namespace, "secret", func(string, ...interface{}) {}); err != nil {
122+
if err := actionConfig.Init(newRESTClientGetter(cfg, namespace), namespace, "secret", logFn); err != nil {
119123
return nil, fmt.Errorf("helm config init: %w", err)
120124
}
121125
return actionConfig, nil
@@ -330,6 +334,45 @@ func InstallHelmChart(cfg *rest.Config, releaseName, namespace, chartName, repoU
330334
return err
331335
}
332336

337+
// InstallHelmChartStream runs helm install asynchronously and pushes log lines to the returned channel.
338+
// The channel is closed when the operation finishes; a final line of "ERROR: <msg>" or "DONE" signals the outcome.
339+
func InstallHelmChartStream(cfg *rest.Config, releaseName, namespace, chartName, repoURL, version, valuesYAML string) <-chan string {
340+
ch := make(chan string, 64)
341+
go func() {
342+
defer close(ch)
343+
logFn := func(format string, args ...interface{}) {
344+
ch <- fmt.Sprintf(format, args...)
345+
}
346+
actionConfig, err := newHelmConfigWithLog(cfg, namespace, logFn)
347+
if err != nil {
348+
ch <- "ERROR: " + err.Error()
349+
return
350+
}
351+
chart, err := loadChart(chartName, repoURL, version)
352+
if err != nil {
353+
ch <- "ERROR: " + err.Error()
354+
return
355+
}
356+
vals, err := parseValues(valuesYAML)
357+
if err != nil {
358+
ch <- "ERROR: " + err.Error()
359+
return
360+
}
361+
install := action.NewInstall(actionConfig)
362+
install.ReleaseName = releaseName
363+
install.Namespace = namespace
364+
install.CreateNamespace = true
365+
install.Wait = true
366+
install.Timeout = 5 * time.Minute
367+
if _, err = install.Run(chart, vals); err != nil {
368+
ch <- "ERROR: " + err.Error()
369+
return
370+
}
371+
ch <- "DONE"
372+
}()
373+
return ch
374+
}
375+
333376
func UpgradeHelmRelease(cfg *rest.Config, releaseName, namespace, chartName, repoURL, version, valuesYAML string) error {
334377
actionConfig, err := newHelmConfig(cfg, namespace)
335378
if err != nil {

web/src/HelmInstallModal.js

Lines changed: 130 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React, {useEffect, useState} from "react";
2-
import {Alert, Form, Input, Modal, Select, Spin, Typography} from "antd";
1+
import React, {useEffect, useRef, useState} from "react";
2+
import {Alert, Button, Form, Input, Modal, Select, Spin, Typography} from "antd";
33
import {useTranslation} from "react-i18next";
44
import * as HelmBackend from "./backend/HelmBackend";
55
import * as NamespaceBackend from "./backend/NamespaceBackend";
@@ -12,12 +12,17 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
1212
const [namespaces, setNamespaces] = useState([]);
1313
const [valuesYAML, setValuesYAML] = useState("");
1414
const [valuesLoading, setValuesLoading] = useState(false);
15-
const [submitting, setSubmitting] = useState(false);
15+
const [installing, setInstalling] = useState(false);
16+
const [done, setDone] = useState(false);
1617
const [error, setError] = useState(null);
18+
const [logs, setLogs] = useState([]);
19+
const logEndRef = useRef(null);
1720

1821
useEffect(() => {
1922
if (!open || !chart) {return;}
2023
setError(null);
24+
setLogs([]);
25+
setDone(false);
2126

2227
NamespaceBackend.getNamespaces().then(res => {
2328
if (res.status === "ok") {
@@ -47,38 +52,56 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
4752
}
4853
}, [open, chart, form]);
4954

55+
useEffect(() => {
56+
if (logEndRef.current) {
57+
logEndRef.current.scrollIntoView({behavior: "smooth"});
58+
}
59+
}, [logs]);
60+
5061
const handleClose = () => {
5162
form.resetFields();
5263
setValuesYAML("");
5364
setError(null);
65+
setLogs([]);
66+
setDone(false);
67+
setInstalling(false);
5468
onClose();
5569
};
5670

5771
const handleOk = () => {
72+
if (done) {
73+
onInstalled?.();
74+
handleClose();
75+
return;
76+
}
5877
form.validateFields().then(values => {
59-
setSubmitting(true);
78+
setInstalling(true);
6079
setError(null);
61-
HelmBackend.installHelmChart({
62-
releaseName: values.releaseName,
63-
namespace: values.namespace,
64-
chartName: chart.chartName,
65-
repoURL: chart.repoURL,
66-
version: values.version || chart.version,
67-
valuesYAML,
68-
}).then(res => {
69-
if (res.status === "ok") {
80+
setLogs([]);
81+
HelmBackend.installHelmChartStream(
82+
{
83+
releaseName: values.releaseName,
84+
namespace: values.namespace,
85+
chartName: chart.chartName,
86+
repoURL: chart.repoURL,
87+
version: values.version || chart.version,
88+
valuesYAML,
89+
},
90+
line => setLogs(prev => [...prev, line])
91+
)
92+
.then(() => {
93+
setDone(true);
7094
onInstalled?.();
71-
handleClose();
72-
} else {
73-
setError(res.msg);
74-
}
75-
}).catch(e => setError(e.message)).finally(() => setSubmitting(false));
95+
})
96+
.catch(e => setError(e.message))
97+
.finally(() => setInstalling(false));
7698
});
7799
};
78100

79101
if (!chart) {return null;}
80102

81103
const nsOptions = namespaces.map(ns => ({label: ns.name, value: ns.name}));
104+
const showLog = installing || done || error;
82105

83106
return (
84107
<Modal
@@ -93,57 +116,101 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) {
93116
</span>
94117
}
95118
open={open}
96-
onOk={handleOk}
97-
onCancel={handleClose}
98-
okText={t("helm:Install")}
99-
confirmLoading={submitting}
100-
width={680}
119+
onCancel={installing ? undefined : handleClose}
120+
closable={!installing}
121+
maskClosable={!installing}
122+
footer={
123+
<div style={{display: "flex", justifyContent: "flex-end", gap: 8}}>
124+
{!installing && (
125+
<Button onClick={handleClose}>{done ? t("general:Close") : t("general:Cancel")}</Button>
126+
)}
127+
{!done && (
128+
<Button type="primary" loading={installing} onClick={handleOk}>
129+
{t("helm:Install")}
130+
</Button>
131+
)}
132+
{done && (
133+
<Button type="primary" onClick={handleOk}>
134+
{t("general:Done")}
135+
</Button>
136+
)}
137+
</div>
138+
}
139+
width={700}
101140
destroyOnHidden
102141
>
103-
{error && <Alert type="error" message={error} showIcon style={{marginBottom: 16}} closable onClose={() => setError(null)} />}
142+
{error && (
143+
<Alert type="error" message={error} showIcon style={{marginBottom: 16}} closable onClose={() => setError(null)} />
144+
)}
104145

105-
<Form form={form} layout="vertical">
106-
<div style={{display: "flex", gap: 12}}>
107-
<Form.Item
108-
style={{flex: 1}}
109-
label={t("helm:Release name")}
110-
name="releaseName"
111-
rules={[
112-
{required: true},
113-
{pattern: /^[a-z0-9][a-z0-9-]*$/, message: t("helm:Release name pattern")},
114-
]}
115-
>
116-
<Input />
117-
</Form.Item>
118-
<Form.Item style={{flex: 1}} label={t("general:Namespaces")} name="namespace" rules={[{required: true}]}>
119-
<Select options={nsOptions} showSearch />
120-
</Form.Item>
121-
<Form.Item style={{width: 130}} label={t("helm:Version")} name="version">
122-
<Input placeholder={chart.version ?? "latest"} />
146+
{!showLog && (
147+
<Form form={form} layout="vertical">
148+
<div style={{display: "flex", gap: 12}}>
149+
<Form.Item
150+
style={{flex: 1}}
151+
label={t("helm:Release name")}
152+
name="releaseName"
153+
rules={[
154+
{required: true},
155+
{pattern: /^[a-z0-9][a-z0-9-]*$/, message: t("helm:Release name pattern")},
156+
]}
157+
>
158+
<Input />
159+
</Form.Item>
160+
<Form.Item style={{flex: 1}} label={t("general:Namespaces")} name="namespace" rules={[{required: true}]}>
161+
<Select options={nsOptions} showSearch />
162+
</Form.Item>
163+
<Form.Item style={{width: 130}} label={t("helm:Version")} name="version">
164+
<Input placeholder={chart.version ?? "latest"} />
165+
</Form.Item>
166+
</div>
167+
168+
<Form.Item label={t("helm:Values (YAML)")}>
169+
{valuesLoading ? (
170+
<div style={{textAlign: "center", padding: 24}}>
171+
<Spin size="small" />
172+
<Text style={{marginLeft: 8, color: "rgba(0,0,0,0.45)"}}>{t("helm:Loading values")}</Text>
173+
</div>
174+
) : (
175+
<textarea
176+
value={valuesYAML}
177+
onChange={e => setValuesYAML(e.target.value)}
178+
rows={14}
179+
style={{
180+
width: "100%", fontFamily: "monospace", fontSize: 12,
181+
padding: "8px 10px", borderRadius: 6,
182+
border: "1px solid #d9d9d9", resize: "vertical", outline: "none",
183+
boxSizing: "border-box",
184+
}}
185+
spellCheck={false}
186+
/>
187+
)}
123188
</Form.Item>
124-
</div>
189+
</Form>
190+
)}
125191

126-
<Form.Item label={t("helm:Values (YAML)")}>
127-
{valuesLoading ? (
128-
<div style={{textAlign: "center", padding: 24}}>
129-
<Spin size="small" /> <Text style={{marginLeft: 8, color: "rgba(0,0,0,0.45)"}}>{t("helm:Loading values")}</Text>
130-
</div>
131-
) : (
132-
<textarea
133-
value={valuesYAML}
134-
onChange={e => setValuesYAML(e.target.value)}
135-
rows={14}
136-
style={{
137-
width: "100%", fontFamily: "monospace", fontSize: 12,
138-
padding: "8px 10px", borderRadius: 6,
139-
border: "1px solid #d9d9d9", resize: "vertical", outline: "none",
140-
boxSizing: "border-box",
141-
}}
142-
spellCheck={false}
143-
/>
192+
{showLog && (
193+
<div
194+
style={{
195+
background: "#1a1a1a", borderRadius: 6, padding: "10px 14px",
196+
fontFamily: "monospace", fontSize: 12, color: "#d4d4d4",
197+
height: 320, overflowY: "auto", lineHeight: 1.6,
198+
}}
199+
>
200+
{logs.length === 0 && installing && (
201+
<span style={{color: "#888"}}>
202+
<Spin size="small" style={{marginRight: 8}} />
203+
{t("helm:Installing")}...
204+
</span>
144205
)}
145-
</Form.Item>
146-
</Form>
206+
{logs.map((line, i) => (
207+
<div key={i} style={{color: line.startsWith("ERROR") ? "#f87171" : done && i === logs.length - 1 ? "#4ade80" : "#d4d4d4"}}>
208+
{line}
209+
</div>
210+
))}
211+
<div ref={logEndRef} />
212+
</div>
213+
)}
147214
</Modal>
148215
);
149216
}

web/src/backend/HelmBackend.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,32 @@ export function installHelmChart(payload) {
5050
}).then(r => r.json());
5151
}
5252

53+
// installHelmChartStream posts the payload then reads the SSE response line-by-line.
54+
// onLine(line) is called for each log line; returns a promise that resolves on "DONE" or rejects on "ERROR: ...".
55+
export async function installHelmChartStream(payload, onLine) {
56+
const resp = await fetch(`${Setting.ServerUrl}/api/install-helm-chart-stream`, {
57+
method: "POST", credentials: "include", headers: jsonHeaders(), body: JSON.stringify(payload),
58+
});
59+
const reader = resp.body.getReader();
60+
const decoder = new TextDecoder();
61+
let buf = "";
62+
for (;;) {
63+
const {done, value} = await reader.read(); // eslint-disable-line no-await-in-loop
64+
if (done) {break;}
65+
buf += decoder.decode(value, {stream: true});
66+
const parts = buf.split("\n\n");
67+
buf = parts.pop();
68+
for (const part of parts) {
69+
const line = part.replace(/^data: /, "");
70+
if (line) {
71+
onLine(line);
72+
if (line.startsWith("ERROR: ")) {throw new Error(line.slice(7));}
73+
if (line === "DONE") {return;}
74+
}
75+
}
76+
}
77+
}
78+
5379
export function upgradeHelmRelease(payload) {
5480
return fetch(`${Setting.ServerUrl}/api/upgrade-helm-release`, {
5581
method: "POST", credentials: "include", headers: jsonHeaders(), body: JSON.stringify(payload),

0 commit comments

Comments
 (0)