diff --git a/README.md b/README.md index bc329b5..d8812eb 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,13 @@ helm plugin uninstall dashboard ## Support Channels -We have two main channels for supporting the Helm Dashboard users: [Slack community](#TODO) for general conversations +We have two main channels for supporting the Helm Dashboard users: [Slack community](https://komodorkommunity.slack.com/x-p3820586794880-3937175868755-4092688791734/archives/C042U85BD45/p1663573506220839) for general conversations and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real bugs. ## Roadmap ### First Public Version -- Helm Plugin Packaging - CLI launcher - Web Server with REST API - Listing the installed applications @@ -64,7 +63,12 @@ and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real - Switch clusters - Show manifest/describe upon clicking on resource +- Helm Plugin Packaging +- Styled properly + ### 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 diff --git a/pkg/dashboard/data.go b/pkg/dashboard/data.go index ac3b5ee..3a92971 100644 --- a/pkg/dashboard/data.go +++ b/pkg/dashboard/data.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/Masterminds/semver/v3" "github.com/hexops/gotextdiff" "github.com/hexops/gotextdiff/myers" "github.com/hexops/gotextdiff/span" @@ -174,8 +173,6 @@ func (d *DataLayer) ChartHistory(namespace string, chartName string) (res []*his return nil, err } - var aprev *semver.Version - var cprev *semver.Version for _, elm := range res { chartRepoName, curVer, err := chartAndVersion(elm.Chart) if err != nil { @@ -183,32 +180,7 @@ func (d *DataLayer) ChartHistory(namespace string, chartName string) (res []*his } elm.ChartName = chartRepoName elm.ChartVer = curVer - elm.Action = "" elm.Updated.Time = elm.Updated.Time.Round(time.Second) - - cver, err1 := semver.NewVersion(elm.ChartVer) - aver, err2 := semver.NewVersion(elm.AppVersion) - if err1 == nil && err2 == nil { - if aprev != nil && cprev != nil { - switch { - case aprev.LessThan(aver): - elm.Action = "app_upgrade" - case aprev.GreaterThan(aver): - elm.Action = "app_downgrade" - case cprev.LessThan(cver): - elm.Action = "chart_upgrade" - case cprev.GreaterThan(cver): - elm.Action = "chart_downgrade" - default: - elm.Action = "reconfigure" - } - } - } else { - log.Debugf("Semver parsing errors: %s=%s, %s=%s", elm.ChartVer, err1, elm.AppVersion, err2) - } - - aprev = aver - cprev = cver } return res, nil diff --git a/pkg/dashboard/helmHandlers.go b/pkg/dashboard/helmHandlers.go index 698e60f..b8bad42 100644 --- a/pkg/dashboard/helmHandlers.go +++ b/pkg/dashboard/helmHandlers.go @@ -22,6 +22,8 @@ func (h *HelmHandler) GetCharts(c *gin.Context) { c.IndentedJSON(http.StatusOK, res) } +// TODO: helm show chart komodorio/k8s-watcher to get the icon URL + func (h *HelmHandler) Uninstall(c *gin.Context) { qp, err := getQueryProps(c, false) if err != nil { diff --git a/pkg/dashboard/helmTypes.go b/pkg/dashboard/helmTypes.go index 031ef9d..b04bee2 100644 --- a/pkg/dashboard/helmTypes.go +++ b/pkg/dashboard/helmTypes.go @@ -25,7 +25,6 @@ type historyElement struct { Description string `json:"description"` ChartName string `json:"chart_name"` ChartVer string `json:"chart_ver"` - Action string `json:"action"` } type repoChartElement struct { diff --git a/pkg/dashboard/static/action.svg b/pkg/dashboard/static/action.svg new file mode 100644 index 0000000..7146f30 --- /dev/null +++ b/pkg/dashboard/static/action.svg @@ -0,0 +1,4 @@ + + + + diff --git a/pkg/dashboard/static/actions.js b/pkg/dashboard/static/actions.js new file mode 100644 index 0000000..488a642 --- /dev/null +++ b/pkg/dashboard/static/actions.js @@ -0,0 +1,189 @@ +$("#btnUpgradeCheck").click(function () { + const self = $(this) + self.find(".bi-repeat").hide() + self.find(".spinner-border").show() + const repoName = self.data("repo") + $("#btnUpgrade span").text("Checking...") + $.post("/api/helm/repo/update?name=" + repoName).fail(function (xhr) { + reportError("Failed to update chart repo", xhr) + }).done(function () { + self.find(".spinner-border").hide() + self.find(".bi-repeat").show() + + checkUpgradeable(self.data("chart")) + $("#btnUpgradeCheck").prop("disabled", true) + }) +}) + + +function checkUpgradeable(name) { + $.getJSON("/api/helm/repo/search?name=" + name).fail(function (xhr) { + reportError("Failed to find chart in repo", xhr) + }).done(function (data) { + if (!data) { + return + } + + $('#upgradeModalLabel select').empty() + for (let i = 0; i < data.length; i++) { + $('#upgradeModalLabel select').append("") + } + + 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) + } else { + $("#btnUpgrade span").text("No upgrades") + } + + $("#btnUpgrade").off("click").click(function () { + popUpUpgrade($(this), verCur, elm) + }) + }) +} + +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) + + $("#upgradeModalLabel .name").text(name) + $("#upgradeModalLabel .ver-old").text(verCur) + + $('#upgradeModalLabel select').val(elm.version).trigger("change") + + const myModal = new bootstrap.Offcanvas(document.getElementById('upgradeModal'), {}); + myModal.show() + + const btnConfirm = $("#upgradeModal .btn-confirm"); + btnConfirm.prop("disabled", true).off('click').click(function () { + console.log("working") + btnConfirm.prop("disabled", true).prepend('') + $.ajax({ + url: url + "&version=" + $('#upgradeModalLabel select').val(), + type: 'POST', + }).fail(function (xhr) { + reportError("Failed to upgrade the chart", xhr) + }).done(function (data) { + setHashParam("revision", data.version) + window.location.reload() + }) + }) +} + +$('#upgradeModalLabel 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) { + reportError("Failed to get upgrade info", xhr) + }).done(function (data) { + $("#upgradeModalBody").empty(); + $("#upgradeModal .btn-confirm").prop("disabled", false) + + const targetElement = document.getElementById('upgradeModalBody'); + const configuration = { + inputFormat: 'diff', outputFormat: 'side-by-side', + drawFileList: false, showFiles: false, highlight: true, + }; + 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") + } + }) +}) + +const btnConfirm = $("#confirmModal .btn-confirm"); +$("#btnUninstall").click(function () { + const chart = getHashParam('chart'); + const namespace = getHashParam('namespace'); + const revision = $("#specRev").data("last-rev") + $("#confirmModalLabel").html("Uninstall " + chart + " from namespace " + namespace + "") + $("#confirmModalBody").empty().append('') + btnConfirm.prop("disabled", true).off('click').click(function () { + btnConfirm.prop("disabled", true).append('') + const url = "/api/helm/charts?namespace=" + namespace + "&name=" + chart; + $.ajax({ + url: url, + type: 'DELETE', + }).fail(function (xhr) { + reportError("Failed to delete the chart", xhr) + }).done(function () { + window.location.href = "/" + }) + }) + + const myModal = new bootstrap.Offcanvas(document.getElementById('confirmModal')); + myModal.show() + + let qstr = "name=" + chart + "&namespace=" + namespace + "&revision=" + revision + let url = "/api/helm/charts/resources" + url += "?" + qstr + $.getJSON(url).fail(function (xhr) { + reportError("Failed to get list of resources", xhr) + }).done(function (data) { + $("#confirmModalBody").empty().append("

