diff --git a/README.md b/README.md index 72d87f6..95bfbce 100644 --- a/README.md +++ b/README.md @@ -70,4 +70,5 @@ Adding new repository Recognise & show ArgoCD-originating charts/objects Have cleaner idea on the web API structure See if we can build in Chechov or Validkube validation -Show manifest/describe upon clicking on resource \ No newline at end of file +Show manifest/describe upon clicking on resource +Recognise the revisions that are rollbacks by their description and mark in timeline \ No newline at end of file diff --git a/pkg/dashboard/api.go b/pkg/dashboard/api.go index 638ee30..9c0755e 100644 --- a/pkg/dashboard/api.go +++ b/pkg/dashboard/api.go @@ -74,13 +74,25 @@ func configureHelms(api *gin.Engine, data *DataLayer) { }) api.DELETE("/api/helm/charts", func(c *gin.Context) { - cName := c.Query("chart") - cNamespace := c.Query("namespace") - if cName == "" { - _ = c.AbortWithError(http.StatusBadRequest, errors.New("missing required query string parameter: chart")) + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + } + err = data.UninstallChart(qp.Namespace, qp.Name) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) return } - err := data.UninstallChart(cNamespace, cName) + c.Redirect(http.StatusFound, "/") + }) + + api.POST("/api/helm/charts/rollback", func(c *gin.Context) { + qp, err := getQueryProps(c, true) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + } + + err = data.Revert(qp.Namespace, qp.Name, qp.Revision) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return @@ -89,14 +101,12 @@ func configureHelms(api *gin.Engine, data *DataLayer) { }) api.GET("/api/helm/charts/history", func(c *gin.Context) { - cName := c.Query("chart") - cNamespace := c.Query("namespace") - if cName == "" { - _ = c.AbortWithError(http.StatusBadRequest, errors.New("missing required query string parameter: chart")) - return + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) } - res, err := data.ChartHistory(cNamespace, cName) + res, err := data.ChartHistory(qp.Namespace, qp.Name) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return @@ -105,19 +115,12 @@ func configureHelms(api *gin.Engine, data *DataLayer) { }) api.GET("/api/helm/charts/resources", func(c *gin.Context) { - cName := c.Query("chart") - cNamespace := c.Query("namespace") - if cName == "" { - _ = c.AbortWithError(http.StatusBadRequest, errors.New("missing required query string parameter: chart")) - return - } - cRev, err := strconv.Atoi(c.Query("revision")) + qp, err := getQueryProps(c, true) if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return + _ = c.AbortWithError(http.StatusBadRequest, err) } - res, err := data.RevisionManifestsParsed(cNamespace, cName, cRev) + res, err := data.RevisionManifestsParsed(qp.Namespace, qp.Name, qp.Revision) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return @@ -125,60 +128,57 @@ func configureHelms(api *gin.Engine, data *DataLayer) { c.IndentedJSON(http.StatusOK, res) }) + api.GET("/api/helm/charts/:section", func(c *gin.Context) { + qp, err := getQueryProps(c, true) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + } + + flag := c.Query("flag") == "true" + rDiff := c.Query("revisionDiff") + res, err := handleGetSection(data, c.Param("section"), rDiff, qp, flag) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + } + c.String(http.StatusOK, res) + }) +} + +func handleGetSection(data *DataLayer, section string, rDiff string, qp *QueryProps, flag bool) (string, error) { sections := map[string]SectionFn{ "manifests": data.RevisionManifests, "values": data.RevisionValues, "notes": data.RevisionNotes, } - api.GET("/api/helm/charts/:section", func(c *gin.Context) { - functor, found := sections[c.Param("section")] - if !found { - _ = c.AbortWithError(http.StatusNotFound, errors.New("unsupported section: "+c.Param("section"))) - return - } + functor, found := sections[section] + if !found { + return "", errors.New("unsupported section: " + section) + } - cName := c.Query("chart") - cNamespace := c.Query("namespace") - if cName == "" { - _ = c.AbortWithError(http.StatusBadRequest, errors.New("missing required query string parameter: chart")) - return - } - - cRev, err := strconv.Atoi(c.Query("revision")) + if rDiff != "" { + cRevDiff, err := strconv.Atoi(rDiff) if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return + return "", err } - flag := c.Query("flag") == "true" - rDiff := c.Query("revisionDiff") - if rDiff != "" { - cRevDiff, err := strconv.Atoi(rDiff) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - ext := ".yaml" - if c.Param("section") == "notes" { - ext = ".txt" - } - - res, err := RevisionDiff(functor, ext, cNamespace, cName, cRevDiff, cRev, flag) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - c.String(http.StatusOK, res) - } else { - res, err := functor(cNamespace, cName, cRev, flag) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - c.String(http.StatusOK, res) + ext := ".yaml" + if section == "notes" { + ext = ".txt" } - }) + + res, err := RevisionDiff(functor, ext, qp.Namespace, qp.Name, cRevDiff, qp.Revision, flag) + if err != nil { + return "", err + } + return res, nil + } else { + res, err := functor(qp.Namespace, qp.Name, qp.Revision, flag) + if err != nil { + return "", err + } + return res, nil + } } func configureKubectls(api *gin.Engine, data *DataLayer) { @@ -192,16 +192,14 @@ func configureKubectls(api *gin.Engine, data *DataLayer) { }) api.GET("/api/kube/resources/:kind", func(c *gin.Context) { - cName := c.Query("name") - cNamespace := c.Query("namespace") - if cName == "" { - _ = c.AbortWithError(http.StatusBadRequest, errors.New("missing required query string parameter: name")) - return + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) } - res, err := data.GetResource(cNamespace, &GenericResource{ + res, err := data.GetResource(qp.Namespace, &GenericResource{ TypeMeta: v1.TypeMeta{Kind: c.Param("kind")}, - ObjectMeta: v1.ObjectMeta{Name: cName}, + ObjectMeta: v1.ObjectMeta{Name: qp.Name}, }) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) @@ -225,14 +223,12 @@ func configureKubectls(api *gin.Engine, data *DataLayer) { }) api.GET("/api/kube/describe/:kind", func(c *gin.Context) { - cName := c.Query("name") - cNamespace := c.Query("namespace") - if cName == "" { - _ = c.AbortWithError(http.StatusBadRequest, errors.New("missing required query string parameter: name")) - return + qp, err := getQueryProps(c, false) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) } - res, err := data.DescribeResource(cNamespace, c.Param("kind"), cName) + res, err := data.DescribeResource(qp.Namespace, c.Param("kind"), qp.Name) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return @@ -281,3 +277,27 @@ func contextSetter(data *DataLayer) gin.HandlerFunc { c.Next() } } + +type QueryProps struct { + Namespace string + Name string + Revision int +} + +func getQueryProps(c *gin.Context, revRequired bool) (*QueryProps, error) { + qp := QueryProps{} + + qp.Namespace = c.Query("namespace") + qp.Name = c.Query("name") + if qp.Name == "" { + return nil, errors.New("missing required query string parameter: name") + } + + cRev, err := strconv.Atoi(c.Query("revision")) + if err != nil && revRequired { + return nil, err + } + qp.Revision = cRev + + return &qp, nil +} diff --git a/pkg/dashboard/data.go b/pkg/dashboard/data.go index 727c395..f9871a9 100644 --- a/pkg/dashboard/data.go +++ b/pkg/dashboard/data.go @@ -333,6 +333,14 @@ func (d *DataLayer) UninstallChart(namespace string, name string) error { return nil } +func (d *DataLayer) Revert(namespace string, name string, rev int) error { + _, err := d.runCommandHelm("rollback", name, strconv.Itoa(rev), "--namespace", namespace) + if err != nil { + return err + } + return nil +} + 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) diff --git a/pkg/dashboard/static/index.html b/pkg/dashboard/static/index.html index 3d9add6..591de9a 100644 --- a/pkg/dashboard/static/index.html +++ b/pkg/dashboard/static/index.html @@ -60,7 +60,7 @@

