diff --git a/README.md b/README.md index a94240a..5dd3106 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,26 @@ -# Helm Dashboard +# Helm Dashboard A simplified way of working with Helm. +[](screenshot.png) + ## Local Testing -Until we make our repo public, we have to use a custom way to install the plugin. +Prerequisites: `helm` and `kubectl` binaries installed and operational. -To install, checkout the source code and run from source dir: -```shell -helm plugin install . -``` +Until we make our repo public, we have to use a custom way to install the plugin. There is a need to build binary for plugin to function, run: ```shell go build -o bin/dashboard . ``` -Local install of plugin just creates a symlink, so making the changes and rebuilding the binary would not require reinstall of a plugin. +To install, checkout the source code and run from source dir: +```shell +helm plugin install . +``` + +Local install of plugin just creates a symlink, so making the changes and rebuilding the binary would not require to reinstall a plugin. To use the plugin, run in your terminal: ```shell @@ -64,4 +68,5 @@ Browsing repositories Adding new repository Recognise & show ArgoCD-originating charts/objects -Have cleaner idea on the web API structure \ No newline at end of file +Have cleaner idea on the web API structure +See if we can build in Chechov or Validkube validation \ No newline at end of file diff --git a/pkg/dashboard/api.go b/pkg/dashboard/api.go index 5b8d94c..ece5eeb 100644 --- a/pkg/dashboard/api.go +++ b/pkg/dashboard/api.go @@ -14,6 +14,11 @@ import ( //go:embed static/* var staticFS embed.FS +func noCache(c *gin.Context) { + c.Header("Cache-Control", "no-cache") + c.Next() +} + func errorHandler(c *gin.Context) { c.Next() @@ -28,7 +33,7 @@ func errorHandler(c *gin.Context) { } } -func NewRouter(abortWeb ControlChan, data DataLayer) *gin.Engine { +func NewRouter(abortWeb ControlChan, data *DataLayer) *gin.Engine { var api *gin.Engine if os.Getenv("DEBUG") == "" { api = gin.New() @@ -37,6 +42,8 @@ func NewRouter(abortWeb ControlChan, data DataLayer) *gin.Engine { api = gin.Default() } + api.Use(noCache) + api.Use(contextSetter(data)) api.Use(errorHandler) configureStatic(api) @@ -44,7 +51,7 @@ func NewRouter(abortWeb ControlChan, data DataLayer) *gin.Engine { return api } -func configureRoutes(abortWeb ControlChan, data DataLayer, api *gin.Engine) { +func configureRoutes(abortWeb ControlChan, data *DataLayer, api *gin.Engine) { // server shutdown handler api.DELETE("/", func(c *gin.Context) { abortWeb <- struct{}{} @@ -84,7 +91,19 @@ func configureRoutes(abortWeb ControlChan, data DataLayer, api *gin.Engine) { c.IndentedJSON(http.StatusOK, res) }) - api.GET("/api/helm/charts/manifest/diff", func(c *gin.Context) { + 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 + } + cName := c.Query("chart") cNamespace := c.Query("namespace") if cName == "" { @@ -92,26 +111,40 @@ func configureRoutes(abortWeb ControlChan, data DataLayer, api *gin.Engine) { return } - cRev1, err := strconv.Atoi(c.Query("revision1")) + cRev, err := strconv.Atoi(c.Query("revision")) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } + 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 + } - cRev2, err := strconv.Atoi(c.Query("revision2")) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } + ext := ".yaml" + if c.Param("section") == "notes" { + ext = ".txt" + } - res, err := data.RevisionManifestsDiff(cNamespace, cName, cRev1, cRev2) - if err != nil { - _ = c.AbortWithError(http.StatusInternalServerError, err) - return + 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) } - c.IndentedJSON(http.StatusOK, res) }) - } func configureStatic(api *gin.Engine) { @@ -143,3 +176,13 @@ func configureStatic(api *gin.Engine) { }) } } + +func contextSetter(data *DataLayer) gin.HandlerFunc { + return func(c *gin.Context) { + if context, ok := c.Request.Header["X-Kubecontext"]; ok { + log.Debugf("Setting current context to: %s", context) + data.KubeContext = context[0] + } + c.Next() + } +} diff --git a/pkg/dashboard/data.go b/pkg/dashboard/data.go index 73a0b4c..02ea4aa 100644 --- a/pkg/dashboard/data.go +++ b/pkg/dashboard/data.go @@ -25,7 +25,6 @@ type DataLayer struct { } func (l *DataLayer) runCommand(cmd ...string) (string, error) { - // TODO: --kube-context=context-name to juggle clusters log.Debugf("Starting command: %s", cmd) prog := exec.Command(cmd[0], cmd[1:]...) prog.Env = os.Environ() @@ -44,7 +43,7 @@ func (l *DataLayer) runCommand(cmd ...string) (string, error) { log.Warnf("STDERR:\n%s", serr) } if eerr, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("failed to run command %s: %s", cmd, eerr) + return "", fmt.Errorf("failed to run command %s:\nError: %s\nSTDERR:%s", cmd, eerr, serr) } return "", err } @@ -93,10 +92,12 @@ func (l *DataLayer) CheckConnectivity() error { return errors.New("did not find any kubectl contexts configured") } - _, err = l.runCommandHelm("env") - if err != nil { - return err - } + /* + _, err = l.runCommandHelm("env") // no point in doing is, since the default context may be invalid + if err != nil { + return err + } + */ return nil } @@ -158,7 +159,7 @@ func (l *DataLayer) ListInstalled() (res []releaseElement, err error) { func (l *DataLayer) ChartHistory(namespace string, chartName string) (res []*historyElement, err error) { // TODO: there is `max` but there is no `offset` - out, err := l.runCommandHelm("history", chartName, "--namespace", namespace, "--max", "5", "--output", "json") + out, err := l.runCommandHelm("history", chartName, "--namespace", namespace, "--output", "json", "--max", "18") if err != nil { return nil, err } @@ -221,7 +222,9 @@ func (l *DataLayer) ChartRepoVersions(chartName string) (res []repoChartElement, return res, nil } -func (l *DataLayer) RevisionManifests(namespace string, chartName string, revision int) (res string, err error) { +type SectionFn = func(string, string, int, bool) (string, error) + +func (l *DataLayer) RevisionManifests(namespace string, chartName string, revision int, _ bool) (res string, err error) { out, err := l.runCommandHelm("get", "manifest", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision)) if err != nil { return "", err @@ -229,19 +232,45 @@ func (l *DataLayer) RevisionManifests(namespace string, chartName string, revisi return out, nil } -func (l *DataLayer) RevisionManifestsDiff(namespace string, name string, revision1 int, revision2 int) (string, error) { - manifest1, err := l.RevisionManifests(namespace, name, revision1) +func (l *DataLayer) RevisionNotes(namespace string, chartName string, revision int, _ bool) (res string, err error) { + out, err := l.runCommandHelm("get", "notes", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision)) if err != nil { + return "", err + } + return out, nil +} + +func (l *DataLayer) RevisionValues(namespace string, chartName string, revision int, onlyUserDefined bool) (res string, err error) { + cmd := []string{"get", "values", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision), "--output", "yaml"} + if !onlyUserDefined { + cmd = append(cmd, "--all") + } + out, err := l.runCommandHelm(cmd...) + if err != nil { + return "", err + } + return out, 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) return "", nil } - manifest2, err := l.RevisionManifests(namespace, name, revision2) + manifest1, err := functor(namespace, name, revision1, flag) if err != nil { - return "", nil + return "", err + } + + manifest2, err := functor(namespace, name, revision2, flag) + if err != nil { + return "", err } edits := myers.ComputeEdits(span.URIFromPath(""), manifest1, manifest2) - unified := gotextdiff.ToUnified("a.txt", "b.txt", manifest1, edits) + unified := gotextdiff.ToUnified(strconv.Itoa(revision1)+ext, strconv.Itoa(revision2)+ext, manifest1, edits) diff := fmt.Sprint(unified) + log.Debugf("The diff is: %s", diff) return diff, nil } diff --git a/pkg/dashboard/data_test.go b/pkg/dashboard/data_test.go index 32f94f4..60ad8c1 100644 --- a/pkg/dashboard/data_test.go +++ b/pkg/dashboard/data_test.go @@ -55,13 +55,13 @@ func TestFlow(t *testing.T) { } _ = upgrade - manifests, err := data.RevisionManifests(chart.Namespace, chart.Name, history[len(history)-1].Revision) + manifests, err := data.RevisionManifests(chart.Namespace, chart.Name, history[len(history)-1].Revision, true) if err != nil { t.Fatal(err) } _ = manifests - diff, err := data.RevisionManifestsDiff(chart.Namespace, chart.Name, history[len(history)-1].Revision, history[len(history)-2].Revision) + diff, err := RevisionDiff(data.RevisionManifests, ".yaml", chart.Namespace, chart.Name, history[len(history)-1].Revision, history[len(history)-2].Revision, true) if err != nil { t.Fatal(err) } diff --git a/pkg/dashboard/server.go b/pkg/dashboard/server.go index f91900d..b0a4fa6 100644 --- a/pkg/dashboard/server.go +++ b/pkg/dashboard/server.go @@ -28,7 +28,7 @@ func StartServer() (string, ControlChan) { } abort := make(ControlChan) - api := NewRouter(abort, data) + api := NewRouter(abort, &data) done := startBackgroundServer(address, api, abort) return "http://" + address, done diff --git a/pkg/dashboard/static/index.html b/pkg/dashboard/static/index.html index 6cd31f8..8f94af5 100644 --- a/pkg/dashboard/static/index.html +++ b/pkg/dashboard/static/index.html @@ -9,7 +9,8 @@ integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous"> - + + @@ -33,12 +34,14 @@ +
@@ -49,56 +52,60 @@
- + + + \ No newline at end of file diff --git a/pkg/dashboard/static/scripts.js b/pkg/dashboard/static/scripts.js index d1ea1c8..4b93fdb 100644 --- a/pkg/dashboard/static/scripts.js +++ b/pkg/dashboard/static/scripts.js @@ -1,66 +1,173 @@ const clusterSelect = $("#cluster"); const chartsCards = $("#charts"); +const revRow = $("#sectionDetails .row"); function reportError(err) { alert(err) // TODO: nice modal/baloon/etc } function revisionClicked(namespace, name, self) { + let active = "active border-primary border-2 bg-opacity-25 bg-primary"; + let inactive = "border-secondary bg-white"; + revRow.find(".active").removeClass(active).addClass(inactive) + self.removeClass(inactive).addClass(active) const elm = self.data("elm") - const parts = window.location.hash.split("&") - parts[2] = elm.revision - window.location.hash = parts.join("&") + setHashParam("revision", elm.revision) $("#sectionDetails h1 span.rev").text(elm.revision) - let qstr = "chart=" + name + "&namespace=" + namespace + "&revision1=" + (elm.revision - 1) + "&revision2=" + elm.revision - let url = "/api/helm/charts/manifest/diff?" + qstr; - $.getJSON(url).fail(function () { - reportError("Failed to get diff of manifests") - }).done(function (data) { - if (data === "") { - $("#manifestText").text("No differences to display") - } else { - const targetElement = document.getElementById('manifestText'); - const configuration = { - inputFormat: 'diff', - outputFormat: 'side-by-side', + $("#chartName").text(elm.chart) + $("#revDescr").text(elm.description).removeClass("text-danger") + if (elm.status === "failed") { + $("#revDescr").addClass("text-danger") + } - drawFileList: false, - showFiles: false, - //matching: 'lines', - }; - const diff2htmlUi = new Diff2HtmlUI(targetElement, data, configuration); - diff2htmlUi.draw() + const tab = getHashParam("tab") + if (!tab) { + $("#nav-tab [data-tab=manifests]").click() + } else { + $("#nav-tab [data-tab=" + tab + "]").click() + } +} + +$("#nav-tab [data-tab]").click(function () { + const self = $(this) + setHashParam("tab", self.data("tab")) + + if (self.data("tab") === "values") { + $("#userDefinedVals").parent().show() + } else { + $("#userDefinedVals").parent().hide() + } + + const flag = getHashParam("udv") === "true"; + $("#userDefinedVals").prop("checked", flag) + + const mode = getHashParam("mode") + if (!mode) { + $("#modePanel [data-mode=diff-prev]").trigger('click') + } else { + $("#modePanel [data-mode=" + mode + "]").trigger('click') + } +}) + +$("#modePanel [data-mode]").click(function () { + const self = $(this) + const mode = self.data("mode") + setHashParam("mode", mode) + loadContentWrapper() +}) + + +$("#userDefinedVals").change(function () { + const self = $(this) + const flag = $("#userDefinedVals").prop("checked"); + setHashParam("udv", flag) + loadContentWrapper() +}) + +function loadContentWrapper() { + let revDiff = 0 + const revision = parseInt(getHashParam("revision")); + if (getHashParam("mode") === "diff-prev") { + revDiff = revision - 1 + } else if (getHashParam("mode") === "diff-rev") { + revDiff = $("#specRev").val() + } + const flag = $("#userDefinedVals").prop("checked"); + loadContent(getHashParam("tab"), getHashParam("namespace"), getHashParam("chart"), revision, revDiff, flag) +} + +function loadContent(mode, namespace, name, revision, revDiff, flag) { + let qstr = "chart=" + name + "&namespace=" + namespace + "&revision=" + revision + if (revDiff) { + qstr += "&revisionDiff=" + revDiff + } + + if (flag) { + qstr += "&flag=" + flag + } + + let url = "/api/helm/charts/" + mode + url += "?" + qstr + const diffDisplay = $("#manifestText"); + diffDisplay.empty().append("") + $.get(url).fail(function () { + reportError("Failed to get diff of " + mode) + }).done(function (data) { + diffDisplay.empty(); + if (data === "") { + diffDisplay.text("No differences to display") + } else { + if (revDiff) { + const targetElement = document.getElementById('manifestText'); + const configuration = { + inputFormat: 'diff', outputFormat: 'side-by-side', + + drawFileList: false, showFiles: false, highlight: true, //matching: 'lines', + }; + const diff2htmlUi = new Diff2HtmlUI(targetElement, data, configuration); + diff2htmlUi.draw() + } else { + data = hljs.highlight(data, {language: 'yaml'}).value + const code = $("#manifestText").empty().append("
").find("pre");
+                code.html(data)
+            }
         }
     })
 }
 
