mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-24 03:38: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:
3
go.mod
3
go.mod
@@ -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
5
go.sum
@@ -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=
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func (l *DataLayer) CheckConnectivity() error {
|
||||||
return nil // TODO
|
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"
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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