diff --git a/README.md b/README.md index e511990..70130c0 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,8 @@ and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real ### Further Ideas - solve umbrella-chart case -- use `--dry-run` instead of `template` - Have cleaner idea on the web API structure - Recognise & show ArgoCD-originating charts/objects, those `helm ls` does not show -- Recognise the revisions that are rollbacks by their description and mark in timeline #### Topic "Validating Manifests" diff --git a/main.go b/main.go index 5de9168..2202781 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,7 @@ func main() { os.Exit(0) } - address, webServerDone := dashboard.StartServer() + address, webServerDone := dashboard.StartServer(version) if os.Getenv("HD_NOBROWSER") == "" { log.Infof("Opening web UI: %s", address) diff --git a/pkg/dashboard/api.go b/pkg/dashboard/api.go index e538023..22149de 100644 --- a/pkg/dashboard/api.go +++ b/pkg/dashboard/api.go @@ -33,7 +33,7 @@ func errorHandler(c *gin.Context) { } } -func NewRouter(abortWeb ControlChan, data *DataLayer) *gin.Engine { +func NewRouter(abortWeb ControlChan, data *DataLayer, version string) *gin.Engine { var api *gin.Engine if os.Getenv("DEBUG") == "" { api = gin.New() @@ -47,15 +47,20 @@ func NewRouter(abortWeb ControlChan, data *DataLayer) *gin.Engine { api.Use(errorHandler) configureStatic(api) - configureRoutes(abortWeb, data, api) + configureRoutes(abortWeb, data, api, version) return api } -func configureRoutes(abortWeb ControlChan, data *DataLayer, api *gin.Engine) { +func configureRoutes(abortWeb ControlChan, data *DataLayer, api *gin.Engine, version string) { // server shutdown handler api.DELETE("/", func(c *gin.Context) { abortWeb <- struct{}{} + c.Status(http.StatusAccepted) + }) + + api.GET("/status", func(c *gin.Context) { + c.String(http.StatusOK, version) }) configureHelms(api.Group("/api/helm"), data) @@ -71,7 +76,7 @@ func configureHelms(api *gin.RouterGroup, data *DataLayer) { api.GET("/charts/resources", h.Resources) api.GET("/repo/search", h.RepoSearch) api.POST("/repo/update", h.RepoUpdate) - api.GET("/charts/install", h.InstallPreview) + api.GET("/repo/values", h.RepoValues) api.POST("/charts/install", h.Install) api.GET("/charts/:section", h.GetInfoSection) } diff --git a/pkg/dashboard/data.go b/pkg/dashboard/data.go index f48eed0..3f6d6fb 100644 --- a/pkg/dashboard/data.go +++ b/pkg/dashboard/data.go @@ -11,7 +11,6 @@ import ( log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" "helm.sh/helm/v3/pkg/release" - "io/ioutil" v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1" "os" "os/exec" @@ -234,7 +233,7 @@ func (d *DataLayer) RevisionManifests(namespace string, chartName string, revisi return out, nil } -func (d *DataLayer) RevisionManifestsParsed(namespace string, chartName string, revision int) ([]*GenericResource, error) { +func (d *DataLayer) RevisionManifestsParsed(namespace string, chartName string, revision int) ([]*v1.Carp, error) { out, err := d.RevisionManifests(namespace, chartName, revision, false) if err != nil { return nil, err @@ -242,7 +241,7 @@ func (d *DataLayer) RevisionManifestsParsed(namespace string, chartName string, dec := yaml.NewDecoder(bytes.NewReader([]byte(out))) - res := make([]*GenericResource, 0) + res := make([]*v1.Carp, 0) var tmp interface{} for dec.Decode(&tmp) == nil { // k8s libs uses only JSON tags defined, say hello to https://github.com/go-yaml/yaml/issues/424 @@ -252,7 +251,7 @@ func (d *DataLayer) RevisionManifestsParsed(namespace string, chartName string, return nil, err } - var doc GenericResource + var doc v1.Carp err = json.Unmarshal(jsoned, &doc) if err != nil { return nil, err @@ -289,11 +288,11 @@ func (d *DataLayer) RevisionValues(namespace string, chartName string, revision return out, nil } -func (d *DataLayer) GetResource(namespace string, def *GenericResource) (*GenericResource, error) { +func (d *DataLayer) GetResource(namespace string, def *v1.Carp) (*v1.Carp, error) { out, err := d.runCommandKubectl("get", strings.ToLower(def.Kind), def.Name, "--namespace", namespace, "--output", "json") if err != nil { if strings.HasSuffix(strings.TrimSpace(err.Error()), " not found") { - return &GenericResource{ + return &v1.Carp{ Status: v1.CarpStatus{ Phase: "NotFound", Message: err.Error(), @@ -305,7 +304,7 @@ func (d *DataLayer) GetResource(namespace string, def *GenericResource) (*Generi } } - var res GenericResource + var res v1.Carp err = json.Unmarshal([]byte(out), &res) if err != nil { return nil, err @@ -367,24 +366,22 @@ func (d *DataLayer) ChartRepoUpdate(name string) error { return nil } -func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string, version string, justTemplate bool) (string, error) { - oldVals, err := d.RevisionValues(namespace, name, 0, false) +func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string, version string, justTemplate bool, values string) (string, error) { + if values == "" { + oldVals, err := d.RevisionValues(namespace, name, 0, true) + if err != nil { + return "", err + } + values = oldVals + } + + oldValsFile, close1, err := tempFile(values) + defer close1() if err != nil { return "", err } - file, err := ioutil.TempFile("", "helm_vals_") - if err != nil { - return "", err - } - defer os.Remove(file.Name()) - - err = ioutil.WriteFile(file.Name(), []byte(oldVals), 0600) - if err != nil { - return "", err - } - - cmd := []string{"upgrade", name, repoChart, "--version", version, "--namespace", namespace, "--values", file.Name(), "--output", "json"} + cmd := []string{"upgrade", name, repoChart, "--version", version, "--namespace", namespace, "--values", oldValsFile, "--output", "json"} if justTemplate { cmd = append(cmd, "--dry-run") } @@ -406,11 +403,22 @@ func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string return "", err } out = getDiff(strings.TrimSpace(manifests), strings.TrimSpace(res.Manifest), "current.yaml", "upgraded.yaml") + } else { + res := release.Release{} + err = json.Unmarshal([]byte(out), &res) + if err != nil { + return "", err + } + _ = res } return out, nil } +func (d *DataLayer) ShowValues(chart string, ver string) (string, error) { + return d.runCommandHelm("show", "values", chart, "--version", ver) +} + func RevisionDiff(functor SectionFn, ext string, namespace string, name string, revision1 int, revision2 int, flag bool) (string, error) { if revision1 == 0 || revision2 == 0 { log.Debugf("One of revisions is zero: %d %d", revision1, revision2) @@ -438,5 +446,3 @@ func getDiff(text1 string, text2 string, name1 string, name2 string) string { log.Debugf("The diff is: %s", diff) return diff } - -type GenericResource = v1.Carp diff --git a/pkg/dashboard/data_test.go b/pkg/dashboard/data_test.go index 243f53f..e363a20 100644 --- a/pkg/dashboard/data_test.go +++ b/pkg/dashboard/data_test.go @@ -3,6 +3,7 @@ package dashboard import ( log "github.com/sirupsen/logrus" "helm.sh/helm/v3/pkg/release" + v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1" "sync" "testing" ) @@ -63,7 +64,7 @@ func TestFlow(t *testing.T) { _ = manifests var wg sync.WaitGroup - res := make([]*GenericResource, 0) + res := make([]*v1.Carp, 0) for _, m := range manifests { wg.Add(1) mc := m // fix the clojure diff --git a/pkg/dashboard/helmHandlers.go b/pkg/dashboard/helmHandlers.go index b8bad42..fefdb31 100644 --- a/pkg/dashboard/helmHandlers.go +++ b/pkg/dashboard/helmHandlers.go @@ -1,10 +1,8 @@ package dashboard import ( - "encoding/json" "errors" "github.com/gin-gonic/gin" - "helm.sh/helm/v3/pkg/release" "net/http" "strconv" ) @@ -113,30 +111,25 @@ func (h *HelmHandler) RepoUpdate(c *gin.Context) { c.Status(http.StatusNoContent) } -func (h *HelmHandler) InstallPreview(c *gin.Context) { - out, err := chartInstall(c, h.Data, true) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - c.String(http.StatusOK, out) -} - func (h *HelmHandler) Install(c *gin.Context) { - out, err := chartInstall(c, h.Data, false) + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + justTemplate := c.Query("flag") != "true" + out, err := h.Data.ChartUpgrade(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), justTemplate, c.PostForm("values")) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } - res := release.Release{} - err = json.Unmarshal([]byte(out), &res) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return + if !justTemplate { + c.Header("Content-Type", "application/json") } - c.IndentedJSON(http.StatusAccepted, res) + c.String(http.StatusAccepted, out) } func (h *HelmHandler) GetInfoSection(c *gin.Context) { @@ -156,17 +149,13 @@ func (h *HelmHandler) GetInfoSection(c *gin.Context) { c.String(http.StatusOK, res) } -func chartInstall(c *gin.Context, data *DataLayer, justTemplate bool) (string, error) { - qp, err := getQueryProps(c, false) +func (h *HelmHandler) RepoValues(c *gin.Context) { + out, err := h.Data.ShowValues(c.Query("chart"), c.Query("version")) if err != nil { - return "", err + _ = c.AbortWithError(http.StatusInternalServerError, err) + return } - - out, err := data.ChartUpgrade(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), justTemplate) - if err != nil { - return "", err - } - return out, nil + c.String(http.StatusOK, out) } func handleGetSection(data *DataLayer, section string, rDiff string, qp *QueryProps, flag bool) (string, error) { diff --git a/pkg/dashboard/kubeHandlers.go b/pkg/dashboard/kubeHandlers.go index 5064bd5..f4564c6 100644 --- a/pkg/dashboard/kubeHandlers.go +++ b/pkg/dashboard/kubeHandlers.go @@ -27,7 +27,7 @@ func (h *KubeHandler) GetResourceInfo(c *gin.Context) { return } - res, err := h.Data.GetResource(qp.Namespace, &GenericResource{ + res, err := h.Data.GetResource(qp.Namespace, &v12.Carp{ TypeMeta: v1.TypeMeta{Kind: c.Param("kind")}, ObjectMeta: v1.ObjectMeta{Name: qp.Name}, }) diff --git a/pkg/dashboard/server.go b/pkg/dashboard/server.go index b0a4fa6..671f188 100644 --- a/pkg/dashboard/server.go +++ b/pkg/dashboard/server.go @@ -8,7 +8,7 @@ import ( "os" ) -func StartServer() (string, ControlChan) { +func StartServer(version string) (string, ControlChan) { data := DataLayer{} err := data.CheckConnectivity() if err != nil { @@ -28,7 +28,7 @@ func StartServer() (string, ControlChan) { } abort := make(ControlChan) - api := NewRouter(abort, &data) + api := NewRouter(abort, &data, version) done := startBackgroundServer(address, api, abort) return "http://" + address, done diff --git a/pkg/dashboard/static/actions.js b/pkg/dashboard/static/actions.js index ffb2d0d..5095702 100644 --- a/pkg/dashboard/static/actions.js +++ b/pkg/dashboard/static/actions.js @@ -4,7 +4,7 @@ $("#btnUpgradeCheck").click(function () { self.find(".spinner-border").show() const repoName = self.data("repo") $("#btnUpgrade span").text("Checking...") - $("#btnUpgrade .icon").removeClass("bi-arrow-up bi-check-circle").addClass("bi-hourglass-split") + $("#btnUpgrade .icon").removeClass("bi-arrow-up bi-pencil").addClass("bi-hourglass") $.post("/api/helm/repo/update?name=" + repoName).fail(function (xhr) { reportError("Failed to update chart repo", xhr) }).done(function () { @@ -29,24 +29,30 @@ function checkUpgradeable(name) { return } - $('#upgradeModalLabel select').empty() + const verCur = $("#specRev").data("last-chart-ver"); + $('#upgradeModal select').empty() for (let i = 0; i < data.length; i++) { - $('#upgradeModalLabel select').append("") + const opt = $(""); + if (data[i].version === verCur) { + opt.html(data[i].version + " ·") + } else { + opt.html(data[i].version) + } + $('#upgradeModal select').append(opt) } const elm = data[0] $("#btnUpgradeCheck").data("repo", elm.name.split('/').shift()) $("#btnUpgradeCheck").data("chart", elm.name.split('/').pop()) - const verCur = $("#specRev").data("last-chart-ver"); const canUpgrade = isNewerVersion(verCur, elm.version); $("#btnUpgradeCheck").prop("disabled", false) if (canUpgrade) { $("#btnUpgrade span").text("Upgrade to " + elm.version) $("#btnUpgrade .icon").removeClass("bi-hourglass-split").addClass("bi-arrow-up") } else { - $("#btnUpgrade span").text("Up-to-date") - $("#btnUpgrade .icon").removeClass("bi-hourglass-split").addClass("bi-check-circle") + $("#btnUpgrade span").text("Reconfigure") + $("#btnUpgrade .icon").removeClass("bi-hourglass-split").addClass("bi-pencil") } $("#btnUpgrade").off("click").click(function () { @@ -58,12 +64,12 @@ function checkUpgradeable(name) { function popUpUpgrade(self, verCur, elm) { const name = getHashParam("chart"); let url = "/api/helm/charts/install?namespace=" + getHashParam("namespace") + "&name=" + name + "&chart=" + elm.name; - $('#upgradeModalLabel select').data("url", url) + $('#upgradeModal select').data("url", url).data("chart", elm.name) $("#upgradeModalLabel .name").text(name) - $("#upgradeModalLabel .ver-old").text(verCur) + $("#upgradeModal .ver-old").text(verCur) - $('#upgradeModalLabel select').val(elm.version).trigger("change") + $('#upgradeModal select').val(elm.version).trigger("change") const myModal = new bootstrap.Modal(document.getElementById('upgradeModal'), {}); myModal.show() @@ -72,26 +78,86 @@ function popUpUpgrade(self, verCur, elm) { btnConfirm.prop("disabled", true).off('click').click(function () { btnConfirm.prop("disabled", true).prepend('') $.ajax({ - url: url + "&version=" + $('#upgradeModalLabel select').val(), type: 'POST', + url: url + "&version=" + $('#upgradeModal select').val() + "&flag=true", + data: $("#upgradeModal textarea").data("dirty") ? $("#upgradeModal form").serialize() : null, }).fail(function (xhr) { reportError("Failed to upgrade the chart", xhr) }).done(function (data) { - setHashParam("revision", data.version) - window.location.reload() + console.log(data) + if (data.version) { + setHashParam("revision", data.version) + window.location.reload() + } else { + reportError("Failed to get new revision number") + } }) }) + + // fill current values + const lastRev = $("#specRev").data("last-rev") + $.get("/api/helm/charts/values?namespace=" + getHashParam("namespace") + "&revision=" + lastRev + "&name=" + getHashParam("chart") + "&flag=true").fail(function (xhr) { + reportError("Failed to get charts values info", xhr) + }).done(function (data) { + $("#upgradeModal textarea").val(data).data("dirty", false) + }) } -$('#upgradeModalLabel select').change(function () { +let reconfigTimeout = null; +$("#upgradeModal textarea").keyup(function () { + const self = $(this); + self.data("dirty", true) + if (reconfigTimeout) { + window.clearTimeout(reconfigTimeout) + } + reconfigTimeout = window.setTimeout(function () { + requestChangeDiff() + }, 500) +}) + +$('#upgradeModal select').change(function () { const self = $(this) - $("#upgradeModalBody").empty().append('') - $("#upgradeModal .btn-confirm").prop("disabled", true) - $.get(self.data("url") + "&version=" + self.val()).fail(function (xhr) { + requestChangeDiff() + + // fill reference values + $("#upgradeModal .ref-vals").html('') + $.get("/api/helm/repo/values?chart=" + self.data("chart") + "&version=" + self.val()).fail(function (xhr) { reportError("Failed to get upgrade info", xhr) }).done(function (data) { - $("#upgradeModalBody").empty(); + data = hljs.highlight(data, {language: 'yaml'}).value + $("#upgradeModal .ref-vals").html(data) + }) +}) + +function requestChangeDiff() { + const self = $('#upgradeModal select'); + const diffBody = $("#upgradeModalBody"); + diffBody.empty().append(' Calculating diff...') + $("#upgradeModal .btn-confirm").prop("disabled", true) + + let values = null; + if ($("#upgradeModal textarea").data("dirty")) { + $("#upgradeModal .invalid-feedback").hide() + values = $("#upgradeModal form").serialize() + + try { + jsyaml.load($("#upgradeModal textarea").val()) + } catch (e) { + $("#upgradeModal .invalid-feedback").text("YAML parse error: "+e.message).show() + $("#upgradeModalBody").html("Invalid values YAML") + return + } + } + + $.ajax({ + type: "POST", + url: self.data("url") + "&version=" + self.val(), + data: values, + }).fail(function (xhr) { + $("#upgradeModalBody").html("
Failed to get upgrade info: "+ xhr.responseText+"
") + }).done(function (data) { + diffBody.empty(); $("#upgradeModal .btn-confirm").prop("disabled", false) const targetElement = document.getElementById('upgradeModalBody'); @@ -101,12 +167,11 @@ $('#upgradeModalLabel select').change(function () { }; const diff2htmlUi = new Diff2HtmlUI(targetElement, data, configuration); diff2htmlUi.draw() - $("#upgradeModalBody").prepend("Following changes will happen to cluster:
") if (!data) { - $("#upgradeModalBody").html("No changes will happen to cluster") + diffBody.html("No changes will happen to the cluster") } }) -}) +} const btnConfirm = $("#confirmModal .btn-confirm"); $("#btnUninstall").click(function () { diff --git a/pkg/dashboard/static/datadog.js b/pkg/dashboard/static/datadog.js index 84f1b3d..9da5f4a 100644 --- a/pkg/dashboard/static/datadog.js +++ b/pkg/dashboard/static/datadog.js @@ -4,15 +4,22 @@ n=o.getElementsByTagName(u)[0];n.parentNode.insertBefore(d,n) })(window,document,'script','https://www.datadoghq-browser-agent.com/datadog-rum-v4.js','DD_RUM') DD_RUM.onReady(function() { - DD_RUM.init({ - clientToken: 'pub16d64cd1c00cf073ce85af914333bf72', - applicationId: 'e75439e5-e1b3-46ba-a9e9-a2e58579a2e2', - site: 'datadoghq.com', - service: 'helm-dashboard', - version: 'v0.0.0', - trackInteractions: true, - trackResources: true, - trackLongTasks: true, - defaultPrivacyLevel: 'mask' - }) + const xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + DD_RUM.init({ + clientToken: 'pub16d64cd1c00cf073ce85af914333bf72', + applicationId: 'e75439e5-e1b3-46ba-a9e9-a2e58579a2e2', + site: 'datadoghq.com', + service: 'helm-dashboard', + version: xhr.responseText, + trackInteractions: true, + trackResources: true, + trackLongTasks: true, + defaultPrivacyLevel: 'mask' + }) + } + } + xhr.open('GET', '/status', true); + xhr.send(null); }) \ No newline at end of file diff --git a/pkg/dashboard/static/index.html b/pkg/dashboard/static/index.html index b529ab7..1a3ca7d 100644 --- a/pkg/dashboard/static/index.html +++ b/pkg/dashboard/static/index.html @@ -16,7 +16,18 @@ @@ -52,9 +63,11 @@