, revision - Rollback + Rollback Uninstall

diff --git a/pkg/dashboard/static/scripts.js b/pkg/dashboard/static/scripts.js index a9f9f8e..f3eaf0c 100644 --- a/pkg/dashboard/static/scripts.js +++ b/pkg/dashboard/static/scripts.js @@ -20,6 +20,13 @@ function revisionClicked(namespace, name, self) { $("#revDescr").addClass("text-danger") } + if (false) { // TODO: hide if only one revision + $("#btnRollback").hide() + } else { + const rev = $("#specRev").data("last-rev") == elm.revision ? elm.revision - 1 : elm.revision + $("#btnRollback").data("rev", rev).show().find("span").text("Rollback to #" + rev) + } + const tab = getHashParam("tab") if (!tab) { $("#nav-tab [data-tab=resources]").click() @@ -420,4 +427,47 @@ $("#btnUninstall").click(function () { $("#confirmModalBody").append("

" + res.kind + "" + res.metadata.name + "

") } }) +}) + +$("#btnRollback").click(function () { + const chart = getHashParam('chart'); + const namespace = getHashParam('namespace'); + const revisionNew = $("#btnRollback").data("rev") + const revisionCur = $("#specRev").data("last-rev") + $("#confirmModalLabel").html("Rollback " + chart + " from revision " + revisionCur + " to " + revisionNew) + $("#confirmModalBody").empty().append("") + $("#confirmModal .btn-primary").prop("disabled", true).off('click').click(function () { + $("#confirmModal .btn-primary").prop("disabled", true).append("") + const url = "/api/helm/charts/rollback?namespace=" + namespace + "&chart=" + chart + "&revision=" + revisionNew; + $.ajax({ + url: url, + type: 'POST', + }).fail(function () { + reportError("Failed to rollback the chart") + }).done(function () { + window.location.reload() + }) + }) + + const myModal = new bootstrap.Modal(document.getElementById('confirmModal'), {}); + myModal.show() + + let qstr = "chart=" + chart + "&namespace=" + namespace + "&revision=" + revisionNew + "&revisionDiff=" + revisionCur + let url = "/api/helm/charts/manifests" + url += "?" + qstr + $.get(url).fail(function () { + reportError("Failed to get list of resources") + }).done(function (data) { + $("#confirmModalBody").empty(); + $("#confirmModal .btn-primary").prop("disabled", false) + + const targetElement = document.getElementById('confirmModalBody'); + const configuration = { + inputFormat: 'diff', outputFormat: 'side-by-side', + drawFileList: false, showFiles: false, highlight: true, + }; + const diff2htmlUi = new Diff2HtmlUI(targetElement, data, configuration); + diff2htmlUi.draw() + $("#confirmModalBody").prepend("

Following changes will happen to cluster:

") + }) }) \ No newline at end of file