mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-24 03:38:04 +00:00
Chart details draft (#4)
* Add logo * Refactor out structs * Data layer context-awareness * Mod * Data layer improvements * Progress * Progress * Progress * Figured the time format shorter * Statuses colors * Sticky URL * Calculate some diffs inside * Separate checks * Scrap gofmt * Skip custom test in GH * Shows some colorful diff
This commit is contained in:
15
.github/workflows/build.yml
vendored
15
.github/workflows/build.yml
vendored
@@ -16,17 +16,16 @@ jobs:
|
|||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: 1.18
|
go-version: 1.18
|
||||||
- name: golangci-lint
|
- name: Unit tests
|
||||||
uses: golangci/golangci-lint-action@v3
|
|
||||||
- name: Series of tests
|
|
||||||
run: |
|
run: |
|
||||||
GO_FILES=$(find . -iname '*.go' -type f) # All the .go files, excluding vendor/
|
|
||||||
test -z $(gofmt -s -l $GO_FILES) # Fail if a .go file hasn't been formatted with gofmt
|
|
||||||
go test -v -race ./... # Run all the tests with the race detector enabled
|
go test -v -race ./... # Run all the tests with the race detector enabled
|
||||||
|
- name: Static analysis
|
||||||
|
run: |
|
||||||
go vet ./... # go vet is the official Go static analyzer
|
go vet ./... # go vet is the official Go static analyzer
|
||||||
|
- name: Cyclomatic complexity
|
||||||
|
run: |
|
||||||
go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
|
go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
|
||||||
/home/runner/go/bin/gocyclo -over 19 cmd pkg # forbid code with huge/complex functions
|
/home/runner/go/bin/gocyclo -over 19 main.go pkg # forbid code with huge/complex functions
|
||||||
go build main.go
|
|
||||||
- name: Dry Run GoReleaser
|
- name: Dry Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v2
|
uses: goreleaser/goreleaser-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -34,3 +33,5 @@ jobs:
|
|||||||
args: release --snapshot --rm-dist
|
args: release --snapshot --rm-dist
|
||||||
- name: Test Binary is Runnable
|
- name: Test Binary is Runnable
|
||||||
run: "dist/helm-dashboard_linux_amd64_v1/helm-dashboard --help"
|
run: "dist/helm-dashboard_linux_amd64_v1/helm-dashboard --help"
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v3
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,4 +22,5 @@
|
|||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
/bin
|
/bin
|
||||||
|
/.idea/
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ To uninstall, run:
|
|||||||
helm plugin uninstall dashboard
|
helm plugin uninstall dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Support Channels
|
||||||
|
|
||||||
|
We have two main channels for supporting the tool users: [Slack community](#TODO) for general conversations and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real bugs.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -59,3 +62,6 @@ Validate manifests before deploy and get better errors
|
|||||||
Switch clusters (?)
|
Switch clusters (?)
|
||||||
Browsing repositories
|
Browsing repositories
|
||||||
Adding new repository
|
Adding new repository
|
||||||
|
|
||||||
|
Recognise & show ArgoCD-originating charts/objects
|
||||||
|
Have cleaner idea on the web API structure
|
||||||
4
go.mod
4
go.mod
@@ -3,9 +3,12 @@ module github.com/komodorio/helm-dashboard
|
|||||||
go 1.18
|
go 1.18
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Masterminds/semver/v3 v3.1.1
|
||||||
github.com/gin-gonic/gin v1.8.1
|
github.com/gin-gonic/gin v1.8.1
|
||||||
|
github.com/hexops/gotextdiff v1.0.3
|
||||||
github.com/sirupsen/logrus v1.8.1
|
github.com/sirupsen/logrus v1.8.1
|
||||||
github.com/toqueteos/webbrowser v1.2.0
|
github.com/toqueteos/webbrowser v1.2.0
|
||||||
|
helm.sh/helm/v3 v3.9.4
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -14,7 +17,6 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.11.0 // indirect
|
github.com/go-playground/validator/v10 v10.11.0 // indirect
|
||||||
github.com/goccy/go-json v0.9.11 // indirect
|
github.com/goccy/go-json v0.9.11 // indirect
|
||||||
github.com/google/go-cmp v0.5.6 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/leodido/go-urn v1.2.1 // indirect
|
github.com/leodido/go-urn v1.2.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||||
|
|||||||
7
go.sum
7
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
|
||||||
|
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -19,8 +21,9 @@ github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
|
|||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@@ -100,3 +103,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
helm.sh/helm/v3 v3.9.4 h1:TCI1QhJUeLVOdccfdw+vnSEO3Td6gNqibptB04QtExY=
|
||||||
|
helm.sh/helm/v3 v3.9.4/go.mod h1:3eaWAIqzvlRSD06gR9MMwmp2KBKwlu9av1/1BZpjeWY=
|
||||||
|
|||||||
@@ -2,17 +2,33 @@ package dashboard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"errors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed static/*
|
//go:embed static/*
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
func newRouter(abortWeb ControlChan, data DataLayer) *gin.Engine {
|
func errorHandler(c *gin.Context) {
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
errs := ""
|
||||||
|
for _, err := range c.Errors {
|
||||||
|
log.Debugf("Error: %s", err)
|
||||||
|
errs += err.Error() + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs != "" {
|
||||||
|
c.String(http.StatusInternalServerError, errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(abortWeb ControlChan, data DataLayer) *gin.Engine {
|
||||||
var api *gin.Engine
|
var api *gin.Engine
|
||||||
if os.Getenv("DEBUG") == "" {
|
if os.Getenv("DEBUG") == "" {
|
||||||
api = gin.New()
|
api = gin.New()
|
||||||
@@ -21,6 +37,84 @@ func newRouter(abortWeb ControlChan, data DataLayer) *gin.Engine {
|
|||||||
api = gin.Default()
|
api = gin.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
api.Use(errorHandler)
|
||||||
|
configureStatic(api)
|
||||||
|
|
||||||
|
configureRoutes(abortWeb, data, api)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureRoutes(abortWeb ControlChan, data DataLayer, api *gin.Engine) {
|
||||||
|
// server shutdown handler
|
||||||
|
api.DELETE("/", func(c *gin.Context) {
|
||||||
|
abortWeb <- struct{}{}
|
||||||
|
})
|
||||||
|
|
||||||
|
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.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/helm/charts/history", func(c *gin.Context) {
|
||||||
|
cName := c.Query("chart")
|
||||||
|
cNamespace := c.Query("namespace")
|
||||||
|
if cName == "" {
|
||||||
|
_ = c.AbortWithError(http.StatusBadRequest, errors.New("missing required query string parameter: chart"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := data.ChartHistory(cNamespace, cName)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.IndentedJSON(http.StatusOK, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.GET("/api/helm/charts/manifest/diff", func(c *gin.Context) {
|
||||||
|
cName := c.Query("chart")
|
||||||
|
cNamespace := c.Query("namespace")
|
||||||
|
if cName == "" {
|
||||||
|
_ = c.AbortWithError(http.StatusBadRequest, errors.New("missing required query string parameter: chart"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cRev1, err := strconv.Atoi(c.Query("revision1"))
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cRev2, err := strconv.Atoi(c.Query("revision2"))
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := data.RevisionManifestsDiff(cNamespace, cName, cRev1, cRev2)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.IndentedJSON(http.StatusOK, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureStatic(api *gin.Engine) {
|
||||||
fs := http.FS(staticFS)
|
fs := http.FS(staticFS)
|
||||||
|
|
||||||
// local dev speed-up
|
// local dev speed-up
|
||||||
@@ -48,29 +142,4 @@ func newRouter(abortWeb ControlChan, data DataLayer) *gin.Engine {
|
|||||||
c.FileFromFS(c.Request.URL.Path, fs)
|
c.FileFromFS(c.Request.URL.Path, fs)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// server shutdown handler
|
|
||||||
api.DELETE("/", func(c *gin.Context) {
|
|
||||||
abortWeb <- struct{}{}
|
|
||||||
})
|
|
||||||
|
|
||||||
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.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)
|
|
||||||
})
|
|
||||||
|
|
||||||
return api
|
|
||||||
}
|
}
|
||||||
|
|||||||
247
pkg/dashboard/data.go
Normal file
247
pkg/dashboard/data.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/Masterminds/semver/v3"
|
||||||
|
"github.com/hexops/gotextdiff"
|
||||||
|
"github.com/hexops/gotextdiff/myers"
|
||||||
|
"github.com/hexops/gotextdiff/span"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DataLayer struct {
|
||||||
|
KubeContext string
|
||||||
|
Helm string
|
||||||
|
Kubectl string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DataLayer) runCommand(cmd ...string) (string, error) {
|
||||||
|
// TODO: --kube-context=context-name to juggle clusters
|
||||||
|
log.Debugf("Starting command: %s", cmd)
|
||||||
|
prog := exec.Command(cmd[0], cmd[1:]...)
|
||||||
|
prog.Env = os.Environ()
|
||||||
|
prog.Env = append(prog.Env, "HELM_KUBECONTEXT="+l.KubeContext)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
prog.Stdout = &stdout
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
prog.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := prog.Run(); err != nil {
|
||||||
|
log.Warnf("Failed command: %s", cmd)
|
||||||
|
serr := stderr.Bytes()
|
||||||
|
if serr != nil {
|
||||||
|
log.Warnf("STDERR:\n%s", serr)
|
||||||
|
}
|
||||||
|
if eerr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return "", fmt.Errorf("failed to run command %s: %s", cmd, eerr)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sout := stdout.Bytes()
|
||||||
|
serr := stderr.Bytes()
|
||||||
|
log.Debugf("Command STDOUT:\n%s", sout)
|
||||||
|
log.Debugf("Command STDERR:\n%s", serr)
|
||||||
|
return string(sout), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DataLayer) runCommandHelm(cmd ...string) (string, error) {
|
||||||
|
if l.Helm == "" {
|
||||||
|
l.Helm = "helm"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = append([]string{l.Helm}, cmd...)
|
||||||
|
if l.KubeContext != "" {
|
||||||
|
cmd = append(cmd, "--kube-context", l.KubeContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.runCommand(cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DataLayer) runCommandKubectl(cmd ...string) (string, error) {
|
||||||
|
if l.Kubectl == "" {
|
||||||
|
l.Kubectl = "kubectl"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = append([]string{l.Kubectl}, cmd...)
|
||||||
|
|
||||||
|
if l.KubeContext != "" {
|
||||||
|
cmd = append(cmd, "--context", l.KubeContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.runCommand(cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DataLayer) CheckConnectivity() error {
|
||||||
|
contexts, err := l.ListContexts()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(contexts) < 1 {
|
||||||
|
return errors.New("did not find any kubectl contexts configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = l.runCommandHelm("env")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type KubeContext struct {
|
||||||
|
IsCurrent bool
|
||||||
|
Name string
|
||||||
|
Cluster string
|
||||||
|
AuthInfo string
|
||||||
|
Namespace string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DataLayer) ListContexts() (res []KubeContext, err error) {
|
||||||
|
out, err := l.runCommandKubectl("config", "get-contexts")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// kubectl has no JSON output for it, we'll have to do custom text parsing
|
||||||
|
lines := strings.Split(out, "\n")
|
||||||
|
|
||||||
|
// find field positions
|
||||||
|
fields := regexp.MustCompile(`(\w+\s+)`).FindAllString(lines[0], -1)
|
||||||
|
cur := len(fields[0])
|
||||||
|
name := cur + len(fields[1])
|
||||||
|
cluster := name + len(fields[2])
|
||||||
|
auth := cluster + len(fields[3])
|
||||||
|
|
||||||
|
// read items
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
res = append(res, KubeContext{
|
||||||
|
IsCurrent: strings.TrimSpace(line[0:cur]) == "*",
|
||||||
|
Name: strings.TrimSpace(line[cur:name]),
|
||||||
|
Cluster: strings.TrimSpace(line[name:cluster]),
|
||||||
|
AuthInfo: strings.TrimSpace(line[cluster:auth]),
|
||||||
|
Namespace: strings.TrimSpace(line[auth:]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DataLayer) ListInstalled() (res []releaseElement, err error) {
|
||||||
|
out, err := l.runCommandHelm("ls", "--all", "--all-namespaces", "--output", "json", "--time-format", time.RFC3339)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal([]byte(out), &res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DataLayer) ChartHistory(namespace string, chartName string) (res []*historyElement, err error) {
|
||||||
|
// TODO: there is `max` but there is no `offset`
|
||||||
|
out, err := l.runCommandHelm("history", chartName, "--namespace", namespace, "--max", "5", "--output", "json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal([]byte(out), &res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var aprev *semver.Version
|
||||||
|
var cprev *semver.Version
|
||||||
|
for _, elm := range res {
|
||||||
|
chartRepoName, curVer, err := chartAndVersion(elm.Chart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
elm.ChartName = chartRepoName
|
||||||
|
elm.ChartVer = curVer
|
||||||
|
elm.Action = ""
|
||||||
|
elm.Updated.Time = elm.Updated.Time.Round(time.Second)
|
||||||
|
|
||||||
|
cver, err1 := semver.NewVersion(elm.ChartVer)
|
||||||
|
aver, err2 := semver.NewVersion(elm.AppVersion)
|
||||||
|
if err1 == nil && err2 == nil {
|
||||||
|
if aprev != nil && cprev != nil {
|
||||||
|
switch {
|
||||||
|
case aprev.LessThan(aver):
|
||||||
|
elm.Action = "app_upgrade"
|
||||||
|
case aprev.GreaterThan(aver):
|
||||||
|
elm.Action = "app_downgrade"
|
||||||
|
case cprev.LessThan(cver):
|
||||||
|
elm.Action = "chart_upgrade"
|
||||||
|
case cprev.GreaterThan(cver):
|
||||||
|
elm.Action = "chart_downgrade"
|
||||||
|
default:
|
||||||
|
elm.Action = "reconfigure"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Debugf("Semver parsing errors: %s=%s, %s=%s", elm.ChartVer, err1, elm.AppVersion, err2)
|
||||||
|
}
|
||||||
|
|
||||||
|
aprev = aver
|
||||||
|
cprev = cver
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DataLayer) ChartRepoVersions(chartName string) (res []repoChartElement, err error) {
|
||||||
|
out, err := l.runCommandHelm("search", "repo", "--regexp", "/"+chartName+"\v", "--versions", "--output", "json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal([]byte(out), &res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DataLayer) RevisionManifests(namespace string, chartName string, revision int) (res string, err error) {
|
||||||
|
out, err := l.runCommandHelm("get", "manifest", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DataLayer) RevisionManifestsDiff(namespace string, name string, revision1 int, revision2 int) (string, error) {
|
||||||
|
manifest1, err := l.RevisionManifests(namespace, name, revision1)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest2, err := l.RevisionManifests(namespace, name, revision2)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
edits := myers.ComputeEdits(span.URIFromPath(""), manifest1, manifest2)
|
||||||
|
unified := gotextdiff.ToUnified("a.txt", "b.txt", manifest1, edits)
|
||||||
|
diff := fmt.Sprint(unified)
|
||||||
|
return diff, nil
|
||||||
|
}
|
||||||
69
pkg/dashboard/data_test.go
Normal file
69
pkg/dashboard/data_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"helm.sh/helm/v3/pkg/release"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFlow(t *testing.T) {
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
|
var _ release.Status
|
||||||
|
data := DataLayer{}
|
||||||
|
err := data.CheckConnectivity()
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "did not find any kubectl contexts configured" {
|
||||||
|
t.Skip()
|
||||||
|
} else {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxses, err := data.ListContexts()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ctx := range ctxses {
|
||||||
|
if ctx.IsCurrent {
|
||||||
|
data.KubeContext = ctx.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
installed, err := data.ListInstalled()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chart := installed[1]
|
||||||
|
history, err := data.ChartHistory(chart.Namespace, chart.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = history
|
||||||
|
|
||||||
|
chartRepoName, curVer, err := chartAndVersion(chart.Chart)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = curVer
|
||||||
|
|
||||||
|
upgrade, err := data.ChartRepoVersions(chartRepoName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = upgrade
|
||||||
|
|
||||||
|
manifests, err := data.RevisionManifests(chart.Namespace, chart.Name, history[len(history)-1].Revision)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = manifests
|
||||||
|
|
||||||
|
diff, err := data.RevisionManifestsDiff(chart.Namespace, chart.Name, history[len(history)-1].Revision, history[len(history)-2].Revision)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = diff
|
||||||
|
}
|
||||||
36
pkg/dashboard/helmTypes.go
Normal file
36
pkg/dashboard/helmTypes.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"helm.sh/helm/v3/pkg/release"
|
||||||
|
helmtime "helm.sh/helm/v3/pkg/time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// unpleasant copy from Helm sources, where they have it non-public
|
||||||
|
type releaseElement struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
Updated helmtime.Time `json:"updated"`
|
||||||
|
Status release.Status `json:"status"`
|
||||||
|
Chart string `json:"chart"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type historyElement struct {
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Updated helmtime.Time `json:"updated"`
|
||||||
|
Status release.Status `json:"status"`
|
||||||
|
Chart string `json:"chart"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ChartName string `json:"chart_name"`
|
||||||
|
ChartVer string `json:"chart_ver"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoChartElement struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package dashboard
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DataLayer struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *DataLayer) runCommand(cmd ...string) (string, error) {
|
|
||||||
// TODO: --kube-context=context-name to juggle clusters
|
|
||||||
log.Debugf("Starting command: %s", cmd)
|
|
||||||
prog := exec.Command(cmd[0], cmd[1:]...)
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
prog.Stdout = &stdout
|
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
prog.Stderr = &stderr
|
|
||||||
|
|
||||||
//prog.Stdout, prog.Stderr = os.Stdout, os.Stderr
|
|
||||||
if err := prog.Run(); err != nil {
|
|
||||||
if eerr, ok := err.(*exec.ExitError); ok {
|
|
||||||
return "", fmt.Errorf("failed to run command %s: %s", cmd, eerr)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
sout := stdout.Bytes()
|
|
||||||
serr := stderr.Bytes()
|
|
||||||
log.Debugf("Command STDOUT:\n%s", sout)
|
|
||||||
log.Debugf("Command STDERR:\n%s", serr)
|
|
||||||
return string(sout), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *DataLayer) CheckConnectivity() error {
|
|
||||||
contexts, err := l.ListContexts()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(contexts) < 1 {
|
|
||||||
return errors.New("did not find any kubectl contexts configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = l.runCommand("helm", "env")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type KubeContext struct {
|
|
||||||
IsCurrent bool
|
|
||||||
Name string
|
|
||||||
Cluster string
|
|
||||||
AuthInfo string
|
|
||||||
Namespace string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *DataLayer) ListContexts() (res []KubeContext, err error) {
|
|
||||||
out, err := l.runCommand("kubectl", "config", "get-contexts")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// kubectl has no JSON output for it, we'll have to do custom text parsing
|
|
||||||
lines := strings.Split(out, "\n")
|
|
||||||
|
|
||||||
// find field positions
|
|
||||||
fields := regexp.MustCompile(`(\w+\s+)`).FindAllString(lines[0], -1)
|
|
||||||
cur := len(fields[0])
|
|
||||||
name := cur + len(fields[1])
|
|
||||||
cluster := name + len(fields[2])
|
|
||||||
auth := cluster + len(fields[3])
|
|
||||||
|
|
||||||
// read items
|
|
||||||
for _, line := range lines[1:] {
|
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
res = append(res, KubeContext{
|
|
||||||
IsCurrent: strings.TrimSpace(line[0:cur]) == "*",
|
|
||||||
Name: strings.TrimSpace(line[cur:name]),
|
|
||||||
Cluster: strings.TrimSpace(line[name:cluster]),
|
|
||||||
AuthInfo: strings.TrimSpace(line[cluster:auth]),
|
|
||||||
Namespace: strings.TrimSpace(line[auth:]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// unpleasant copy from Helm sources, where they have it non-public
|
|
||||||
type releaseElement struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
Revision string `json:"revision"`
|
|
||||||
Updated string `json:"updated"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Chart string `json:"chart"`
|
|
||||||
AppVersion string `json:"app_version"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *DataLayer) ListInstalled() (res []releaseElement, err error) {
|
|
||||||
out, err := l.runCommand("helm", "ls", "--all", "--all-namespaces", "--output", "json")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.Unmarshal([]byte(out), &res)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ func StartServer() (string, ControlChan) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abort := make(ControlChan)
|
abort := make(ControlChan)
|
||||||
api := newRouter(abort, data)
|
api := NewRouter(abort, data)
|
||||||
done := startBackgroundServer(address, api, abort)
|
done := startBackgroundServer(address, api, abort)
|
||||||
|
|
||||||
return "http://" + address, done
|
return "http://" + address, done
|
||||||
|
|||||||
@@ -1,58 +1,125 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<link rel="icon" href="https://komodor.com/wp-content/uploads/2021/05/favicon-50x50.png"/>
|
<link rel="icon" href="static/logo.png"/>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Helm Dashboard</title>
|
<title>Helm Dashboard</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet"
|
<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">
|
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" type="text/css" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css" />
|
||||||
<link href="static/styles.css" rel="stylesheet">
|
<link href="static/styles.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<nav class="navbar navbar-expand-lg bg-light rounded" aria-label="Eleventh navbar example">
|
<i class="fa-solid fa-arrow-trend-down"></i>
|
||||||
|
<nav class="navbar navbar-expand-lg bg-light rounded" style="margin-bottom: 0.75rem">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div style="line-height: 90%">
|
<div style="line-height: 90%; font-size: 1.5rem" class="navbar-brand">
|
||||||
|
<img src="static/logo.png" style="height: 3rem; float: left" alt="Logo">
|
||||||
<a class="navbar-brand" href="#"><b>Helm Dashboard</b></a><br/>
|
<a class="navbar-brand" href="#"><b>Helm Dashboard</b></a><br/>
|
||||||
<span style="font-size: smaller">by <a href="https://komodor.io">komodor.io</a></span>
|
<span style="font-size: 0.8rem;">by <a href="https://komodor.io">komodor.io</a></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarsExample09"
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav"
|
||||||
aria-controls="navbarsExample09" aria-expanded="false" aria-label="Toggle navigation">
|
aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarsExample09">
|
<div class="collapse navbar-collapse" id="mainNav">
|
||||||
<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">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="/">Charts List</a>
|
<a class="nav-link active" aria-current="page" href="/">Installed Charts</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link disabled">Provisional Charts</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link disabled">Repositories</a>
|
<a class="nav-link disabled">Repositories</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<form class="d-flex flex-nowrap text-nowrap">
|
<form class="d-flex flex-nowrap text-nowrap">
|
||||||
<label for="cluster" class="">K8s Context:</label>
|
<label for="cluster" style="margin-top: 0.5rem">K8s Context:</label>
|
||||||
<select id="cluster" class="form-control"></select>
|
<select id="cluster" class="form-control"></select>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="bg-light p-5 pt-0 rounded" id="sectionDetails" style="display: none">
|
||||||
|
<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 class="bg-light p-5 rounded">
|
</div>
|
||||||
|
<h1><a href="/" class="text-reset" style="text-decoration: none">⇐</a> Chart Details: <span class="name">chosen-one</span>,
|
||||||
|
revision <span class="rev"></span></h1>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<div class="nav nav-tabs" id="nav-tab" role="tablist">
|
||||||
|
<button class="nav-link active" id="nav-manifest-diff-tab" data-bs-toggle="tab" data-bs-target="#nav-manifest-diff"
|
||||||
|
type="button" role="tab" aria-controls="nav-manifest-diff" aria-selected="true">Manifests
|
||||||
|
</button>
|
||||||
|
<button class="nav-link" id="nav-disabled-tab" data-bs-toggle="tab" data-bs-target="#nav-disabled"
|
||||||
|
type="button" role="tab" aria-controls="nav-disabled" aria-selected="false" disabled>Resources
|
||||||
|
</button>
|
||||||
|
<button class="nav-link" id="nav-disabled-tab" data-bs-toggle="tab" data-bs-target="#nav-disabled"
|
||||||
|
type="button" role="tab" aria-controls="nav-disabled" aria-selected="false" disabled>Parameterized Values
|
||||||
|
</button>
|
||||||
|
<button class="nav-link" id="nav-disabled-tab" data-bs-toggle="tab" data-bs-target="#nav-disabled"
|
||||||
|
type="button" role="tab" aria-controls="nav-disabled" aria-selected="false" disabled>Notes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="tab-content" id="nav-tabContent">
|
||||||
|
<div class="tab-pane fade show active" id="nav-manifest-diff" role="tabpanel" aria-labelledby="nav-manifest-diff-tab"
|
||||||
|
tabindex="0">
|
||||||
|
<nav class="navbar bg-light">
|
||||||
|
<form class="container-fluid">
|
||||||
|
<label class="form-check-label" for="diffModeNone">
|
||||||
|
<input class="form-check-input" type="radio" name="diffMode" id="diffModeNone" disabled>
|
||||||
|
View Manifests
|
||||||
|
</label>
|
||||||
|
<label class="form-check-label" for="diffModePrev">
|
||||||
|
<input class="form-check-input" type="radio" name="diffMode" id="diffModePrev">
|
||||||
|
Diff with previous
|
||||||
|
</label>
|
||||||
|
<label class="form-check-label" for="diffModeLatest">
|
||||||
|
<input class="form-check-input" type="radio" name="diffMode" id="diffModeLatest" disabled>
|
||||||
|
Diff with latest <span class="text-muted">(#5)</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check-label" for="diffModeRev">
|
||||||
|
<input class="form-check-input" type="radio" name="diffMode" id="diffModeRev" disabled>
|
||||||
|
Diff with specific revision: <input class="form-input" size="3" disabled>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="manifestText" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="nav-disabled" role="tabpanel" aria-labelledby="nav-disabled-tab"
|
||||||
|
tabindex="0">...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-light p-5 rounded" id="sectionList" style="display: none">
|
||||||
<h1>Charts List</h1>
|
<h1>Charts List</h1>
|
||||||
<div id="charts" class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
<div id="charts" class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
|
integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js"></script>
|
||||||
<script src="static/scripts.js"></script>
|
<script src="static/scripts.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
BIN
pkg/dashboard/static/logo.png
Normal file
BIN
pkg/dashboard/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -5,6 +5,126 @@ function reportError(err) {
|
|||||||
alert(err) // TODO: nice modal/baloon/etc
|
alert(err) // TODO: nice modal/baloon/etc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revisionClicked(namespace, name, self) {
|
||||||
|
const elm = self.data("elm")
|
||||||
|
const parts = window.location.hash.split("&")
|
||||||
|
parts[2] = elm.revision
|
||||||
|
window.location.hash = parts.join("&")
|
||||||
|
$("#sectionDetails h1 span.rev").text(elm.revision)
|
||||||
|
let qstr = "chart=" + name + "&namespace=" + namespace + "&revision1=" + (elm.revision - 1) + "&revision2=" + elm.revision
|
||||||
|
let url = "/api/helm/charts/manifest/diff?" + qstr;
|
||||||
|
$.getJSON(url).fail(function () {
|
||||||
|
reportError("Failed to get diff of manifests")
|
||||||
|
}).done(function (data) {
|
||||||
|
if (data === "") {
|
||||||
|
$("#manifestText").text("No differences to display")
|
||||||
|
} else {
|
||||||
|
const targetElement = document.getElementById('manifestText');
|
||||||
|
const configuration = {
|
||||||
|
inputFormat: 'diff',
|
||||||
|
outputFormat: 'side-by-side',
|
||||||
|
|
||||||
|
drawFileList: false,
|
||||||
|
showFiles: false,
|
||||||
|
//matching: 'lines',
|
||||||
|
};
|
||||||
|
const diff2htmlUi = new Diff2HtmlUI(targetElement, data, configuration);
|
||||||
|
diff2htmlUi.draw()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillChartDetails(namespace, name) {
|
||||||
|
$("#sectionDetails").show()
|
||||||
|
$("#sectionDetails h1 span.name").text(name)
|
||||||
|
$.getJSON("/api/helm/charts/history?chart=" + name + "&namespace=" + namespace).fail(function () {
|
||||||
|
reportError("Failed to get list of clusters")
|
||||||
|
}).done(function (data) {
|
||||||
|
let revRow = $("#sectionDetails .row");
|
||||||
|
for (let x = 0; x < data.length; x++) {
|
||||||
|
const elm = data[x]
|
||||||
|
const rev = $(`<div class="col-md-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:</span> <span class="app-ver"></span><br/>
|
||||||
|
<span class="text-muted small rev-date"></span><br/>
|
||||||
|
</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-status").text(elm.status).attr("title", elm.action)
|
||||||
|
|
||||||
|
if (elm.status === "failed") {
|
||||||
|
rev.find(".rev-status").parent().addClass("text-danger")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elm.status === "deployed") {
|
||||||
|
//rev.removeClass("bg-white").addClass("text-light bg-primary")
|
||||||
|
}
|
||||||
|
|
||||||
|
rev.data("elm", elm)
|
||||||
|
rev.addClass("rev-" + elm.revision)
|
||||||
|
rev.click(function () {
|
||||||
|
revisionClicked(namespace, name, $(this))
|
||||||
|
})
|
||||||
|
|
||||||
|
revRow.append(rev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = window.location.hash.substring(1).split("&")
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
revRow.find(".rev-" + parts[2]).click()
|
||||||
|
} else {
|
||||||
|
revRow.find("div.col-md-2:last-child").click()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChartsList() {
|
||||||
|
$("#sectionList").show()
|
||||||
|
$.getJSON("/api/helm/charts").fail(function () {
|
||||||
|
reportError("Failed to get list of clusters")
|
||||||
|
}).done(function (data) {
|
||||||
|
chartsCards.empty()
|
||||||
|
data.forEach(function (elm) {
|
||||||
|
const header = $("<div class='card-header'></div>")
|
||||||
|
header.append($('<div class="float-end"><h5 class="float-end text-muted text-end">#' + elm.revision + '</h5><br/><div class="badge">' + elm.status + "</div>"))
|
||||||
|
// TODO: for pending- and uninstalling, add the spinner
|
||||||
|
if (elm.status === "failed") {
|
||||||
|
header.find(".badge").addClass("bg-danger text-light")
|
||||||
|
} else if (elm.status === "deployed" || elm.status === "superseded") {
|
||||||
|
header.find(".badge").addClass("bg-info")
|
||||||
|
} else {
|
||||||
|
header.find(".badge").addClass("bg-light text-dark")
|
||||||
|
}
|
||||||
|
|
||||||
|
header.append($('<h5 class="card-title"></h5>').text(elm.name))
|
||||||
|
header.append($('<p class="card-text small text-muted"></p>').append("Chart: " + elm.chart))
|
||||||
|
|
||||||
|
const body = $("<div class='card-body'></div>")
|
||||||
|
body.append($('<p class="card-text"></p>').append("Namespace: " + elm.namespace))
|
||||||
|
body.append($('<p class="card-text"></p>').append("Version: " + elm.app_version))
|
||||||
|
body.append($('<p class="card-text"></p>').append("Updated: " + elm.updated))
|
||||||
|
|
||||||
|
let card = $("<div class='card'></div>").append(header).append(body);
|
||||||
|
|
||||||
|
card.data("chart", elm)
|
||||||
|
card.click(function () {
|
||||||
|
const self = $(this)
|
||||||
|
$("#sectionList").hide()
|
||||||
|
|
||||||
|
let chart = self.data("chart");
|
||||||
|
window.location.hash = chart.namespace + "&" + chart.name
|
||||||
|
fillChartDetails(chart.namespace, chart.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
chartsCards.append($("<div class='col'></div>").append(card))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
$(function () {
|
$(function () {
|
||||||
// cluster list
|
// cluster list
|
||||||
$.getJSON("/api/kube/contexts").fail(function () {
|
$.getJSON("/api/kube/contexts").fail(function () {
|
||||||
@@ -25,34 +145,10 @@ $(function () {
|
|||||||
// TODO: remember it, respect it in the function above and in all other places
|
// TODO: remember it, respect it in the function above and in all other places
|
||||||
})
|
})
|
||||||
|
|
||||||
// charts list
|
const parts = window.location.hash.substring(1).split("&")
|
||||||
$.getJSON("/api/helm/charts").fail(function () {
|
if (parts[0] === "") {
|
||||||
reportError("Failed to get list of clusters")
|
loadChartsList()
|
||||||
}).done(function (data) {
|
} else {
|
||||||
chartsCards.empty()
|
fillChartDetails(parts[0], parts[1])
|
||||||
data.forEach(function (elm) {
|
}
|
||||||
const header = $("<div class='card-header'></div>")
|
|
||||||
header.append($('<div class="float-end"><h5 class="float-end text-muted text-end">#' + elm.revision + '</h5><br/><div class="badge bg-info">' + elm.status + "</div>"))
|
|
||||||
header.append($('<h5 class="card-title"></h5>').text(elm.name))
|
|
||||||
header.append($('<p class="card-text small text-muted"></p>').append("Version: " + elm.app_version))
|
|
||||||
|
|
||||||
const body = $("<div class='card-body'></div>")
|
|
||||||
body.append($('<p class="card-text"></p>').append("Namespace: " + elm.namespace))
|
|
||||||
body.append($('<p class="card-text"></p>').append("Chart: " + elm.chart))
|
|
||||||
body.append($('<p class="card-text"></p>').append("Updated: " + elm.updated))
|
|
||||||
|
|
||||||
/*
|
|
||||||
"namespace": "default",
|
|
||||||
"revision": "4",
|
|
||||||
"updated": "2022-08-16 17:11:26.73393511 +0300 IDT",
|
|
||||||
"status": "deployed",
|
|
||||||
"chart": "k8s-watcher-0.17.1",
|
|
||||||
"app_version": "0.1.108"
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
let card = $("<div class='card'></div>").append(header).append(body);
|
|
||||||
chartsCards.append($("<div class='col'></div>").append(card))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
#charts .card {
|
#charts .card, #sectionDetails .row > div {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary .text-muted {
|
||||||
|
color: white!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d2h-file-header {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,17 @@
|
|||||||
package dashboard
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type ControlChan = chan struct{}
|
type ControlChan = chan struct{}
|
||||||
|
|
||||||
|
func chartAndVersion(x string) (string, string, error) {
|
||||||
|
lastInd := strings.LastIndex(x, "-")
|
||||||
|
if lastInd < 0 {
|
||||||
|
return "", "", errors.New("can't parse chart version string")
|
||||||
|
}
|
||||||
|
|
||||||
|
return x[:lastInd], x[lastInd+1:], nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user