Following resources will be deleted from the cluster:

"); + btnConfirm.prop("disabled", false) + for (let i = 0; i < data.length; i++) { + const res = data[i] + $("#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('') + btnConfirm.prop("disabled", true).off('click').click(function () { + btnConfirm.prop("disabled", true).append('') + const url = "/api/helm/charts/rollback?namespace=" + namespace + "&name=" + chart + "&revision=" + revisionNew; + $.ajax({ + url: url, + type: 'POST', + }).fail(function (xhr) { + reportError("Failed to rollback the chart", xhr) + }).done(function () { + window.location.reload() + }) + }) + + const myModal = new bootstrap.Offcanvas(document.getElementById('confirmModal'), {}); + myModal.show() + + let qstr = "name=" + chart + "&namespace=" + namespace + "&revision=" + revisionNew + "&revisionDiff=" + revisionCur + let url = "/api/helm/charts/manifests" + url += "?" + qstr + $.get(url).fail(function (xhr) { + reportError("Failed to get list of resources", xhr) + }).done(function (data) { + $("#confirmModalBody").empty(); + $("#confirmModal .btn-confirm").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() + if (data) { + $("#confirmModalBody").prepend("

Following changes will happen to cluster:

") + } else { + $("#confirmModalBody").html("

No changes will happen to cluster

