Repo version info and upgrade (#10)

* Repo block shows

* repository update

* API progress

* Fix problem of stopping after uninstall

* Upgrade preview backend

* Upgrade preview displays fine

* Install flow works

* Weird check for updates

* Update action

* Fix action

* Still trying

* Fix lint error

* Refactor out helm handlers

* refactor out kube handlers

* save

* Change icon collection

* Reworked upgrade check
This commit is contained in:
Andrey Pokhilko
2022-09-14 13:20:10 +01:00
committed by GitHub
parent 47929785e7
commit 269895ae31
8 changed files with 616 additions and 268 deletions

View File

@@ -34,4 +34,10 @@ jobs:
- name: Test Binary is Runnable
run: "dist/helm-dashboard_linux_amd64_v1/helm-dashboard --help"
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v3.2.0
with:
# version: latest
# skip-go-installation: true
skip-pkg-cache: true
skip-build-cache: true
# args: --timeout=15m

View File

@@ -5,8 +5,6 @@ import (
"errors"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"k8s.io/apimachinery/pkg/apis/meta/v1"
v12 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"net/http"
"os"
"path"
@@ -44,12 +42,14 @@ func NewRouter(abortWeb ControlChan, data *DataLayer) *gin.Engine {
api = gin.Default()
}
api.Use(noCache)
api.Use(contextSetter(data))
api.Use(errorHandler)
configureStatic(api)
configureStatic(api)
configureRoutes(abortWeb, data, api)
api.Use(noCache)
api.Use(errorHandler)
return api
}
@@ -59,191 +59,29 @@ func configureRoutes(abortWeb ControlChan, data *DataLayer, api *gin.Engine) {
abortWeb <- struct{}{}
})
configureHelms(api, data)
configureKubectls(api, data)
configureHelms(api.Group("/api/helm"), data)
configureKubectls(api.Group("/api/kube"), data)
}
func configureHelms(api *gin.Engine, data *DataLayer) {
api.GET("/api/helm/charts", func(c *gin.Context) {
res, err := data.ListInstalled()
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, res)
})
api.DELETE("/api/helm/charts", func(c *gin.Context) {
qp, err := getQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
err = data.UninstallChart(qp.Namespace, qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusFound, "/")
})
api.POST("/api/helm/charts/rollback", func(c *gin.Context) {
qp, err := getQueryProps(c, true)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
err = data.Revert(qp.Namespace, qp.Name, qp.Revision)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusFound, "/")
})
api.GET("/api/helm/charts/history", func(c *gin.Context) {
qp, err := getQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
res, err := data.ChartHistory(qp.Namespace, qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, res)
})
api.GET("/api/helm/charts/resources", func(c *gin.Context) {
qp, err := getQueryProps(c, true)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
res, err := data.RevisionManifestsParsed(qp.Namespace, qp.Name, qp.Revision)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, res)
})
api.GET("/api/helm/charts/:section", func(c *gin.Context) {
qp, err := getQueryProps(c, true)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
flag := c.Query("flag") == "true"
rDiff := c.Query("revisionDiff")
res, err := handleGetSection(data, c.Param("section"), rDiff, qp, flag)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.String(http.StatusOK, res)
})
func configureHelms(api *gin.RouterGroup, data *DataLayer) {
h := 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("/repo/search", h.RepoSearch)
api.POST("/repo/update", h.RepoUpdate)
api.GET("/charts/install", h.InstallPreview)
api.POST("/charts/install", h.Install)
api.GET("/charts/:section", h.GetInfoSection)
}
func handleGetSection(data *DataLayer, section string, rDiff string, qp *QueryProps, flag bool) (string, error) {
sections := map[string]SectionFn{
"manifests": data.RevisionManifests,
"values": data.RevisionValues,
"notes": data.RevisionNotes,
}
functor, found := sections[section]
if !found {
return "", errors.New("unsupported section: " + section)
}
if rDiff != "" {
cRevDiff, err := strconv.Atoi(rDiff)
if err != nil {
return "", err
}
ext := ".yaml"
if section == "notes" {
ext = ".txt"
}
res, err := RevisionDiff(functor, ext, qp.Namespace, qp.Name, cRevDiff, qp.Revision, flag)
if err != nil {
return "", err
}
return res, nil
} else {
res, err := functor(qp.Namespace, qp.Name, qp.Revision, flag)
if err != nil {
return "", err
}
return res, nil
}
}
func configureKubectls(api *gin.Engine, data *DataLayer) {
api.GET("/api/kube/contexts", func(c *gin.Context) {
res, err := data.ListContexts()
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, res)
})
api.GET("/api/kube/resources/:kind", func(c *gin.Context) {
qp, err := getQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
res, err := data.GetResource(qp.Namespace, &GenericResource{
TypeMeta: v1.TypeMeta{Kind: c.Param("kind")},
ObjectMeta: v1.ObjectMeta{Name: qp.Name},
})
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
if res.Status.Phase == "Active" || res.Status.Phase == "Error" {
_ = res.Name + ""
} else if res.Status.Phase == "" && len(res.Status.Conditions) > 0 {
res.Status.Phase = v12.CarpPhase(res.Status.Conditions[len(res.Status.Conditions)-1].Type)
res.Status.Message = res.Status.Conditions[len(res.Status.Conditions)-1].Message
res.Status.Reason = res.Status.Conditions[len(res.Status.Conditions)-1].Reason
if res.Status.Conditions[len(res.Status.Conditions)-1].Status == "False" {
res.Status.Phase = "Not" + res.Status.Phase
}
} else if res.Status.Phase == "" {
res.Status.Phase = "Exists"
}
c.IndentedJSON(http.StatusOK, res)
})
api.GET("/api/kube/describe/:kind", func(c *gin.Context) {
qp, err := getQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
res, err := data.DescribeResource(qp.Namespace, c.Param("kind"), qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.String(http.StatusOK, res)
})
func configureKubectls(api *gin.RouterGroup, data *DataLayer) {
h := KubeHandler{Data: data}
api.GET("/contexts", h.GetContexts)
api.GET("/resources/:kind", h.GetResourceInfo)
api.GET("/describe/:kind", h.Describe)
}
func configureStatic(api *gin.Engine) {

View File

@@ -11,6 +11,7 @@ import (
"github.com/hexops/gotextdiff/span"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"io/ioutil"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"os"
"os/exec"
@@ -214,7 +215,8 @@ func (d *DataLayer) ChartHistory(namespace string, chartName string) (res []*his
}
func (d *DataLayer) ChartRepoVersions(chartName string) (res []repoChartElement, err error) {
out, err := d.runCommandHelm("search", "repo", "--regexp", "/"+chartName+"\v", "--versions", "--output", "json")
cmd := []string{"search", "repo", "--regexp", "/" + chartName + "\v", "--versions", "--output", "json"}
out, err := d.runCommandHelm(cmd...)
if err != nil {
return nil, err
}
@@ -229,7 +231,12 @@ func (d *DataLayer) ChartRepoVersions(chartName string) (res []repoChartElement,
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) {
out, err := d.runCommandHelm("get", "manifest", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision))
cmd := []string{"get", "manifest", chartName, "--namespace", namespace}
if revision > 0 {
cmd = append(cmd, "--revision", strconv.Itoa(revision))
}
out, err := d.runCommandHelm(cmd...)
if err != nil {
return "", err
}
@@ -275,7 +282,12 @@ func (d *DataLayer) RevisionNotes(namespace string, chartName string, revision i
}
func (d *DataLayer) RevisionValues(namespace string, chartName string, revision int, onlyUserDefined bool) (res string, err error) {
cmd := []string{"get", "values", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision), "--output", "yaml"}
cmd := []string{"get", "values", chartName, "--namespace", namespace, "--output", "yaml"}
if revision > 0 {
cmd = append(cmd, "--revision", strconv.Itoa(revision))
}
if !onlyUserDefined {
cmd = append(cmd, "--all")
}
@@ -341,6 +353,61 @@ func (d *DataLayer) Revert(namespace string, name string, rev int) error {
return nil
}
func (d *DataLayer) ChartRepoUpdate(name string) error {
cmd := []string{"repo", "update"}
if name != "" {
cmd = append(cmd, name)
}
_, err := d.runCommandHelm(cmd...)
if err != nil {
return err
}
return nil
}
func (d *DataLayer) ChartUpgrade(namespace string, name string, repoChart string, version string, justTemplate bool) (string, error) {
oldVals, err := d.RevisionValues(namespace, name, 0, false)
if err != nil {
return "", err
}
file, err := ioutil.TempFile("", "helm_vals_")
if err != nil {
return "", err
}
defer os.Remove(file.Name())
err = ioutil.WriteFile(file.Name(), []byte(oldVals), 0600)
if err != nil {
return "", err
}
cmd := []string{name, repoChart, "--version", version, "--namespace", namespace, "--values", file.Name()}
if justTemplate {
cmd = append([]string{"template"}, cmd...)
} else {
cmd = append([]string{"upgrade"}, cmd...)
cmd = append(cmd, "--output", "json")
}
out, err := d.runCommandHelm(cmd...)
if err != nil {
return "", err
}
if justTemplate {
manifests, err := d.RevisionManifests(namespace, name, 0, false)
if err != nil {
return "", err
}
out = getDiff(strings.TrimSpace(manifests), strings.TrimSpace(out), "current.yaml", "upgraded.yaml")
}
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)
@@ -357,11 +424,16 @@ func RevisionDiff(functor SectionFn, ext string, namespace string, name string,
return "", err
}
edits := myers.ComputeEdits(span.URIFromPath(""), manifest1, manifest2)
unified := gotextdiff.ToUnified(strconv.Itoa(revision1)+ext, strconv.Itoa(revision2)+ext, manifest1, edits)
diff := fmt.Sprint(unified)
log.Debugf("The diff is: %s", diff)
diff := getDiff(manifest1, manifest2, strconv.Itoa(revision1)+ext, strconv.Itoa(revision2)+ext)
return diff, nil
}
func getDiff(text1 string, text2 string, name1 string, name2 string) string {
edits := myers.ComputeEdits(span.URIFromPath(""), text1, text2)
unified := gotextdiff.ToUnified(name1, name2, text1, edits)
diff := fmt.Sprint(unified)
log.Debugf("The diff is: %s", diff)
return diff
}
type GenericResource = v1.Carp

View File

@@ -0,0 +1,205 @@
package dashboard
import (
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
"helm.sh/helm/v3/pkg/release"
"net/http"
"strconv"
)
type HelmHandler struct {
Data *DataLayer
}
func (h *HelmHandler) GetCharts(c *gin.Context) {
res, err := h.Data.ListInstalled()
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, res)
}
func (h *HelmHandler) Uninstall(c *gin.Context) {
qp, err := getQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
err = h.Data.UninstallChart(qp.Namespace, qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Status(http.StatusAccepted)
}
func (h *HelmHandler) Rollback(c *gin.Context) {
qp, err := getQueryProps(c, true)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
err = h.Data.Revert(qp.Namespace, qp.Name, qp.Revision)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Redirect(http.StatusOK, "/")
}
func (h *HelmHandler) History(c *gin.Context) {
qp, err := getQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
res, err := h.Data.ChartHistory(qp.Namespace, qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, res)
}
func (h *HelmHandler) Resources(c *gin.Context) {
qp, err := getQueryProps(c, true)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
res, err := h.Data.RevisionManifestsParsed(qp.Namespace, qp.Name, qp.Revision)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, res)
}
func (h *HelmHandler) RepoSearch(c *gin.Context) {
qp, err := getQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
res, err := h.Data.ChartRepoVersions(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 := getQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
err = h.Data.ChartRepoUpdate(qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HelmHandler) InstallPreview(c *gin.Context) {
out, err := chartInstall(c, h.Data, true)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.String(http.StatusOK, out)
}
func (h *HelmHandler) Install(c *gin.Context) {
out, err := chartInstall(c, h.Data, false)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
res := release.Release{}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusAccepted, res)
}
func (h *HelmHandler) GetInfoSection(c *gin.Context) {
qp, err := getQueryProps(c, true)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
flag := c.Query("flag") == "true"
rDiff := c.Query("revisionDiff")
res, err := handleGetSection(h.Data, c.Param("section"), rDiff, qp, flag)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.String(http.StatusOK, res)
}
func chartInstall(c *gin.Context, data *DataLayer, justTemplate bool) (string, error) {
qp, err := getQueryProps(c, false)
if err != nil {
return "", err
}
out, err := data.ChartUpgrade(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), justTemplate)
if err != nil {
return "", err
}
return out, nil
}
func handleGetSection(data *DataLayer, section string, rDiff string, qp *QueryProps, flag bool) (string, error) {
sections := map[string]SectionFn{
"manifests": data.RevisionManifests,
"values": data.RevisionValues,
"notes": data.RevisionNotes,
}
functor, found := sections[section]
if !found {
return "", errors.New("unsupported section: " + section)
}
if rDiff != "" {
cRevDiff, err := strconv.Atoi(rDiff)
if err != nil {
return "", err
}
ext := ".yaml"
if section == "notes" {
ext = ".txt"
}
res, err := RevisionDiff(functor, ext, qp.Namespace, qp.Name, cRevDiff, qp.Revision, flag)
if err != nil {
return "", err
}
return res, nil
} else {
res, err := functor(qp.Namespace, qp.Name, qp.Revision, flag)
if err != nil {
return "", err
}
return res, nil
}
}

View File

@@ -0,0 +1,69 @@
package dashboard
import (
"github.com/gin-gonic/gin"
"k8s.io/apimachinery/pkg/apis/meta/v1"
v12 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"net/http"
)
type KubeHandler struct {
Data *DataLayer
}
func (h *KubeHandler) GetContexts(c *gin.Context) {
res, err := h.Data.ListContexts()
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, res)
}
func (h *KubeHandler) GetResourceInfo(c *gin.Context) {
qp, err := getQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
res, err := h.Data.GetResource(qp.Namespace, &GenericResource{
TypeMeta: v1.TypeMeta{Kind: c.Param("kind")},
ObjectMeta: v1.ObjectMeta{Name: qp.Name},
})
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
if res.Status.Phase == "Active" || res.Status.Phase == "Error" {
_ = res.Name + ""
} else if res.Status.Phase == "" && len(res.Status.Conditions) > 0 {
res.Status.Phase = v12.CarpPhase(res.Status.Conditions[len(res.Status.Conditions)-1].Type)
res.Status.Message = res.Status.Conditions[len(res.Status.Conditions)-1].Message
res.Status.Reason = res.Status.Conditions[len(res.Status.Conditions)-1].Reason
if res.Status.Conditions[len(res.Status.Conditions)-1].Status == "False" {
res.Status.Phase = "Not" + res.Status.Phase
}
} else if res.Status.Phase == "" {
res.Status.Phase = "Exists"
}
c.IndentedJSON(http.StatusOK, res)
}
func (h *KubeHandler) Describe(c *gin.Context) {
qp, err := getQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
res, err := h.Data.DescribeResource(qp.Namespace, c.Param("kind"), qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.String(http.StatusOK, res)
}

View File

@@ -7,8 +7,7 @@
<title>Helm Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"/>
<!-- CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/lightfair.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css"/>
<link href="static/styles.css" rel="stylesheet">
@@ -16,8 +15,7 @@
<body>
<div class="container">
<i class="fa-solid fa-arrow-trend-down"></i>
<nav class="navbar navbar-expand-lg bg-light rounded" style="margin-bottom: 0.75rem">
<nav class="navbar navbar-expand-lg bg-light rounded mb-2 mt-2">
<div class="container-fluid">
<div style="line-height: 90%; font-size: 1.5rem" class="navbar-brand">
<img src="static/logo.png" style="height: 3rem; float: left" alt="Logo">
@@ -47,21 +45,22 @@
<label for="cluster" style="margin-top: 0.5rem">K8s Context:</label>
<select id="cluster" class="form-control"></select>
</form>
<i class="btn fa fa-power-off text-muted" title="Shut down the Helm Dashboard application"></i>
<i class="btn bi-power text-muted" title="Shut down the Helm Dashboard application"></i>
</div>
</div>
</nav>
<div class="bg-light p-5 pt-0 rounded" id="sectionDetails" style="display: none">
<div class="bg-light p-5 pt-0 rounded display-none" id="sectionDetails">
<span class="text-muted"
style="transform: rotate(270deg); z-index: 100; display: inline-block; position: relative; left:-4rem; top: 4rem; color: #BBB!important; text-transform: uppercase">Revisions</span>
<div class="row mb-3">
</div>
<h1><span class="name"></span>, revision <span class="rev"></span>
<span class="float-end">
<a id="btnRollback" class="btn btn-sm bg-primary border border-secondary text-light" title="Rollback to this revision"><i class="fa fa-backward"></i> <span>Rollback</span></a>
<a id="btnUninstall" class="btn btn-sm bg-danger border border-secondary text-light" title="Uninstall the chart"><i class="fa fa-trash"></i> Uninstall</a>
<span class="float-end" id="actionButtons">
<a id="btnUpgrade" class="opacity-10 btn btn-sm bg-secondary text-light bg-opacity-50 rounded-0 me-0 rounded-start ">Checking...</a><a id="btnUpgradeCheck" class="btn btn-sm text-muted btn-light border-secondary rounded-0 rounded-end ms-0" title="Check for newer chart version from repo"><i class="bi-repeat"></i><span class="spinner-border spinner-border-sm" style="display: none" role="status" aria-hidden="true"></span></a>
<a id="btnRollback" class="btn btn-sm bg-primary border border-secondary text-light" title="Rollback to this revision"><i class="bi-rewind-fill"></i> <span>Rollback</span></a>
<a id="btnUninstall" class="btn btn-sm bg-danger border border-secondary text-light" title="Uninstall the chart"><i class="bi-trash-fill"></i> Uninstall</a>
</span>
</h1>
Chart <b id="chartName"></b>: <i id="revDescr"></i>
@@ -119,7 +118,7 @@
</div>
</div>
<div class="bg-light p-5 rounded" id="sectionList" style="display: none">
<div class="bg-light p-5 rounded display-none" id="sectionList">
<h1>Charts List</h1>
<div id="charts" class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
@@ -160,6 +159,33 @@
</div>
</div>
<div class="modal" id="upgradeModal"
tabindex="-1" aria-labelledby="describeModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog modal-dialog-scrollable modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="upgradeModalLabel">
Upgrade <b class='text-success name'></b> from version <b class='text-success ver-old'></b> to <select class='fw-bold text-success ver-new'></select>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="upgradeModalBody">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success">Confirm Upgrade</button>
</div>
</div>
</div>
</div>
<footer class="text-center small mt-3">
Brought to you by <img src="https://komodor.com/wp-content/uploads/2021/05/favicon.png" style="height: 1rem"> <a href="https://komodor.io">Komodor.io</a> |
<i class="bi-github"></i>
<a href="https://github.com/komodorio/helm-dashboard">Project page on GitHub</a>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js"
integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
crossorigin="anonymous"></script>

View File

@@ -100,7 +100,7 @@ function loadContent(mode, namespace, name, revision, revDiff, flag) {
let url = "/api/helm/charts/" + mode
url += "?" + qstr
const diffDisplay = $("#manifestText");
diffDisplay.empty().append("<i class='fa fa-spinner fa-spin fa-2x'></i>")
diffDisplay.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$.get(url).fail(function () {
reportError("Failed to get diff of " + mode)
}).done(function (data) {
@@ -134,62 +134,68 @@ $('#specRev').keyup(function (event) {
event.preventDefault()
});
function loadChartHistory(namespace, name) {
$("#sectionDetails").show()
$("#sectionDetails h1 span.name").text(name)
revRow.empty().append("<div><i class='fa fa-spinner fa-spin fa-2x'></i></div>")
$.getJSON("/api/helm/charts/history?name=" + name + "&namespace=" + namespace).fail(function () {
reportError("Failed to get chart details")
}).done(function (data) {
revRow.empty()
for (let x = 0; x < data.length; x++) {
const elm = data[x]
$("#specRev").val(elm.revision).data("last-rev", elm.revision)
const rev = $(`<div class="col-md-2 p-2 rounded border border-secondary bg-gradient bg-white">
function fillChartHistory(data, namespace, name) {
revRow.empty()
for (let x = 0; x < data.length; x++) {
const elm = data[x]
$("#specRev").val(elm.revision).data("last-rev", elm.revision).data("last-chart-ver", elm.chart_ver)
const rev = $(`<div class="col-md-2 p-2 rounded border border-secondary bg-gradient bg-white">
<span><b class="rev-number"></b> - <span class="rev-status"></span></span><br/>
<span class="text-muted">Chart:</span> <span class="chart-ver"></span><br/>
<span class="text-muted">App ver:</span> <span class="app-ver"></span><br/>
<p class="small mt-3 mb-0"><span class="text-muted">Age:</span> <span class="rev-age"></span><br/>
<span class="text-muted rev-date"></span><br/></p>
</div>`)
rev.find(".rev-number").text("#" + elm.revision)
rev.find(".app-ver").text(elm.app_version)
rev.find(".chart-ver").text(elm.chart_ver)
rev.find(".rev-date").text(elm.updated.replace("T", " "))
rev.find(".rev-age").text(getAge(elm, data[x + 1]))
rev.find(".rev-status").text(elm.status)
rev.find(".fa").attr("title", elm.action)
rev.find(".rev-number").text("#" + elm.revision)
rev.find(".app-ver").text(elm.app_version)
rev.find(".chart-ver").text(elm.chart_ver)
rev.find(".rev-date").text(elm.updated.replace("T", " "))
rev.find(".rev-age").text(getAge(elm, data[x + 1]))
rev.find(".rev-status").text(elm.status)
rev.find(".fa").attr("title", elm.action)
if (elm.status === "failed") {
rev.find(".rev-status").parent().addClass("text-danger")
}
switch (elm.action) {
case "app_upgrade":
rev.find(".app-ver").append(" <i class='fa fa-angle-double-up text-success'></i>")
break
case "app_downgrade":
rev.find(".app-ver").append(" <i class='fa fa-angle-double-down text-danger'></i>")
break
case "chart_upgrade":
rev.find(".chart-ver").append(" <i class='fa fa-angle-up text-success'></i>")
break
case "chart_downgrade":
rev.find(".chart-ver").append(" <i class='fa fa-angle-down text-danger'></i>")
break
case "reconfigure": // ?
break
}
rev.data("elm", elm)
rev.addClass("rev-" + elm.revision)
rev.click(function () {
revisionClicked(namespace, name, $(this))
})
revRow.append(rev)
if (elm.status === "failed") {
rev.find(".rev-status").parent().addClass("text-danger")
}
switch (elm.action) {
case "app_upgrade":
rev.find(".app-ver").append(" <i class='bi-chevron-double-up text-success'></i>")
break
case "app_downgrade":
rev.find(".app-ver").append(" <i class='bi-chevron-double-down text-danger'></i>")
break
case "chart_upgrade":
rev.find(".chart-ver").append(" <i class='bi-chevron-up text-success'></i>")
break
case "chart_downgrade":
rev.find(".chart-ver").append(" <i class='bi-chevron-down text-danger'></i>")
break
case "reconfigure": // ?
break
}
rev.data("elm", elm)
rev.addClass("rev-" + elm.revision)
rev.click(function () {
revisionClicked(namespace, name, $(this))
})
revRow.append(rev)
}
}
function loadChartHistory(namespace, name) {
$("#sectionDetails").show()
$("#sectionDetails h1 span.name").text(name)
revRow.empty().append("<div><span class=\"spinner-border spinner-border-sm\" role=\"status\" aria-hidden=\"true\"></span></div>")
$.getJSON("/api/helm/charts/history?name=" + name + "&namespace=" + namespace).fail(function () {
reportError("Failed to get chart details")
}).done(function (data) {
fillChartHistory(data, namespace, name);
checkUpgradeable(data[data.length - 1].chart_name)
const rev = getHashParam("revision")
if (rev) {
revRow.find(".rev-" + rev).click()
@@ -199,6 +205,116 @@ function loadChartHistory(namespace, name) {
})
}
$("#btnUpgradeCheck").click(function () {
const self = $(this)
self.find(".bi-repeat").hide()
self.find(".spinner-border").show()
const repoName = self.data("repo")
$.post("/api/helm/repo/update?name=" + repoName).fail(function () {
reportError("Failed to update chart repo")
}).done(function () {
self.find(".spinner-border").hide()
self.find(".bi-repeat").show()
checkUpgradeable(self.data("chart"))
$("#btnUpgradeCheck").prop("disabled", true).find(".fa").removeClass("fa-spin fa-spinner").addClass("fa-times")
})
})
function checkUpgradeable(name) {
$("#btnUpgrade").text("Checking...")
$.getJSON("/api/helm/repo/search?name=" + name).fail(function () {
reportError("Failed to find chart in repo")
}).done(function (data) {
if (!data) {
return
}
$('#upgradeModalLabel select').empty()
for (let i = 0; i < data.length; i++) {
$('#upgradeModalLabel select').append("<option value='" + data[i].version + "'>" + data[i].version + "</option>")
}
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").removeClass("bg-secondary bg-opacity-50").addClass("bg-success").text("Upgrade to "+elm.version)
} else {
$("#btnUpgrade").removeClass("bg-success").addClass("bg-secondary bg-opacity-50").text("No upgrades")
}
$("#btnUpgrade").off("click").click(function () {
popUpUpgrade($(this), verCur, elm)
})
})
}
$('#upgradeModalLabel select').change(function () {
const self = $(this)
$("#upgradeModalBody").empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$("#upgradeModal .btn-success").prop("disabled", true)
$.get(self.data("url") + "&version=" + self.val()).fail(function () {
reportError("Failed to get upgrade")
}).done(function (data) {
$("#upgradeModalBody").empty();
$("#upgradeModal .btn-success").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("<p>Following changes will happen to cluster:</p>")
if (!data) {
$("#upgradeModalBody").html("No changes will happen to cluster")
}
})
})
$("#upgradeModal .btn-secondary").click(function () {
const self=$(this)
self.find(".fa").removeClass("fa-cloud-download").addClass("fa-spin fa-spinner").prop("disabled", true)
$("#btnUpgradeCheck").click()
$("#upgradeModal .btn-close").click()
})
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)
self.prop("disabled", true)
$("#upgradeModalLabel .name").text(name)
$("#upgradeModalLabel .ver-old").text(verCur)
$('#upgradeModalLabel select').val(elm.version).trigger("change")
const myModal = new bootstrap.Modal(document.getElementById('upgradeModal'), {});
myModal.show()
$("#upgradeModal .btn-success").prop("disabled", true).off('click').click(function () {
$("#upgradeModal .btn-success").prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$.ajax({
url: url + "&version=" + $('#upgradeModalLabel select').val(),
type: 'POST',
}).fail(function () {
reportError("Failed to upgrade the chart")
}).done(function (data) {
setHashParam("revision", data.version)
window.location.reload()
})
})
}
function getHashParam(name) {
const params = new URLSearchParams(window.location.hash.substring(1))
return params.get(name)
@@ -240,14 +356,15 @@ function buildChartCard(elm) {
let chart = self.data("chart");
setHashParam("namespace", chart.namespace)
setHashParam("chart", chart.name)
loadChartHistory(chart.namespace, chart.name)
loadChartHistory(chart.namespace, chart.name, elm.chart_name)
})
return card;
}
function loadChartsList() {
$("#sectionList").show()
chartsCards.empty().append("<div><i class='fa fa-spinner fa-spin fa-2x'></i> Loading...</div>")
chartsCards.empty().append("<div><span class=\"spinner-border spinner-border-sm\" role=\"status\" aria-hidden=\"true\"></span> Loading...</div>")
$.getJSON("/api/helm/charts").fail(function () {
reportError("Failed to get list of charts")
}).done(function (data) {
@@ -261,7 +378,6 @@ function loadChartsList() {
$(function () {
// cluster list
clusterSelect.change(function () {
Cookies.set("context", clusterSelect.val())
window.location.href = "/"
@@ -322,7 +438,7 @@ function getAge(obj1, obj2) {
}
function showResources(namespace, chart, revision) {
$("#nav-resources").empty().append("<i class='fa fa-spin fa-spinner fa-2x'></i>");
$("#nav-resources").empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>');
let qstr = "name=" + chart + "&namespace=" + namespace + "&revision=" + revision
let url = "/api/helm/charts/resources"
url += "?" + qstr
@@ -336,7 +452,7 @@ function showResources(namespace, chart, revision) {
<div class="input-group row">
<span class="input-group-text col-sm-2"><em class="text-muted small">` + res.kind + `</em></span>
<span class="input-group-text col-sm-6">` + res.metadata.name + `</span>
<span class="form-control col-sm-4"><i class="fa fa-spinner fa-spin"></i> <span class="text-muted small">Getting status...</span></span>
<span class="form-control col-sm-4"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span class="text-muted small">Getting status...</span></span>
</div>`)
$("#nav-resources").append(resBlock)
let ns = res.metadata.namespace ? res.metadata.namespace : namespace
@@ -358,8 +474,8 @@ function showResources(namespace, chart, revision) {
statusBlock.empty().append(badge).append("<span class='text-muted small'>" + (data.status.message ? data.status.message : '') + "</span>")
if (badge.text() !== "NotFound") {
statusBlock.prepend("<i class=\"btn fa fa-search-plus float-end text-muted\"></i>")
statusBlock.find(".fa-search-plus").click(function () {
statusBlock.prepend("<i class=\"btn bi-zoom-in float-end text-muted\"></i>")
statusBlock.find(".bi-zoom-in").click(function () {
showDescribe(ns, res.kind, res.metadata.name)
})
}
@@ -368,8 +484,8 @@ function showResources(namespace, chart, revision) {
})
}
$(".fa-power-off").click(function () {
$(".fa-power-off").attr("disabled", "disabled").removeClass(".fa-power-off").addClass("fa-spin fa-spinner")
$(".bi-power").click(function () {
$(".bi-power").attr("disabled", "disabled").removeClass(".bi-power").append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$.ajax({
url: "/",
type: 'DELETE',
@@ -380,7 +496,7 @@ $(".fa-power-off").click(function () {
function showDescribe(ns, kind, name) {
$("#describeModalLabel").text("Describe " + kind + ": " + ns + " / " + name)
$("#describeModalBody").empty().append("<i class='fa fa-spin fa-spinner fa-2x'></i>")
$("#describeModalBody").empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
const myModal = new bootstrap.Modal(document.getElementById('describeModal'), {});
myModal.show()
@@ -397,9 +513,9 @@ $("#btnUninstall").click(function () {
const namespace = getHashParam('namespace');
const revision = $("#specRev").data("last-rev")
$("#confirmModalLabel").html("Uninstall <b class='text-danger'>" + chart + "</b> from namespace <b class='text-danger'>" + namespace + "</b>")
$("#confirmModalBody").empty().append("<i class='fa fa-spin fa-spinner fa-2x'></i>")
$("#confirmModalBody").empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$("#confirmModal .btn-primary").prop("disabled", true).off('click').click(function () {
$("#confirmModal .btn-primary").prop("disabled", true).append("<i class='fa fa-spin fa-spinner'></i>")
$("#confirmModal .btn-primary").prop("disabled", true).append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
const url = "/api/helm/charts?namespace=" + namespace + "&name=" + chart;
$.ajax({
url: url,
@@ -435,9 +551,9 @@ $("#btnRollback").click(function () {
const revisionNew = $("#btnRollback").data("rev")
const revisionCur = $("#specRev").data("last-rev")
$("#confirmModalLabel").html("Rollback <b class='text-danger'>" + chart + "</b> from revision " + revisionCur + " to " + revisionNew)
$("#confirmModalBody").empty().append("<i class='fa fa-spin fa-spinner fa-2x'></i>")
$("#confirmModalBody").empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$("#confirmModal .btn-primary").prop("disabled", true).off('click').click(function () {
$("#confirmModal .btn-primary").prop("disabled", true).append("<i class='fa fa-spin fa-spinner'></i>")
$("#confirmModal .btn-primary").prop("disabled", true).append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
const url = "/api/helm/charts/rollback?namespace=" + namespace + "&name=" + chart + "&revision=" + revisionNew;
$.ajax({
url: url,
@@ -470,4 +586,16 @@ $("#btnRollback").click(function () {
diff2htmlUi.draw()
$("#confirmModalBody").prepend("<p>Following changes will happen to cluster:</p>")
})
})
})
function isNewerVersion(oldVer, newVer) {
const oldParts = oldVer.split('.')
const newParts = newVer.split('.')
for (let i = 0; i < newParts.length; i++) {
const a = ~~newParts[i] // parse int
const b = ~~oldParts[i] // parse int
if (a > b) return true
if (a < b) return false
}
return false
}

View File

@@ -8,4 +8,8 @@
.d2h-file-collapse, .d2h-tag {
opacity: 0; /* trollface */
}
.display-none {
display: none;
}