-function fillChartDetails(namespace, name) {
+$('#specRev').keyup(function (event) {
+    let keycode = (event.keyCode ? event.keyCode : event.which);
+    if (keycode == '13') {
+        $("#diffModeRev").click()
+    }
+    event.preventDefault()
+});
+
+function loadChartHistory(namespace, name) {
     $("#sectionDetails").show()
     $("#sectionDetails h1 span.name").text(name)
+    revRow.empty().append("
") $.getJSON("/api/helm/charts/history?chart=" + name + "&namespace=" + namespace).fail(function () { reportError("Failed to get list of clusters") }).done(function (data) { - let revRow = $("#sectionDetails .row"); + revRow.empty() for (let x = 0; x < data.length; x++) { const elm = data[x] - const rev = $(`
+ $("#specRev").val(elm.revision) + const rev = $(`
-
Chart:
- App:
-
+ App ver:
+

Age:
+

`) rev.find(".rev-number").text("#" + elm.revision) rev.find(".app-ver").text(elm.app_version) rev.find(".chart-ver").text(elm.chart_ver) rev.find(".rev-date").text(elm.updated.replace("T", " ")) - rev.find(".rev-status").text(elm.status).attr("title", elm.action) + rev.find(".rev-age").text(getAge(elm, data[x + 1])) + rev.find(".rev-status").text(elm.status) + rev.find(".fa").attr("title", elm.action) if (elm.status === "failed") { rev.find(".rev-status").parent().addClass("text-danger") } - if (elm.status === "deployed") { - //rev.removeClass("bg-white").addClass("text-light bg-primary") + switch (elm.action) { + case "app_upgrade": + rev.find(".app-ver").append(" ") + break + case "app_downgrade": + rev.find(".app-ver").append(" ") + break + case "chart_upgrade": + rev.find(".chart-ver").append(" ") + break + case "chart_downgrade": + rev.find(".chart-ver").append(" ") + break + case "reconfigure": // ? + break } rev.data("elm", elm) @@ -72,18 +179,29 @@ function fillChartDetails(namespace, name) { revRow.append(rev) } - const parts = window.location.hash.substring(1).split("&") - if (parts.length >= 3) { - revRow.find(".rev-" + parts[2]).click() + const rev = getHashParam("revision") + if (rev) { + revRow.find(".rev-" + rev).click() } else { revRow.find("div.col-md-2:last-child").click() } }) +} +function getHashParam(name) { + const params = new URLSearchParams(window.location.hash.substring(1)) + return params.get(name) +} + +function setHashParam(name, val) { + const params = new URLSearchParams(window.location.hash.substring(1)) + params.set(name, val) + window.location.hash = new URLSearchParams(params).toString() } function loadChartsList() { $("#sectionList").show() + chartsCards.empty().append("
Loading...
") $.getJSON("/api/helm/charts").fail(function () { reportError("Failed to get list of clusters") }).done(function (data) { @@ -116,8 +234,9 @@ function loadChartsList() { $("#sectionList").hide() let chart = self.data("chart"); - window.location.hash = chart.namespace + "&" + chart.name - fillChartDetails(chart.namespace, chart.name) + setHashParam("namespace", chart.namespace) + setHashParam("chart", chart.name) + loadChartHistory(chart.namespace, chart.name) }) chartsCards.append($("
").append(card)) @@ -125,30 +244,64 @@ function loadChartsList() { }) } + $(function () { // cluster list + clusterSelect.change(function () { + Cookies.set("context", clusterSelect.val()) + window.location.href = "/" + }) + $.getJSON("/api/kube/contexts").fail(function () { reportError("Failed to get list of clusters") }).done(function (data) { + const context = Cookies.get("context") + data.forEach(function (elm) { // aws CLI uses complicated context names, the suffix does not work well // maybe we should have an `if` statement here let label = elm.Name //+ " (" + elm.Cluster + "/" + elm.AuthInfo + "/" + elm.Namespace + ")" let opt = $("").val(elm.Name).text(label) - if (elm.IsCurrent) { + if (elm.IsCurrent && !context) { opt.attr("selected", "selected") + } else if (context && elm.Name === context) { + opt.attr("selected", "selected") + $.ajaxSetup({ + headers: { + 'x-kubecontext': context + } + }); } clusterSelect.append(opt) }) - }) - clusterSelect.change(function () { - // TODO: remember it, respect it in the function above and in all other places - }) - const parts = window.location.hash.substring(1).split("&") - if (parts[0] === "") { - loadChartsList() - } else { - fillChartDetails(parts[0], parts[1]) - } + const namespace = getHashParam("namespace") + const chart = getHashParam("chart") + if (!chart) { + loadChartsList() + } else { + loadChartHistory(namespace, chart) + } + }) }) + +function getAge(obj1, obj2) { + const date = luxon.DateTime.fromISO(obj1.updated); + let dateNext = luxon.DateTime.now() + if (obj2) { + dateNext = luxon.DateTime.fromISO(obj2.updated); + } + const diff = dateNext.diff(date); + + const map = { + "years": "yr", "months": "mo", "days": "d", "hours": "h", "minutes": "m", "seconds": "s", "milliseconds": "ms" + } + + for (let unit of ["years", "months", "days", "hours", "minutes", "seconds", "milliseconds"]) { + const val = diff.as(unit); + if (val >= 1) { + return Math.round(val) + map[unit] + } + } + return "n/a" +} \ No newline at end of file diff --git a/pkg/dashboard/static/styles.css b/pkg/dashboard/static/styles.css index 055ca3e..86abb70 100644 --- a/pkg/dashboard/static/styles.css +++ b/pkg/dashboard/static/styles.css @@ -6,6 +6,6 @@ color: white!important; } -.d2h-file-header { - display: none; +.d2h-file-collapse, .d2h-tag { + opacity: 0; /* trollface */ } \ No newline at end of file diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..37ab768 Binary files /dev/null and b/screenshot.png differ