Initial features pt 2 (#3)

* Less logging when not in DEBUG

* Check helm is fine

* Display kube context switch

* Cosmetics

* Displays list of chartss

* Linter stuff

* Fix option name
This commit is contained in:
Andrey Pokhilko
2022-08-24 14:42:20 +03:00
committed by GitHub
parent 925cfa77dd
commit d9a88feb7b
8 changed files with 249 additions and 19 deletions

3
go.mod
View File

@@ -6,16 +6,15 @@ require (
github.com/gin-gonic/gin v1.8.1 github.com/gin-gonic/gin v1.8.1
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.3
) )
require ( require (
github.com/Masterminds/semver/v3 v3.1.1 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/locales v0.14.0 // indirect
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

5
go.sum
View File

@@ -1,5 +1,3 @@
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=
@@ -21,6 +19,7 @@ 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/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=
@@ -101,5 +100,3 @@ 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.3 h1:etd4Qc45/bnIkBofZIRwrAzYuG3bNWR1EdMN4fsfzoE=
helm.sh/helm/v3 v3.9.3/go.mod h1:3eaWAIqzvlRSD06gR9MMwmp2KBKwlu9av1/1BZpjeWY=

View File

@@ -13,7 +13,14 @@ import (
var staticFS embed.FS var staticFS embed.FS
func newRouter(abortWeb ControlChan, data DataLayer) *gin.Engine { func newRouter(abortWeb ControlChan, data DataLayer) *gin.Engine {
api := gin.Default() var api *gin.Engine
if os.Getenv("DEBUG") == "" {
api = gin.New()
api.Use(gin.Recovery())
} else {
api = gin.Default()
}
fs := http.FS(staticFS) fs := http.FS(staticFS)
// local dev speed-up // local dev speed-up
@@ -47,8 +54,22 @@ func newRouter(abortWeb ControlChan, data DataLayer) *gin.Engine {
abortWeb <- struct{}{} abortWeb <- struct{}{}
}) })
api.GET("/api", func(c *gin.Context) { api.GET("/api/helm/charts", func(c *gin.Context) {
c.IndentedJSON(http.StatusOK, data.ListInstalled()) 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 return api

View File

@@ -1,16 +1,125 @@
package dashboard package dashboard
import ( import (
"helm.sh/helm/v3/pkg/release" "bytes"
"encoding/json"
"errors"
"fmt"
log "github.com/sirupsen/logrus"
"os/exec"
"regexp"
"strings"
) )
type DataLayer struct { type DataLayer struct {
} }
func (l *DataLayer) CheckConnectivity() { func (l *DataLayer) runCommand(cmd ...string) (string, error) {
// TODO: check that we can work with context and subcommands // 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
} }
func (l *DataLayer) ListInstalled() []*release.Release { sout := stdout.Bytes()
return nil // TODO 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
} }

View File

@@ -6,14 +6,21 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"net/http" "net/http"
"os" "os"
"strings"
) )
func StartServer() (string, ControlChan) { func StartServer() (string, ControlChan) {
data := DataLayer{} data := DataLayer{}
data.CheckConnectivity() err := data.CheckConnectivity()
if err != nil {
log.Errorf("Failed to check that Helm is operational, cannot continue. The error was: %s", err)
os.Exit(1) // TODO: propagate error instead?
}
address := os.Getenv("HD_BIND") address := os.Getenv("HD_BIND")
if address == "" {
address = "localhost"
}
if os.Getenv("HD_PORT") == "" { if os.Getenv("HD_PORT") == "" {
address += ":8080" // TODO: better default port to clash less? address += ":8080" // TODO: better default port to clash less?
} else { } else {
@@ -24,9 +31,6 @@ func StartServer() (string, ControlChan) {
api := newRouter(abort, data) api := newRouter(abort, data)
done := startBackgroundServer(address, api, abort) done := startBackgroundServer(address, api, abort)
if strings.HasPrefix(address, ":") {
address = "localhost" + address
}
return "http://" + address, done return "http://" + address, done
} }

View File

@@ -1,6 +1,7 @@
<!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"/>
<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>
@@ -10,10 +11,48 @@
</head> </head>
<body> <body>
<div class="container">
<nav class="navbar navbar-expand-lg bg-light rounded" aria-label="Eleventh navbar example">
<div class="container-fluid">
<div style="line-height: 90%">
<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>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarsExample09"
aria-controls="navbarsExample09" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarsExample09">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">Charts List</a>
</li>
<li class="nav-item">
<a class="nav-link disabled">Repositories</a>
</li>
</ul>
<form class="d-flex flex-nowrap text-nowrap">
<label for="cluster" class="">K8s Context:</label>
<select id="cluster" class="form-control"></select>
</form>
</div>
</div>
</nav>
<div class="bg-light p-5 rounded">
<h1>Charts List</h1>
<div id="charts" class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
</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="static/scripts.js"></script> <script src="static/scripts.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,58 @@
const clusterSelect = $("#cluster");
const chartsCards = $("#charts");
function reportError(err) {
alert(err) // TODO: nice modal/baloon/etc
}
$(function () {
// cluster list
$.getJSON("/api/kube/contexts").fail(function () {
reportError("Failed to get list of clusters")
}).done(function (data) {
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 opt = $("<option></option>").val(elm.Name).text(label)
if (elm.IsCurrent) {
opt.attr("selected", "selected")
}
clusterSelect.append(opt)
})
})
clusterSelect.change(function () {
// TODO: remember it, respect it in the function above and in all other places
})
// charts list
$.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 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))
})
})
})

View File

@@ -0,0 +1,3 @@
#charts .card {
cursor: pointer;
}