mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-24 11:48:04 +00:00
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:
@@ -13,7 +13,14 @@ import (
|
||||
var staticFS embed.FS
|
||||
|
||||
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)
|
||||
|
||||
// local dev speed-up
|
||||
@@ -47,8 +54,22 @@ func newRouter(abortWeb ControlChan, data DataLayer) *gin.Engine {
|
||||
abortWeb <- struct{}{}
|
||||
})
|
||||
|
||||
api.GET("/api", func(c *gin.Context) {
|
||||
c.IndentedJSON(http.StatusOK, data.ListInstalled())
|
||||
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
|
||||
|
||||
@@ -1,16 +1,125 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"helm.sh/helm/v3/pkg/release"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DataLayer struct {
|
||||
}
|
||||
|
||||
func (l *DataLayer) CheckConnectivity() {
|
||||
// TODO: check that we can work with context and subcommands
|
||||
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) ListInstalled() []*release.Release {
|
||||
return nil // TODO
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,14 +6,21 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func StartServer() (string, ControlChan) {
|
||||
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")
|
||||
if address == "" {
|
||||
address = "localhost"
|
||||
}
|
||||
|
||||
if os.Getenv("HD_PORT") == "" {
|
||||
address += ":8080" // TODO: better default port to clash less?
|
||||
} else {
|
||||
@@ -24,9 +31,6 @@ func StartServer() (string, ControlChan) {
|
||||
api := newRouter(abort, data)
|
||||
done := startBackgroundServer(address, api, abort)
|
||||
|
||||
if strings.HasPrefix(address, ":") {
|
||||
address = "localhost" + address
|
||||
}
|
||||
return "http://" + address, done
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="icon" href="https://komodor.com/wp-content/uploads/2021/05/favicon-50x50.png"/>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Helm Dashboard</title>
|
||||
@@ -10,10 +11,48 @@
|
||||
</head>
|
||||
<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"
|
||||
integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="static/scripts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#charts .card {
|
||||
cursor: pointer;
|
||||
}
|
||||
Reference in New Issue
Block a user