Repository-related functions (#19)

* Roadmap item

* Start building repo view

* Section switcher

* Show repo list

* Adding chart repo works

* Showing the pane

* Couple of buttons

* Listing items

* Styling

* Enriching repo view

* Navigate from repo to installed

* Tuning install popup

* Working on install

* Cosmetics
This commit is contained in:
Andrey Pokhilko
2022-10-23 13:41:45 +01:00
committed by GitHub
parent 0141eecef1
commit 0de0b5d0cb
12 changed files with 552 additions and 144 deletions

View File

@@ -6,17 +6,21 @@ A simplified way of working with Helm.
## What it Does? ## What it Does?
The _Helm Dashboard_ plugin offers a UI-driven way to view the installed Helm charts, see their revision history and corresponding k8s resources. Also, you can perform simple actions like roll back to a revision or upgrade to newer version. The _Helm Dashboard_ plugin offers a UI-driven way to view the installed Helm charts, see their revision history and
corresponding k8s resources. Also, you can perform simple actions like roll back to a revision or upgrade to newer
version.
This project is part of [Komodor's](https://komodor.com/?utm_campaign=Helm-Dash&utm_source=helm-dash-gh) vision of helping Kubernetes users to navigate and troubleshoot their clusters. This project is part of [Komodor's](https://komodor.com/?utm_campaign=Helm-Dash&utm_source=helm-dash-gh) vision of
helping Kubernetes users to navigate and troubleshoot their clusters.
Some of the key capabilities of the tool: Some of the key capabilities of the tool:
- See all installed charts and their revision history
- See manifest diff of the past revisions - See all installed charts and their revision history
- Browse k8s resources resulting from the chart - See manifest diff of the past revisions
- Easy rollback or upgrade version with a clear and easy manifest diff - Browse k8s resources resulting from the chart
- Integration with popular problem scanners - Easy rollback or upgrade version with a clear and easy manifest diff
- Easy switch between multiple clusters - Integration with popular problem scanners
- Easy switch between multiple clusters
## Installing ## Installing
@@ -27,6 +31,7 @@ helm plugin install https://github.com/komodorio/helm-dashboard.git
``` ```
To update the plugin to the latest version, run: To update the plugin to the latest version, run:
```shell ```shell
helm plugin update dashboard helm plugin update dashboard
``` ```
@@ -42,13 +47,16 @@ helm plugin uninstall dashboard
To use the plugin, your machine needs to have working `helm` and also `kubectl` commands. To use the plugin, your machine needs to have working `helm` and also `kubectl` commands.
After installing, start the UI by running: After installing, start the UI by running:
```shell ```shell
helm dashboard helm dashboard
``` ```
The command above will launch the local Web server and will open the UI in new browser tab. The command will hang waiting for you to terminate it in command-line or web UI. The command above will launch the local Web server and will open the UI in new browser tab. The command will hang
waiting for you to terminate it in command-line or web UI.
By default, the web server is only available locally. You can change that by specifying `HD_BIND` environment variable to the desired value. For example, `0.0.0.0` would bind to all IPv4 addresses or `[::0]` would be all IPv6 addresses. By default, the web server is only available locally. You can change that by specifying `HD_BIND` environment variable
to the desired value. For example, `0.0.0.0` would bind to all IPv4 addresses or `[::0]` would be all IPv6 addresses.
If your port 8080 is busy, you can specify a different port to use via `HD_PORT` environment variable. If your port 8080 is busy, you can specify a different port to use via `HD_PORT` environment variable.
@@ -58,20 +66,23 @@ If you want to increase the logging verbosity and see all the debug info, set `D
## Scanner Integrations ## Scanner Integrations
Upon startup, Helm Dashboard detects the presence of [Trivy](https://github.com/aquasecurity/trivy) and [Checkov](https://github.com/bridgecrewio/checkov) scanners. When available, these scanners are offered on k8s resources page, as well as install/upgrade preview page. Upon startup, Helm Dashboard detects the presence of [Trivy](https://github.com/aquasecurity/trivy)
and [Checkov](https://github.com/bridgecrewio/checkov) scanners. When available, these scanners are offered on k8s
resources page, as well as install/upgrade preview page.
You can request scanning of the specific k8s resource in your cluster: You can request scanning of the specific k8s resource in your cluster:
![](screenshot_scan_resource.png) ![](screenshot_scan_resource.png)
If you want to validate the k8s manifest prior to installing/reconfiguring a Helm chart, look for "Scan for Problems" button at the bottom of the dialog: If you want to validate the k8s manifest prior to installing/reconfiguring a Helm chart, look for "Scan for Problems"
button at the bottom of the dialog:
![](screenshot_scan_manifest.png) ![](screenshot_scan_manifest.png)
## Support Channels ## Support Channels
We have two main channels for supporting the Helm Dashboard users: [Slack community](https://komodorkommunity.slack.com/archives/C044U1B0265) for general conversations We have two main channels for supporting the Helm Dashboard
users: [Slack community](https://komodorkommunity.slack.com/archives/C044U1B0265) for general conversations
and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real bugs. and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real bugs.
## Roadmap & Ideas ## Roadmap & Ideas
### First Public Version ### First Public Version
@@ -93,22 +104,12 @@ and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real
- Styled properly - Styled properly
### Further Ideas ### Further Ideas
- solve umbrella-chart case - solve umbrella-chart case
- Have cleaner idea on the web API structure - Have cleaner idea on the web API structure
- Recognise & show ArgoCD-originating charts/objects, those `helm ls` does not show - Recognise & show ArgoCD-originating charts/objects, those `helm ls` does not show
- loki example - DaemonSet and StatefulSet better status display - loki example - DaemonSet and StatefulSet better status display
#### Iteration "Value Setting"
- Setting parameter values and installing
- Reconfiguring the application
#### Iteration "Repo View"
- Browsing repositories
- Adding new repository
- Installing new app from repo
## Local Dev Testing ## Local Dev Testing
Prerequisites: `helm` and `kubectl` binaries installed and operational. Prerequisites: `helm` and `kubectl` binaries installed and operational.
@@ -127,7 +128,8 @@ To install, checkout the source code and run from source dir:
helm plugin install . helm plugin install .
``` ```
Local installation of plugin just creates a symlink, so making the changes and rebuilding the binary would not require to Local installation of plugin just creates a symlink, so making the changes and rebuilding the binary would not require
to
reinstall a plugin. reinstall a plugin.
To use the plugin, run in your terminal: To use the plugin, run in your terminal:

View File

@@ -81,16 +81,23 @@ func configureRoutes(abortWeb utils.ControlChan, data *subproc.DataLayer, api *g
func configureHelms(api *gin.RouterGroup, data *subproc.DataLayer) { func configureHelms(api *gin.RouterGroup, data *subproc.DataLayer) {
h := handlers.HelmHandler{Data: data} h := handlers.HelmHandler{Data: data}
api.GET("/charts", h.GetCharts) api.GET("/charts", h.GetCharts)
api.DELETE("/charts", h.Uninstall) api.DELETE("/charts", h.Uninstall)
api.POST("/charts/rollback", h.Rollback)
api.GET("/charts/history", h.History) api.GET("/charts/history", h.History)
api.GET("/charts/resources", h.Resources) api.GET("/charts/resources", h.Resources)
api.GET("/charts/:section", h.GetInfoSection)
api.POST("/charts/install", h.Install)
api.POST("/charts/rollback", h.Rollback)
api.GET("/repo", h.RepoList)
api.POST("/repo", h.RepoAdd)
api.DELETE("/repo", h.RepoDelete)
api.GET("/repo/charts", h.RepoCharts)
api.GET("/repo/search", h.RepoSearch) api.GET("/repo/search", h.RepoSearch)
api.POST("/repo/update", h.RepoUpdate) api.POST("/repo/update", h.RepoUpdate)
api.GET("/repo/values", h.RepoValues) api.GET("/repo/values", h.RepoValues)
api.POST("/charts/install", h.Install)
api.GET("/charts/:section", h.GetInfoSection)
} }
func configureKubectls(api *gin.RouterGroup, data *subproc.DataLayer) { func configureKubectls(api *gin.RouterGroup, data *subproc.DataLayer) {

View File

@@ -31,7 +31,7 @@ func (h *HelmHandler) Uninstall(c *gin.Context) {
_ = c.AbortWithError(http.StatusBadRequest, err) _ = c.AbortWithError(http.StatusBadRequest, err)
return return
} }
err = h.Data.UninstallChart(qp.Namespace, qp.Name) err = h.Data.ChartUninstall(qp.Namespace, qp.Name)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -99,6 +99,21 @@ func (h *HelmHandler) RepoSearch(c *gin.Context) {
c.IndentedJSON(http.StatusOK, res) c.IndentedJSON(http.StatusOK, res)
} }
func (h *HelmHandler) RepoCharts(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
res, err := h.Data.ChartRepoCharts(qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, res)
}
func (h *HelmHandler) RepoUpdate(c *gin.Context) { func (h *HelmHandler) RepoUpdate(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false) qp, err := utils.GetQueryProps(c, false)
if err != nil { if err != nil {
@@ -122,21 +137,25 @@ func (h *HelmHandler) Install(c *gin.Context) {
} }
justTemplate := c.Query("flag") != "true" justTemplate := c.Query("flag") != "true"
out, err := h.Data.ChartUpgrade(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), justTemplate, c.PostForm("values")) isInitial := c.Query("initial") != "true"
out, err := h.Data.ChartInstall(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), justTemplate, c.PostForm("values"), isInitial)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
if !justTemplate { if justTemplate {
c.Header("Content-Type", "application/json") manifests := ""
} else { if isInitial {
manifests, err := h.Data.RevisionManifests(qp.Namespace, qp.Name, 0, false) manifests, err = h.Data.RevisionManifests(qp.Namespace, qp.Name, 0, false)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
}
} }
out = subproc.GetDiff(strings.TrimSpace(manifests), out, "current.yaml", "upgraded.yaml") out = subproc.GetDiff(strings.TrimSpace(manifests), out, "current.yaml", "upgraded.yaml")
} else {
c.Header("Content-Type", "application/json")
} }
c.String(http.StatusAccepted, out) c.String(http.StatusAccepted, out)
@@ -168,6 +187,39 @@ func (h *HelmHandler) RepoValues(c *gin.Context) {
c.String(http.StatusOK, out) c.String(http.StatusOK, out)
} }
func (h *HelmHandler) RepoList(c *gin.Context) {
out, err := h.Data.ChartRepoList()
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, out)
}
func (h *HelmHandler) RepoAdd(c *gin.Context) {
_, err := h.Data.ChartRepoAdd(c.PostForm("name"), c.PostForm("url"))
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HelmHandler) RepoDelete(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
_, err = h.Data.ChartRepoDelete(qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
func handleGetSection(data *subproc.DataLayer, section string, rDiff string, qp *utils.QueryProps, flag bool) (string, error) { func handleGetSection(data *subproc.DataLayer, section string, rDiff string, qp *utils.QueryProps, flag bool) (string, error) {
sections := map[string]subproc.SectionFn{ sections := map[string]subproc.SectionFn{
"manifests": data.RevisionManifests, "manifests": data.RevisionManifests,

View File

@@ -26,7 +26,8 @@ func (h *ScannersHandler) ScanDraftManifest(c *gin.Context) {
return return
} }
mnf, err := h.Data.ChartUpgrade(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), true, c.PostForm("values")) reuseVals := c.Query("initial") != "true"
mnf, err := h.Data.ChartInstall(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), true, c.PostForm("values"), reuseVals)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return

View File

@@ -30,17 +30,6 @@ function checkUpgradeable(name) {
} }
const verCur = $("#specRev").data("last-chart-ver"); const verCur = $("#specRev").data("last-chart-ver");
$('#upgradeModal select').empty()
for (let i = 0; i < data.length; i++) {
const opt = $("<option value='" + data[i].version + "'></option>");
if (data[i].version === verCur) {
opt.html(data[i].version + " &middot;")
} else {
opt.html(data[i].version)
}
$('#upgradeModal select').append(opt)
}
const elm = data[0] const elm = data[0]
$("#btnUpgradeCheck").data("repo", elm.name.split('/').shift()) $("#btnUpgradeCheck").data("repo", elm.name.split('/').shift())
$("#btnUpgradeCheck").data("chart", elm.name.split('/').pop()) $("#btnUpgradeCheck").data("chart", elm.name.split('/').pop())
@@ -56,55 +45,86 @@ function checkUpgradeable(name) {
} }
$("#btnUpgrade").off("click").click(function () { $("#btnUpgrade").off("click").click(function () {
popUpUpgrade($(this), verCur, elm) popUpUpgrade(elm, getHashParam("namespace"), getHashParam("chart"), verCur, $("#specRev").data("last-rev"))
}) })
}) })
} }
function popUpUpgrade(self, verCur, elm) { function popUpUpgrade(elm, ns, name, verCur, lastRev) {
const name = getHashParam("chart"); $("#upgradeModal .btn-confirm").prop("disabled", true)
const qstr = "?namespace=" + getHashParam("namespace") + "&name=" + name + "&chart=" + elm.name;
let url = "/api/helm/charts/install" + qstr
$('#upgradeModal select').data("qstr", qstr).data("url", url).data("chart", elm.name)
$("#upgradeModalLabel .name").text(name) $('#upgradeModal').data("chart", elm.name).data("initial", !verCur)
$("#upgradeModal .ver-old").text(verCur)
$('#upgradeModal select').val(elm.version).trigger("change") $("#upgradeModalLabel .name").text(elm.name)
const myModal = new bootstrap.Modal(document.getElementById('upgradeModal'), {}); if (verCur) {
myModal.show() $("#upgradeModal .ver-old").show().find("span").text(verCur)
$("#upgradeModal .rel-name").prop("disabled", true).val(name)
$("#upgradeModal .rel-ns").prop("disabled", true).val(ns)
} else {
$("#upgradeModal .ver-old").hide()
$("#upgradeModal .rel-name").prop("disabled", false).val(elm.name.split("/").pop())
$("#upgradeModal .rel-ns").prop("disabled", false).val("")
}
const btnConfirm = $("#upgradeModal .btn-confirm"); $.getJSON("/api/helm/repo/search?name=" + elm.name).fail(function (xhr) {
btnConfirm.prop("disabled", true).off('click').click(function () { reportError("Failed to find chart in repo", xhr)
btnConfirm.prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') }).done(function (vers) {
$.ajax({ // fill versions
type: 'POST', $('#upgradeModal select').empty()
url: url + "&version=" + $('#upgradeModal select').val() + "&flag=true", for (let i = 0; i < vers.length; i++) {
data: $("#upgradeModal textarea").data("dirty") ? $("#upgradeModal form").serialize() : null, const opt = $("<option value='" + vers[i].version + "'></option>");
}).fail(function (xhr) { if (vers[i].version === verCur) {
reportError("Failed to upgrade the chart", xhr) opt.html(vers[i].version + " &middot;")
}).done(function (data) {
if (data.version) {
setHashParam("revision", data.version)
window.location.reload()
} else { } else {
reportError("Failed to get new revision number") opt.html(vers[i].version)
} }
}) $('#upgradeModal select').append(opt)
}) }
// fill current values $('#upgradeModal select').val(elm.version).trigger("change")
const lastRev = $("#specRev").data("last-rev")
$.get("/api/helm/charts/values?namespace=" + getHashParam("namespace") + "&revision=" + lastRev + "&name=" + getHashParam("chart") + "&flag=true").fail(function (xhr) { const myModal = new bootstrap.Modal(document.getElementById('upgradeModal'), {});
reportError("Failed to get charts values info", xhr) myModal.show()
}).done(function (data) {
$("#upgradeModal textarea").val(data).data("dirty", false) if (verCur) {
// fill current values
$.get("/api/helm/charts/values?namespace=" + ns + "&revision=" + lastRev + "&name=" + name + "&flag=true").fail(function (xhr) {
reportError("Failed to get charts values info", xhr)
}).done(function (data) {
$("#upgradeModal textarea").val(data).data("dirty", false)
})
} else {
$("#upgradeModal textarea").val("").data("dirty", true)
}
}) })
} }
$("#upgradeModal .btn-confirm").click(function () {
const btnConfirm = $("#upgradeModal .btn-confirm")
btnConfirm.prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$.ajax({
type: 'POST',
url: "/api/helm/charts/install" + upgradeModalQstr() + "&flag=true",
data: $("#upgradeModal textarea").data("dirty") ? $("#upgradeModal form").serialize() : null,
}).fail(function (xhr) {
reportError("Failed to upgrade the chart", xhr)
}).done(function (data) {
if (data.version) {
setHashParam("section", null)
setHashParam("namespace", $("#upgradeModal .rel-ns").val())
setHashParam("chart", $("#upgradeModal .rel-name").val())
setHashParam("revision", data.version)
window.location.reload()
} else {
reportError("Failed to get new revision number")
}
})
})
let reconfigTimeout = null; let reconfigTimeout = null;
$("#upgradeModal textarea").keyup(function () {
function changeTimer() {
const self = $(this); const self = $(this);
self.data("dirty", true) self.data("dirty", true)
if (reconfigTimeout) { if (reconfigTimeout) {
@@ -113,7 +133,11 @@ $("#upgradeModal textarea").keyup(function () {
reconfigTimeout = window.setTimeout(function () { reconfigTimeout = window.setTimeout(function () {
requestChangeDiff() requestChangeDiff()
}, 500) }, 500)
}) }
$("#upgradeModal textarea").keyup(changeTimer)
$("#upgradeModal .rel-name").keyup(changeTimer)
$("#upgradeModal .rel-ns").keyup(changeTimer)
$('#upgradeModal select').change(function () { $('#upgradeModal select').change(function () {
const self = $(this) const self = $(this)
@@ -122,7 +146,7 @@ $('#upgradeModal select').change(function () {
// fill reference values // fill reference values
$("#upgradeModal .ref-vals").html('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') $("#upgradeModal .ref-vals").html('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$.get("/api/helm/repo/values?chart=" + self.data("chart") + "&version=" + self.val()).fail(function (xhr) { $.get("/api/helm/repo/values?chart=" + $("#upgradeModal").data("chart") + "&version=" + self.val()).fail(function (xhr) {
reportError("Failed to get upgrade info", xhr) reportError("Failed to get upgrade info", xhr)
}).done(function (data) { }).done(function (data) {
data = hljs.highlight(data, {language: 'yaml'}).value data = hljs.highlight(data, {language: 'yaml'}).value
@@ -134,10 +158,9 @@ $('#upgradeModal .btn-scan').click(function () {
const self = $(this) const self = $(this)
self.prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') self.prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
const qstr = $('#upgradeModal select').data("qstr")
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: "/api/scanners/manifests" + qstr + "&version=" + $('#upgradeModal select').val(), url: "/api/scanners/manifests" + upgradeModalQstr(),
data: $("#upgradeModal form").serialize(), data: $("#upgradeModal form").serialize(),
}).fail(function (xhr) { }).fail(function (xhr) {
reportError("Failed to scan the manifest", xhr) reportError("Failed to scan the manifest", xhr)
@@ -186,7 +209,7 @@ function requestChangeDiff() {
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: self.data("url") + "&version=" + self.val(), url: "/api/helm/charts/install" + upgradeModalQstr(),
data: values, data: values,
}).fail(function (xhr) { }).fail(function (xhr) {
$("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: " + xhr.responseText + "</p>") $("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: " + xhr.responseText + "</p>")
@@ -207,6 +230,20 @@ function requestChangeDiff() {
}) })
} }
function upgradeModalQstr() {
let qstr = "?" +
"namespace=" + $("#upgradeModal .rel-ns").val() +
"&name=" + $("#upgradeModal .rel-name").val() +
"&chart=" + $("#upgradeModal").data("chart") +
"&version=" + $('#upgradeModal select').val()
if ($("#upgradeModal").data("initial")) {
qstr += "&initial=true"
}
return qstr
}
const btnConfirm = $("#confirmModal .btn-confirm"); const btnConfirm = $("#confirmModal .btn-confirm");
$("#btnUninstall").click(function () { $("#btnUninstall").click(function () {
const chart = getHashParam('chart'); const chart = getHashParam('chart');

View File

@@ -48,18 +48,11 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item mx-2"> <li class="nav-item mx-2">
<a class="nav-link px-3 active" aria-current="page" href="/">Installed</a> <a class="nav-link px-3 section-installed">Installed</a>
</li> </li>
<!-- TODO
<li class="nav-item mx-2"> <li class="nav-item mx-2">
<a href="#" class="nav-link px-3">Repository</a> <a class="nav-link px-3 section-repo">Repository</a>
</li> </li>
-->
<!-- TODO
<li class="nav-item">
<a class="nav-link disabled">Provisional Charts</a>
</li>
-->
</ul> </ul>
<div> <div>
<a class="btn" href="https://komodor.com/?utm_campaign=Helm-Dash&utm_source=helm-dash"><img <a class="btn" href="https://komodor.com/?utm_campaign=Helm-Dash&utm_source=helm-dash"><img
@@ -75,7 +68,48 @@
</nav> </nav>
<!-- /TOP BAR --> <!-- /TOP BAR -->
<div class="row mt-3 pt-3 me-5" id="sectionList" style="display: none"> <!--REPO SECTION-->
<div class="row mt-3 pt-3 me-5 section" id="sectionRepo" style="display: none">
<div class="col-3 ps-4 repo-list">
<div class="p-2 bg-white rounded-1 b-shadow">
<h4 class="fs-6">Repositories</h4>
<ul class="list-unstyled p-2">
</ul>
<button class="btn btn-sm border-secondary text-muted">
<i class="bi-plus-lg"></i> Add Repository
</button>
</div>
</div>
<div class="col-9 repo-details bg-white b-shadow pt-4 px-5 overflow-auto rounded">
<div class="float-end">
<button class="me-2 btn btn-sm btn-light bg-white border border-secondary btn-update">
<i class="bi-arrow-repeat"></i> Update
</button>
<button class="btn btn-sm btn-light bg-white border border-secondary btn-remove">
<i class="bi-trash3"></i> Remove
</button>
</div>
<div><span class="text-muted small fw-bold me-3">REPOSITORY</span></div>
<h2 class="mb-3">name-of-repo</h2>
<div class="mb-5">
<span class="rounded rounded-1 me-2 p-1 px-2 bg-tag text-dark">URL: <span class="url fw-bold">http://somerepo/somepath</span></span>
</div>
<div class="float-end">
<!-- TODO <input class="form-control form-control-sm" type="text" placeholder="Filter..."> -->
</div>
<div class="row bg-secondary rounded px-3 py-2 mb-3 fw-bold small"
style="text-transform: uppercase">
<div class="col-3">Chart Name</div>
<div class="col">Description</div>
<div class="col-1">Version</div>
<div class="col-1"></div>
</div>
<ul class="list-unstyled mt-4"></ul>
</div>
</div>
<div class="row mt-3 pt-3 me-5 section" id="sectionList" style="display: none">
<div class="col-2 ms-3"> <div class="col-2 ms-3">
<!-- FILTER BLOCK --> <!-- FILTER BLOCK -->
<div class="p-2 ps-2 bg-white rounded-1 b-shadow" id="filters"> <div class="p-2 ps-2 bg-white rounded-1 b-shadow" id="filters">
@@ -117,7 +151,7 @@
<!-- /INSTALLED LIST --> <!-- /INSTALLED LIST -->
</div> </div>
<div class="row flex-nowrap pt-0 mx-0" id="sectionDetails" style="display: none"> <div class="row flex-nowrap pt-0 mx-0 section" id="sectionDetails" style="display: none">
<div class="col-2 px-4 py-4 pe-3 rev-list"> <div class="col-2 px-4 py-4 pe-3 rev-list">
<h3 class="fw-bold small">Revisions</h3> <h3 class="fw-bold small">Revisions</h3>
<ul class="list-unstyled"> <ul class="list-unstyled">
@@ -270,21 +304,48 @@
</div> </div>
</div> </div>
<div class="modal" id="repoAddModal" tabindex="-1">
<div class="modal-dialog modal-dialog modal-dialog-scrollable modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Chart Repository</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form enctype="application/x-www-form-urlencoded">
<label class="form-label">Name: <input class="form-control" name="name"></label>
<label class="form-label">URL: <input class="form-control" name="url"></label>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-confirm">Add Repository</button>
</div>
</div>
</div>
</div>
<div class="modal" id="upgradeModal" tabindex="-1" aria-labelledby="describeModalLabel" aria-hidden="true"> <div class="modal" id="upgradeModal" tabindex="-1">
<div class="modal-dialog modal-dialog modal-dialog-scrollable modal-xl"> <div class="modal-dialog modal-dialog modal-dialog-scrollable modal-xl">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="upgradeModalLabel"> <h4 class="modal-title" id="upgradeModalLabel">
Upgrade <b class='text-success name'></b> Install <b class='text-success name'></b>
</h4> </h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form class="modal-body border-bottom fs-5" enctype="multipart/form-data"> <form class="modal-body border-bottom fs-5" enctype="multipart/form-data">
<div class="input-group mb-3 text-muted"> <div class="input-group mb-3 text-muted">
<label class="form-label me-4 text-dark">Version to install: <select <label class="form-label me-4 text-dark">Version to install: <select
class='fw-bold text-success ver-new'></select></label> (current version is <span class='fw-bold text-success ver-new'></select></label> <span class="ver-old">(current version is <span
class='text-success ver-old ms-1'>0.0.0</span>) class='text-success ms-1'>0.0.0</span>)</span>
</div>
<div class="input-group mb-3 text-muted">
<label class="form-label me-4 text-dark">
Release Name: <input class="form-control rel-name">
</label>
<label class="form-label me-4 text-dark">
Namespace (optional): <input class="form-control rel-ns">
</label>
</div> </div>
<div class="row"> <div class="row">
<div class="col-6 pe-3"> <div class="col-6 pe-3">
@@ -296,7 +357,8 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-6 pe-3"> <div class="col-6 pe-3">
<textarea name="values" class="form-control w-100 h-100" rows="5" style="font-family: monospace"></textarea> <textarea name="values" class="form-control w-100 h-100" rows="5"
style="font-family: monospace"></textarea>
</div> </div>
<div class="col-6 ps-3"> <div class="col-6 ps-3">
<pre class="ref-vals fs-6 w-100 bg-secondary p-2 rounded" style="max-height: 20rem"></pre> <pre class="ref-vals fs-6 w-100 bg-secondary p-2 rounded" style="max-height: 20rem"></pre>
@@ -313,7 +375,7 @@
</form> </form>
<div class="modal-footer d-flex"> <div class="modal-footer d-flex">
<button type="button" class="btn btn-scan bg-white border-secondary">Scan for Problems</button> <button type="button" class="btn btn-scan bg-white border-secondary">Scan for Problems</button>
<button type="button" class="btn btn-primary btn-confirm">Confirm Upgrade</button> <button type="button" class="btn btn-primary btn-confirm">Confirm</button>
</div> </div>
</div> </div>
</div> </div>
@@ -332,6 +394,7 @@
integrity="sha512-CSBhVREyzHAjAFfBlIBakjoRUKp5h7VSweP0InR/pAJyptH7peuhCsqAI/snV+TwZmXZqoUklpXp6R6wMnYf5Q==" integrity="sha512-CSBhVREyzHAjAFfBlIBakjoRUKp5h7VSweP0InR/pAJyptH7peuhCsqAI/snV+TwZmXZqoUklpXp6R6wMnYf5Q=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script> crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="static/repo.js"></script>
<script src="static/list-view.js"></script> <script src="static/list-view.js"></script>
<script src="static/revisions-view.js"></script> <script src="static/revisions-view.js"></script>
<script src="static/details-view.js"></script> <script src="static/details-view.js"></script>

View File

@@ -15,7 +15,6 @@ function loadChartsList() {
}) })
} }
function buildChartCard(elm) { function buildChartCard(elm) {
const card = $(`<div class="row m-0 py-3 bg-white rounded-1 b-shadow border-4 border-start"> const card = $(`<div class="row m-0 py-3 bg-white rounded-1 b-shadow border-4 border-start">
<div class="col-4 rel-name"><span class="link">release-name</span><div></div></div> <div class="col-4 rel-name"><span class="link">release-name</span><div></div></div>

View File

@@ -0,0 +1,120 @@
function loadRepoView() {
$("#sectionRepo .repo-details").hide()
$("#sectionRepo").show()
$.getJSON("/api/helm/repo").fail(function (xhr) {
reportError("Failed to get list of repositories", xhr)
}).done(function (data) {
const items = $("#sectionRepo .repo-list ul").empty()
data.forEach(function (elm) {
let opt = $('<li class="mb-2"><label><input type="radio" name="cluster" class="me-2"/><span></span></label></li>');
opt.attr('title', elm.url)
opt.find("input").val(elm.name).text(elm.name).data("item", elm)
opt.find("span").text(elm.name)
items.append(opt)
})
if (!data.length) {
items.text("No repositories found, try adding one")
}
items.find("input").click(function () {
const self = $(this)
const elm = self.data("item");
setHashParam("repo", elm.name)
$("#sectionRepo .repo-details").show()
$("#sectionRepo .repo-details h2").text(elm.name)
$("#sectionRepo .repo-details .url").text(elm.url)
$("#sectionRepo .repo-details ul").html('<span class="spinner-border spinner-border-sm mx-1" role="status" aria-hidden="true"></span>')
$.getJSON("/api/helm/repo/charts?name=" + elm.name).fail(function (xhr) {
reportError("Failed to get list of charts in repo", xhr)
}).done(function (data) {
$("#sectionRepo .repo-details ul").empty()
data.forEach(function (elm) {
const li = $(`<li class="row p-2 rounded">
<h6 class="col-3 py-2">` + elm.name.split('/').pop() + `</h6>
<div class="col py-2">` + elm.description + `</div>
<div class="col-1 py-2">` + elm.version + `</div>
<div class="col-1 action text-nowrap"><button class="btn btn-sm border-secondary bg-white">Install</button></div>
</li>`)
li.data("item", elm)
if (elm.installed_namespace) {
li.find("button").text("View").addClass("btn-success").removeClass("bg-white")
li.find(".action").prepend("<i class='bi-check-circle-fill me-1 text-success' title='Already installed'></i>")
}
li.click(repoChartClicked)
$("#sectionRepo .repo-details ul").append(li)
})
})
})
if (getHashParam("repo")) {
items.find("input[value='" + getHashParam("repo") + "']").click()
} else {
items.find("input").first().click()
}
})
}
$("#sectionRepo .repo-list .btn").click(function () {
const myModal = new bootstrap.Modal(document.getElementById('repoAddModal'), {});
myModal.show()
})
$("#repoAddModal .btn-confirm").click(function () {
$("#repoAddModal .btn-confirm").prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm mx-1" role="status" aria-hidden="true"></span>')
$.ajax({
type: 'POST',
url: "/api/helm/repo",
data: $("#repoAddModal form").serialize(),
}).fail(function (xhr) {
reportError("Failed to add repo", xhr)
}).done(function () {
setHashParam("repo", $("#repoAddModal form input[name=name]").val())
window.location.reload()
})
})
$("#sectionRepo .btn-remove").click(function () {
if (confirm("Confirm removing repository?")) {
$.ajax({
type: 'DELETE',
url: "/api/helm/repo?name=" + $("#sectionRepo .repo-details h2").text(),
}).fail(function (xhr) {
reportError("Failed to add repo", xhr)
}).done(function () {
setHashParam("repo", null)
window.location.reload()
})
}
})
$("#sectionRepo .btn-update").click(function () {
$("#sectionRepo .btn-update i").removeClass("bi-arrow-repeat").append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$.ajax({
type: 'POST',
url: "/api/helm/repo/update?name=" + $("#sectionRepo .repo-details h2").text(),
}).fail(function (xhr) {
reportError("Failed to add repo", xhr)
}).done(function () {
window.location.reload()
})
})
function repoChartClicked() {
const self = $(this)
const elm = self.data("item")
if (elm.installed_namespace) {
setHashParam("section", null)
setHashParam("namespace", elm.installed_namespace)
setHashParam("chart", elm.installed_name)
window.location.reload()
} else {
popUpUpgrade(elm)
}
}

View File

@@ -11,6 +11,27 @@ $(function () {
const context = getHashParam("context") const context = getHashParam("context")
fillClusterList(data, context); fillClusterList(data, context);
initView(); // can only do it after loading cluster list
})
$.getJSON("/api/scanners").fail(function (xhr) {
reportError("Failed to get list of scanners", xhr)
}).done(function (data) {
if (!data.length) {
$("#upgradeModal .btn-scan").hide()
}
})
})
function initView() {
$(".section").hide()
const section = getHashParam("section")
if (section === "repository") {
$("#topNav ul a.section-repo").addClass("active")
loadRepoView()
} else {
$("#topNav ul a.section-installed").addClass("active")
const namespace = getHashParam("namespace") const namespace = getHashParam("namespace")
const chart = getHashParam("chart") const chart = getHashParam("chart")
if (!chart) { if (!chart) {
@@ -18,27 +39,27 @@ $(function () {
} else { } else {
loadChartHistory(namespace, chart) loadChartHistory(namespace, chart)
} }
}) }
}
$.getJSON("/api/scanners").fail(function (xhr) { $("#topNav ul a").click(function () {
reportError("Failed to get list of scanners", xhr) const self = $(this)
}).done(function (data) {
for (let n = 0; n < data.length; n++) {
const item = $(`
<label class="form-check-label me-4">
<input class="form-check-input me-1" type="checkbox" checked name="scanner" value="` + data[n] + `"> ` + data[n] + `
</label>`)
$("#nav-scanners form span").prepend(item) $("#topNav ul a").removeClass("active")
}
if (!data.length) { const ctx = getHashParam("context")
$("#upgradeModal .btn-scan").hide() setHashParam(null, null)
} setHashParam("context", ctx)
})
if (self.hasClass("section-repo")) {
setHashParam("section", "repository")
} else {
setHashParam("section", null)
}
initView()
}) })
const myAlert = document.getElementById('errorAlert') const myAlert = document.getElementById('errorAlert')
myAlert.addEventListener('close.bs.alert', event => { myAlert.addEventListener('close.bs.alert', event => {
event.preventDefault() event.preventDefault()
@@ -60,8 +81,14 @@ function getHashParam(name) {
} }
function setHashParam(name, val) { function setHashParam(name, val) {
const params = new URLSearchParams(window.location.hash.substring(1)) let params = new URLSearchParams(window.location.hash.substring(1))
params.set(name, val) if (!name) {
params = new URLSearchParams()
} else if (!val) {
params.delete(name)
} else {
params.set(name, val)
}
window.location.hash = new URLSearchParams(params).toString() window.location.hash = new URLSearchParams(params).toString()
} }
@@ -86,14 +113,14 @@ function statusStyle(status, card, txt) {
} }
function getCleanClusterName(rawClusterName) { function getCleanClusterName(rawClusterName) {
if (rawClusterName.indexOf('arn') == 0) { if (rawClusterName.indexOf('arn') === 0) {
// AWS cluster // AWS cluster
clusterSplit = rawClusterName.split(':') const clusterSplit = rawClusterName.split(':')
clusterName = clusterSplit.at(-1).split("/").at(-1) const clusterName = clusterSplit.at(-1).split("/").at(-1)
region = clusterSplit.at(-3) const region = clusterSplit.at(-3)
return region + "/" + clusterName + ' [AWS]' return region + "/" + clusterName + ' [AWS]'
} }
if (rawClusterName.indexOf('gke') == 0) { if (rawClusterName.indexOf('gke') === 0) {
// GKE cluster // GKE cluster
return rawClusterName.split('_').at(-2) + '/' + rawClusterName.split('_').at(-1) + ' [GKE]' return rawClusterName.split('_').at(-2) + '/' + rawClusterName.split('_').at(-1) + ' [GKE]'
} }
@@ -102,13 +129,11 @@ function getCleanClusterName(rawClusterName) {
function fillClusterList(data, context) { function fillClusterList(data, context) {
data.forEach(function (elm) { data.forEach(function (elm) {
// aws CLI uses complicated context names, the suffix does not work well let label = getCleanClusterName(elm.Name)
// maybe we should have an `if` statement here
let label = elm.Name //+ " (" + elm.Cluster + "/" + elm.AuthInfo + "/" + elm.Namespace + ")"
let opt = $('<li><label><input type="radio" name="cluster" class="me-2"/><span></span></label></li>'); let opt = $('<li><label><input type="radio" name="cluster" class="me-2"/><span></span></label></li>');
opt.attr('title', label) opt.attr('title', elm.Name)
opt.find("input").val(elm.Name).text(label) opt.find("input").val(elm.Name).text(label)
opt.find("span").text(getCleanClusterName(label)) opt.find("span").text(label)
if (elm.IsCurrent && !context) { if (elm.IsCurrent && !context) {
opt.find("input").prop("checked", true) opt.find("input").prop("checked", true)
setCurrentContext(elm.Name) setCurrentContext(elm.Name)

View File

@@ -1,3 +1,7 @@
.link, .nav-link {
cursor: pointer;
}
.strike { .strike {
text-decoration: line-through; text-decoration: line-through;
} }
@@ -325,7 +329,7 @@ span.link {
} }
#nav-resources .bg-secondary { #nav-resources .bg-secondary {
background-color: #E6E7EB!important; background-color: #E6E7EB !important;
} }
.res-actions .btn-sm { .res-actions .btn-sm {
@@ -350,3 +354,15 @@ span.link {
#describeModalBody pre { #describeModalBody pre {
font-size: 1rem; font-size: 1rem;
} }
#sectionRepo .repo-details ul .row .btn {
visibility: hidden;
}
#sectionRepo .repo-details ul .row:hover {
background-color: #F4F7FA !important;
}
#sectionRepo .repo-details ul .row:hover .btn {
visibility: visible;
}

View File

@@ -122,6 +122,7 @@ func (d *DataLayer) ListContexts() (res []KubeContext, err error) {
} }
func (d *DataLayer) ListInstalled() (res []ReleaseElement, err error) { func (d *DataLayer) ListInstalled() (res []ReleaseElement, err error) {
// TODO: filter by namespace
out, err := d.runCommandHelm("ls", "--all", "--all-namespaces", "--output", "json", "--time-format", time.RFC3339) out, err := d.runCommandHelm("ls", "--all", "--all-namespaces", "--output", "json", "--time-format", time.RFC3339)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -159,8 +160,13 @@ func (d *DataLayer) ChartHistory(namespace string, chartName string) (res []*His
return res, nil return res, nil
} }
func (d *DataLayer) ChartRepoVersions(chartName string) (res []RepoChartElement, err error) { func (d *DataLayer) ChartRepoVersions(chartName string) (res []*RepoChartElement, err error) {
cmd := []string{"search", "repo", "--regexp", "/" + chartName + "\v", "--versions", "--output", "json"} search := "/" + chartName + "\v"
if strings.Contains(chartName, "/") {
search = "\v" + chartName + "\v"
}
cmd := []string{"search", "repo", "--regexp", search, "--versions", "--output", "json"}
out, err := d.runCommandHelm(cmd...) out, err := d.runCommandHelm(cmd...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -173,6 +179,46 @@ func (d *DataLayer) ChartRepoVersions(chartName string) (res []RepoChartElement,
return res, nil return res, nil
} }
func (d *DataLayer) ChartRepoCharts(repoName string) (res []*RepoChartElement, err error) {
cmd := []string{"search", "repo", "--regexp", "\v" + repoName + "/", "--output", "json"}
out, err := d.runCommandHelm(cmd...)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return nil, err
}
ins, err := d.ListInstalled()
if err != nil {
return nil, err
}
enrichRepoChartsWithInstalled(res, ins)
return res, nil
}
func enrichRepoChartsWithInstalled(charts []*RepoChartElement, installed []ReleaseElement) {
for _, chart := range charts {
for _, rel := range installed {
c, _, err := utils.ChartAndVersion(rel.Chart)
if err != nil {
log.Warnf("Failed to parse chart: %s", err)
continue
}
pieces := strings.Split(chart.Name, "/")
if pieces[1] == c {
chart.InstalledNamespace = rel.Namespace
chart.InstalledName = rel.Name
}
}
}
}
type SectionFn = func(string, string, int, bool) (string, error) // TODO: rework it into struct-based argument? type SectionFn = func(string, string, int, bool) (string, error) // TODO: rework it into struct-based argument?
func (d *DataLayer) RevisionManifests(namespace string, chartName string, revision int, _ bool) (res string, err error) { func (d *DataLayer) RevisionManifests(namespace string, chartName string, revision int, _ bool) (res string, err error) {
@@ -305,7 +351,7 @@ func (d *DataLayer) DescribeResource(namespace string, kind string, name string)
return out, nil return out, nil
} }
func (d *DataLayer) UninstallChart(namespace string, name string) error { func (d *DataLayer) ChartUninstall(namespace string, name string) error {
_, err := d.runCommandHelm("uninstall", name, "--namespace", namespace) _, err := d.runCommandHelm("uninstall", name, "--namespace", namespace)
if err != nil { if err != nil {
return err return err
@@ -335,8 +381,8 @@ func (d *DataLayer) ChartRepoUpdate(name string) error {
return nil return nil
} }
func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string, version string, justTemplate bool, values string) (string, error) { func (d *DataLayer) ChartInstall(namespace string, name string, repoChart string, version string, justTemplate bool, values string, reuseVals bool) (string, error) {
if values == "" { if values == "" && reuseVals {
oldVals, err := d.RevisionValues(namespace, name, 0, true) oldVals, err := d.RevisionValues(namespace, name, 0, true)
if err != nil { if err != nil {
return "", err return "", err
@@ -344,13 +390,13 @@ func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string
values = oldVals values = oldVals
} }
oldValsFile, close1, err := utils.TempFile(values) valsFile, close1, err := utils.TempFile(values)
defer close1() defer close1()
if err != nil { if err != nil {
return "", err return "", err
} }
cmd := []string{"upgrade", name, repoChart, "--version", version, "--namespace", namespace, "--values", oldValsFile, "--output", "json"} cmd := []string{"upgrade", "--install", "--create-namespace", name, repoChart, "--version", version, "--namespace", namespace, "--values", valsFile, "--output", "json"}
if justTemplate { if justTemplate {
cmd = append(cmd, "--dry-run") cmd = append(cmd, "--dry-run")
} }
@@ -375,6 +421,37 @@ func (d *DataLayer) ShowValues(chart string, ver string) (string, error) {
return d.runCommandHelm("show", "values", chart, "--version", ver) return d.runCommandHelm("show", "values", chart, "--version", ver)
} }
func (d *DataLayer) ChartRepoList() (res []RepositoryElement, err error) {
out, err := d.runCommandHelm("repo", "list", "--output", "json")
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return nil, err
}
return res, nil
}
func (d *DataLayer) ChartRepoAdd(name string, url string) (string, error) {
out, err := d.runCommandHelm("repo", "add", "--force-update", name, url)
if err != nil {
return "", err
}
return out, nil
}
func (d *DataLayer) ChartRepoDelete(name string) (string, error) {
out, err := d.runCommandHelm("repo", "remove", name)
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) { func RevisionDiff(functor SectionFn, ext string, namespace string, name string, revision1 int, revision2 int, flag bool) (string, error) {
if revision1 == 0 || revision2 == 0 { if revision1 == 0 || revision2 == 0 {
log.Debugf("One of revisions is zero: %d %d", revision1, revision2) log.Debugf("One of revisions is zero: %d %d", revision1, revision2)

View File

@@ -6,6 +6,7 @@ import (
) )
// unpleasant copy from Helm sources, where they have it non-public // unpleasant copy from Helm sources, where they have it non-public
type ReleaseElement struct { type ReleaseElement struct {
Name string `json:"name"` Name string `json:"name"`
Namespace string `json:"namespace"` Namespace string `json:"namespace"`
@@ -32,4 +33,12 @@ type RepoChartElement struct {
Version string `json:"version"` Version string `json:"version"`
AppVersion string `json:"app_version"` AppVersion string `json:"app_version"`
Description string `json:"description"` Description string `json:"description"`
InstalledNamespace string `json:"installed_namespace"` // custom addition on top of Helm
InstalledName string `json:"installed_name"` // custom addition on top of Helm
}
type RepositoryElement struct {
Name string `json:"name"`
URL string `json:"url"`
} }