") + } + }) +}) + diff --git a/pkg/dashboard/static/details-view.js b/pkg/dashboard/static/details-view.js new file mode 100644 index 0000000..b1556fc --- /dev/null +++ b/pkg/dashboard/static/details-view.js @@ -0,0 +1,207 @@ +function revisionClicked(namespace, name, self) { + let active = "active border-primary border-1 bg-white"; + let inactive = "border-secondary bg-secondary"; + revRow.find(".active").removeClass(active).addClass(inactive) + self.removeClass(inactive).addClass(active) + const elm = self.data("elm") + console.log(elm) + setHashParam("revision", elm.revision) + $("#sectionDetails span.rev").text("#"+elm.revision) + statusStyle(elm.status, $("#none"), $("#sectionDetails .rev-details .rev-status")) + + $("#sectionDetails .rev-date").text(elm.updated.replace("T", " ").replace("+", " +")) + $("#sectionDetails .rev-tags .rev-chart").text(elm.chart) + $("#sectionDetails .rev-tags .rev-app").text(elm.app_version) + $("#sectionDetails .rev-tags .rev-ns").text(getHashParam("namespace")) + + $("#revDescr").text(elm.description).removeClass("text-danger") + if (elm.status === "failed") { + $("#revDescr").addClass("text-danger") + } + + const rev = $("#specRev").data("last-rev") == elm.revision ? elm.revision - 1 : elm.revision + if (!rev || getHashParam("revision") === $("#specRev").data("first-rev")) { + $("#btnRollback").hide() + } else { + $("#btnRollback").show().data("rev", rev).find("span").text("Rollback to #" + rev) + } + + const tab = getHashParam("tab") + if (!tab) { + $("#nav-tab [data-tab=resources]").click() + } else { + $("#nav-tab [data-tab=" + tab + "]").click() + } +} + + +function loadContentWrapper() { + let revDiff = 0 + const revision = parseInt(getHashParam("revision")); + if (revision === $("#specRev").data("first-rev")) { + revDiff = 0 + } else 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 = "name=" + 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 (xhr) { + reportError("Failed to get diff of " + mode, xhr) + }).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)
+            }
+        }
+    })
+}
+
+$('#specRev').keyup(function (event) {
+    let keycode = (event.keyCode ? event.keyCode : event.which);
+    if (keycode == '13') {
+        $("#diffModeRev").click()
+    }
+    event.preventDefault()
+});
+
+
+$("#userDefinedVals").change(function () {
+    const self = $(this)
+    const flag = $("#userDefinedVals").prop("checked");
+    setHashParam("udv", flag)
+    loadContentWrapper()
+})
+
+$("#modePanel [data-mode]").click(function () {
+    const self = $(this)
+    const mode = self.data("mode")
+    setHashParam("mode", mode)
+    loadContentWrapper()
+})
+
+$("#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)
+
+    if (self.data("tab") === "resources") {
+        showResources(getHashParam("namespace"), getHashParam("chart"), getHashParam("revision"))
+    } else {
+        const mode = getHashParam("mode")
+        if (!mode) {
+            $("#modePanel [data-mode=view]").trigger('click')
+        } else {
+            $("#modePanel [data-mode=" + mode + "]").trigger('click')
+        }
+    }
+})
+
+function showResources(namespace, chart, revision) {
+    const resBody = $("#nav-resources .body");
+    resBody.empty().append('');
+    let qstr = "name=" + chart + "&namespace=" + namespace + "&revision=" + revision
+    let url = "/api/helm/charts/resources"
+    url += "?" + qstr
+    $.getJSON(url).fail(function (xhr) {
+        reportError("Failed to get list of resources", xhr)
+    }).done(function (data) {
+        resBody.empty();
+        for (let i = 0; i < data.length; i++) {
+            const res = data[i]
+            const resBlock = $(`
+                    
+
+
+
Getting status...
+
+
+ `) + + resBlock.find(".res-kind").text(res.kind) + resBlock.find(".res-name").text(res.metadata.name) + + resBody.append(resBlock) + let ns = res.metadata.namespace ? res.metadata.namespace : namespace + $.getJSON("/api/kube/resources/" + res.kind.toLowerCase() + "?name=" + res.metadata.name + "&namespace=" + ns).fail(function () { + //reportError("Failed to get list of resources") + }).done(function (data) { + const badge = $("").text(data.status.phase); + if (["Available", "Active", "Established"].includes(data.status.phase)) { + badge.addClass("bg-success") + } else if (["Exists"].includes(data.status.phase)) { + badge.addClass("bg-success bg-opacity-50") + } else if (["Progressing"].includes(data.status.phase)) { + badge.addClass("bg-warning") + } else { + badge.addClass("bg-danger") + } + + const statusBlock = resBlock.find(".res-status"); + statusBlock.empty().append(badge).append("" + (data.status.message ? data.status.message : '') + "") + + if (badge.text() !== "NotFound") { + resBlock.find(".res-actions") + resBlock.find(".res-actions").append("") + statusBlock.find(".bi-zoom-in").click(function () { + showDescribe(ns, res.kind, res.metadata.name) + }) + } + }) + } + }) +} + +function showDescribe(ns, kind, name) { + $("#describeModalLabel").text("Describe " + kind + ": " + ns + " / " + name) + $("#describeModalBody").empty().append('') + + const myModal = new bootstrap.Offcanvas(document.getElementById('describeModal')); + myModal.show() + $.get("/api/kube/describe/" + kind.toLowerCase() + "?name=" + name + "&namespace=" + ns).fail(function (xhr) { + reportError("Failed to describe resource", xhr) + }).done(function (data) { + data = hljs.highlight(data, {language: 'yaml'}).value + $("#describeModalBody").empty().append("
").find("pre").html(data)
+    })
+}
diff --git a/pkg/dashboard/static/helm-gray-50.svg b/pkg/dashboard/static/helm-gray-50.svg
new file mode 100644
index 0000000..b2da19d
--- /dev/null
+++ b/pkg/dashboard/static/helm-gray-50.svg
@@ -0,0 +1,65 @@
+
+
+  
+  
+  
+  
+  
+  
+  
+  
+
diff --git a/pkg/dashboard/static/helm-gray.svg b/pkg/dashboard/static/helm-gray.svg
new file mode 100644
index 0000000..aabe2bf
--- /dev/null
+++ b/pkg/dashboard/static/helm-gray.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/pkg/dashboard/static/index.html b/pkg/dashboard/static/index.html
index d37e222..98da1e5 100644
--- a/pkg/dashboard/static/index.html
+++ b/pkg/dashboard/static/index.html
@@ -5,6 +5,9 @@
     
     
     Helm Dashboard
+    
+
     
     
@@ -14,186 +17,262 @@
 
 
 
-
-