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,19 +6,23 @@ A simplified way of working with Helm.
## 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:
- See all installed charts and their revision history
- See manifest diff of the past revisions
- Browse k8s resources resulting from the chart
- Easy rollback or upgrade version with a clear and easy manifest diff
- Integration with popular problem scanners
- Easy switch between multiple clusters
## Installing
- See all installed charts and their revision history
- See manifest diff of the past revisions
- Browse k8s resources resulting from the chart
- Easy rollback or upgrade version with a clear and easy manifest diff
- Integration with popular problem scanners
- Easy switch between multiple clusters
## Installing
To install it, simply run Helm command:
@@ -27,6 +31,7 @@ helm plugin install https://github.com/komodorio/helm-dashboard.git
```
To update the plugin to the latest version, run:
```shell
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.
After installing, start the UI by running:
```shell
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.
@@ -58,20 +66,23 @@ If you want to increase the logging verbosity and see all the debug info, set `D
## 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:
![](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)
## 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.
## Roadmap & Ideas
### First Public Version
@@ -93,22 +104,12 @@ and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real
- Styled properly
### Further Ideas
- solve umbrella-chart case
- Have cleaner idea on the web API structure
- Recognise & show ArgoCD-originating charts/objects, those `helm ls` does not show
- 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
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 .
```
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.
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) {
h := handlers.HelmHandler{Data: data}
api.GET("/charts", h.GetCharts)
api.DELETE("/charts", h.Uninstall)
api.POST("/charts/rollback", h.Rollback)
api.GET("/charts/history", h.History)
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.POST("/repo/update", h.RepoUpdate)
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) {

View File

@@ -31,7 +31,7 @@ func (h *HelmHandler) Uninstall(c *gin.Context) {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
err = h.Data.UninstallChart(qp.Namespace, qp.Name)
err = h.Data.ChartUninstall(qp.Namespace, qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
@@ -99,6 +99,21 @@ func (h *HelmHandler) RepoSearch(c *gin.Context) {
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) {
qp, err := utils.GetQueryProps(c, false)
if err != nil {
@@ -122,21 +137,25 @@ func (h *HelmHandler) Install(c *gin.Context) {
}
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 {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
if !justTemplate {
c.Header("Content-Type", "application/json")
} else {
manifests, err := h.Data.RevisionManifests(qp.Namespace, qp.Name, 0, false)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
if justTemplate {
manifests := ""
if isInitial {
manifests, err = h.Data.RevisionManifests(qp.Namespace, qp.Name, 0, false)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
out = subproc.GetDiff(strings.TrimSpace(manifests), out, "current.yaml", "upgraded.yaml")
} else {
c.Header("Content-Type", "application/json")
}
c.String(http.StatusAccepted, out)
@@ -168,6 +187,39 @@ func (h *HelmHandler) RepoValues(c *gin.Context) {
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) {
sections := map[string]subproc.SectionFn{
"manifests": data.RevisionManifests,

View File

@@ -26,7 +26,8 @@ func (h *ScannersHandler) ScanDraftManifest(c *gin.Context) {
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 {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return

View File

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

View File

@@ -48,18 +48,11 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<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>
<!-- TODO
<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>
-->
<!-- TODO
<li class="nav-item">
<a class="nav-link disabled">Provisional Charts</a>
</li>
-->
</ul>
<div>
<a class="btn" href="https://komodor.com/?utm_campaign=Helm-Dash&utm_source=helm-dash"><img
@@ -75,7 +68,48 @@
</nav>
<!-- /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">
<!-- FILTER BLOCK -->
<div class="p-2 ps-2 bg-white rounded-1 b-shadow" id="filters">
@@ -117,7 +151,7 @@
<!-- /INSTALLED LIST -->
</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">
<h3 class="fw-bold small">Revisions</h3>
<ul class="list-unstyled">
@@ -270,21 +304,48 @@
</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-content">
<div class="modal-header">
<h4 class="modal-title" id="upgradeModalLabel">
Upgrade <b class='text-success name'></b>
Install <b class='text-success name'></b>
</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form class="modal-body border-bottom fs-5" enctype="multipart/form-data">
<div class="input-group mb-3 text-muted">
<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='text-success ver-old ms-1'>0.0.0</span>)
class='fw-bold text-success ver-new'></select></label> <span class="ver-old">(current version is <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 class="row">
<div class="col-6 pe-3">
@@ -296,7 +357,8 @@
</div>
<div class="row">
<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 class="col-6 ps-3">
<pre class="ref-vals fs-6 w-100 bg-secondary p-2 rounded" style="max-height: 20rem"></pre>
@@ -313,7 +375,7 @@
</form>
<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-primary btn-confirm">Confirm Upgrade</button>
<button type="button" class="btn btn-primary btn-confirm">Confirm</button>
</div>
</div>
</div>
@@ -332,6 +394,7 @@
integrity="sha512-CSBhVREyzHAjAFfBlIBakjoRUKp5h7VSweP0InR/pAJyptH7peuhCsqAI/snV+TwZmXZqoUklpXp6R6wMnYf5Q=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="static/repo.js"></script>
<script src="static/list-view.js"></script>
<script src="static/revisions-view.js"></script>
<script src="static/details-view.js"></script>

View File

@@ -15,7 +15,6 @@ function loadChartsList() {
})
}
function buildChartCard(elm) {
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>

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")
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 chart = getHashParam("chart")
if (!chart) {
@@ -18,27 +39,27 @@ $(function () {
} else {
loadChartHistory(namespace, chart)
}
})
}
}
$.getJSON("/api/scanners").fail(function (xhr) {
reportError("Failed to get list of scanners", xhr)
}).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>`)
$("#topNav ul a").click(function () {
const self = $(this)
$("#nav-scanners form span").prepend(item)
}
$("#topNav ul a").removeClass("active")
if (!data.length) {
$("#upgradeModal .btn-scan").hide()
}
})
const ctx = getHashParam("context")
setHashParam(null, null)
setHashParam("context", ctx)
if (self.hasClass("section-repo")) {
setHashParam("section", "repository")
} else {
setHashParam("section", null)
}
initView()
})
const myAlert = document.getElementById('errorAlert')
myAlert.addEventListener('close.bs.alert', event => {
event.preventDefault()
@@ -60,8 +81,14 @@ function getHashParam(name) {
}
function setHashParam(name, val) {
const params = new URLSearchParams(window.location.hash.substring(1))
params.set(name, val)
let params = new URLSearchParams(window.location.hash.substring(1))
if (!name) {
params = new URLSearchParams()
} else if (!val) {
params.delete(name)
} else {
params.set(name, val)
}
window.location.hash = new URLSearchParams(params).toString()
}
@@ -86,14 +113,14 @@ function statusStyle(status, card, txt) {
}
function getCleanClusterName(rawClusterName) {
if (rawClusterName.indexOf('arn') == 0) {
if (rawClusterName.indexOf('arn') === 0) {
// AWS cluster
clusterSplit = rawClusterName.split(':')
clusterName = clusterSplit.at(-1).split("/").at(-1)
region = clusterSplit.at(-3)
const clusterSplit = rawClusterName.split(':')
const clusterName = clusterSplit.at(-1).split("/").at(-1)
const region = clusterSplit.at(-3)
return region + "/" + clusterName + ' [AWS]'
}
if (rawClusterName.indexOf('gke') == 0) {
if (rawClusterName.indexOf('gke') === 0) {
// GKE cluster
return rawClusterName.split('_').at(-2) + '/' + rawClusterName.split('_').at(-1) + ' [GKE]'
}
@@ -102,13 +129,11 @@ function getCleanClusterName(rawClusterName) {
function fillClusterList(data, 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 label = getCleanClusterName(elm.Name)
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("span").text(getCleanClusterName(label))
opt.find("span").text(label)
if (elm.IsCurrent && !context) {
opt.find("input").prop("checked", true)
setCurrentContext(elm.Name)

View File

@@ -1,3 +1,7 @@
.link, .nav-link {
cursor: pointer;
}
.strike {
text-decoration: line-through;
}
@@ -325,7 +329,7 @@ span.link {
}
#nav-resources .bg-secondary {
background-color: #E6E7EB!important;
background-color: #E6E7EB !important;
}
.res-actions .btn-sm {
@@ -349,4 +353,16 @@ span.link {
#describeModalBody pre {
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) {
// TODO: filter by namespace
out, err := d.runCommandHelm("ls", "--all", "--all-namespaces", "--output", "json", "--time-format", time.RFC3339)
if err != nil {
return nil, err
@@ -159,8 +160,13 @@ func (d *DataLayer) ChartHistory(namespace string, chartName string) (res []*His
return res, nil
}
func (d *DataLayer) ChartRepoVersions(chartName string) (res []RepoChartElement, err error) {
cmd := []string{"search", "repo", "--regexp", "/" + chartName + "\v", "--versions", "--output", "json"}
func (d *DataLayer) ChartRepoVersions(chartName string) (res []*RepoChartElement, err error) {
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...)
if err != nil {
return nil, err
@@ -173,6 +179,46 @@ func (d *DataLayer) ChartRepoVersions(chartName string) (res []RepoChartElement,
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?
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
}
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)
if err != nil {
return err
@@ -335,8 +381,8 @@ func (d *DataLayer) ChartRepoUpdate(name string) error {
return nil
}
func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string, version string, justTemplate bool, values string) (string, error) {
if values == "" {
func (d *DataLayer) ChartInstall(namespace string, name string, repoChart string, version string, justTemplate bool, values string, reuseVals bool) (string, error) {
if values == "" && reuseVals {
oldVals, err := d.RevisionValues(namespace, name, 0, true)
if err != nil {
return "", err
@@ -344,13 +390,13 @@ func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string
values = oldVals
}
oldValsFile, close1, err := utils.TempFile(values)
valsFile, close1, err := utils.TempFile(values)
defer close1()
if err != nil {
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 {
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)
}
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) {
if revision1 == 0 || revision2 == 0 {
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
type ReleaseElement struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
@@ -32,4 +33,12 @@ type RepoChartElement struct {
Version string `json:"version"`
AppVersion string `json:"app_version"`
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"`
}