pkg/debug: implement control panel
Control Panel allows to control different chores and services. Currently this adds controlling of cycles. Change-Id: I734f1676b2a0d883b8f5ba937e93c45ac1a9ce21
This commit is contained in:
parent
f237d70098
commit
a2b2bc676b
193
pkg/debug/panel.go
Normal file
193
pkg/debug/panel.go
Normal file
@ -0,0 +1,193 @@
|
||||
// Copyright (C) 2020 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"unicode"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"storj.io/common/sync2"
|
||||
)
|
||||
|
||||
// Panel implements a serving of customized callbacks.
|
||||
type Panel struct {
|
||||
log *zap.Logger
|
||||
|
||||
url string
|
||||
|
||||
mu sync.RWMutex
|
||||
lookup map[string]*Button
|
||||
categories []*ButtonGroup
|
||||
}
|
||||
|
||||
// NewPanel creates a new panel.
|
||||
func NewPanel(log *zap.Logger, url string) *Panel {
|
||||
return &Panel{
|
||||
log: log,
|
||||
url: url,
|
||||
lookup: map[string]*Button{},
|
||||
}
|
||||
}
|
||||
|
||||
// ButtonGroup contains description of a collection of buttons.
|
||||
type ButtonGroup struct {
|
||||
Slug string
|
||||
Name string
|
||||
Buttons []*Button
|
||||
}
|
||||
|
||||
// Button defines a clickable button.
|
||||
type Button struct {
|
||||
Slug string
|
||||
Name string
|
||||
Call func(progress io.Writer) error
|
||||
}
|
||||
|
||||
// Add adds a button group to the panel.
|
||||
func (panel *Panel) Add(cat *ButtonGroup) {
|
||||
panel.mu.Lock()
|
||||
defer panel.mu.Unlock()
|
||||
|
||||
if cat.Slug == "" {
|
||||
cat.Slug = slugify(cat.Name)
|
||||
}
|
||||
for _, but := range cat.Buttons {
|
||||
but.Slug = slugify(but.Name)
|
||||
|
||||
panel.lookup["/"+path.Join(cat.Slug, but.Slug)] = but
|
||||
}
|
||||
|
||||
panel.categories = append(panel.categories, cat)
|
||||
|
||||
sort.Slice(panel.categories, func(i, k int) bool {
|
||||
return panel.categories[i].Name < panel.categories[k].Name
|
||||
})
|
||||
}
|
||||
|
||||
// ServeHTTP serves buttons on the prefix and on
|
||||
// other endpoints calls the specified call.
|
||||
func (panel *Panel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
panel.mu.RLock()
|
||||
defer panel.mu.RUnlock()
|
||||
|
||||
url := strings.TrimPrefix(r.URL.Path, panel.url)
|
||||
if len(url) >= len(r.URL.Path) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if url == "/" {
|
||||
err := buttonsTemplate.Execute(w, map[string]interface{}{
|
||||
"URL": panel.url,
|
||||
"Categories": panel.categories,
|
||||
})
|
||||
if err != nil {
|
||||
panel.log.Error("buttons template failed", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
button, ok := panel.lookup[url]
|
||||
if !ok {
|
||||
http.Error(w, "control not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
panel.log.Debug("calling", zap.String("url", url))
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Header().Add("Cache-Control", "no-cache")
|
||||
w.Header().Add("Cache-Control", "max-age=0")
|
||||
|
||||
err := button.Call(w)
|
||||
if err != nil {
|
||||
panel.log.Error("failed to run button", zap.String("url", url), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
var buttonsTemplate = template.Must(template.New("").Parse(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Control Panel</title>
|
||||
<style>
|
||||
.button {
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid #ccc;
|
||||
margin: 0.1px;
|
||||
}
|
||||
.button:hover {
|
||||
background: #eee;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Control Panel</h1>
|
||||
{{ $url := .URL }}
|
||||
{{ range $cat := .Categories }}
|
||||
<div class="category">
|
||||
<h2>{{$cat.Name}}</h2>
|
||||
{{ range $but := $cat.Buttons }}
|
||||
<a class="button" href="{{$url}}/{{$cat.Slug}}/{{$but.Slug}}">{{.Name}}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// slugify converts text to a slug
|
||||
func slugify(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case 'a' <= r && r <= 'z':
|
||||
return r
|
||||
case 'A' <= r && r <= 'Z':
|
||||
return unicode.ToLower(r)
|
||||
case '0' <= r && r <= '9':
|
||||
return r
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}, s)
|
||||
}
|
||||
|
||||
// Cycle returns button group for a cycle
|
||||
func Cycle(name string, cycle *sync2.Cycle) *ButtonGroup {
|
||||
return &ButtonGroup{
|
||||
Name: name,
|
||||
Buttons: []*Button{
|
||||
{
|
||||
Name: "Trigger",
|
||||
Call: func(w io.Writer) error {
|
||||
_, _ = fmt.Fprintln(w, "Triggering")
|
||||
cycle.TriggerWait()
|
||||
_, _ = fmt.Fprintln(w, "Done")
|
||||
return nil
|
||||
},
|
||||
}, {
|
||||
Name: "Pause",
|
||||
Call: func(w io.Writer) error {
|
||||
cycle.Pause()
|
||||
_, _ = fmt.Fprintln(w, "Paused")
|
||||
return nil
|
||||
},
|
||||
}, {
|
||||
Name: "Resume",
|
||||
Call: func(w io.Writer) error {
|
||||
cycle.Restart()
|
||||
_, _ = fmt.Fprintln(w, "Resumed")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
@ -31,6 +31,8 @@ func init() {
|
||||
// Config defines configuration for debug server.
|
||||
type Config struct {
|
||||
Address string `internal:"true"`
|
||||
|
||||
Control bool `help:"expose control panel" releaseDefault:"false" devDefault:"true"`
|
||||
}
|
||||
|
||||
// Server provides endpoints for debugging.
|
||||
@ -41,10 +43,12 @@ type Server struct {
|
||||
server http.Server
|
||||
mux http.ServeMux
|
||||
|
||||
Panel *Panel
|
||||
|
||||
registry *monkit.Registry
|
||||
}
|
||||
|
||||
// NewServer returns a new debug.Server
|
||||
// NewServer returns a new debug.Server.
|
||||
func NewServer(log *zap.Logger, listener net.Listener, registry *monkit.Registry, config Config) *Server {
|
||||
server := &Server{log: log}
|
||||
|
||||
@ -52,6 +56,13 @@ func NewServer(log *zap.Logger, listener net.Listener, registry *monkit.Registry
|
||||
server.server.Handler = &server.mux
|
||||
server.registry = registry
|
||||
|
||||
server.Panel = NewPanel(log.Named("control"), "/control")
|
||||
if config.Control {
|
||||
server.mux.Handle("/control/", server.Panel)
|
||||
}
|
||||
|
||||
server.mux.Handle("/version/", http.StripPrefix("/version", checker.NewDebugHandler(log.Named("version"))))
|
||||
|
||||
server.mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
server.mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
server.mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
@ -60,9 +71,9 @@ func NewServer(log *zap.Logger, listener net.Listener, registry *monkit.Registry
|
||||
|
||||
server.mux.HandleFunc("/debug/run/trace/db", server.collectTraces)
|
||||
|
||||
server.mux.Handle("/version/", http.StripPrefix("/version", checker.NewDebugHandler(log.Named("version"))))
|
||||
server.mux.Handle("/mon/", http.StripPrefix("/mon", present.HTTP(server.registry)))
|
||||
server.mux.HandleFunc("/metrics", server.metrics)
|
||||
|
||||
server.mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintln(w, "OK")
|
||||
})
|
||||
@ -89,13 +100,13 @@ func (server *Server) Run(ctx context.Context) error {
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// Close closes server and underlying listener
|
||||
// Close closes server and underlying listener.
|
||||
func (server *Server) Close() error {
|
||||
return Error.Wrap(server.server.Close())
|
||||
}
|
||||
|
||||
// metrics writes https://prometheus.io/docs/instrumenting/exposition_formats/
|
||||
func (server *Server) metrics(w http.ResponseWriter, r *http.Request) {
|
||||
// writes https://prometheus.io/docs/instrumenting/exposition_formats/
|
||||
// TODO(jt): deeper monkit integration so we can expose prometheus types
|
||||
// (https://prometheus.io/docs/concepts/metric_types/)
|
||||
server.registry.Stats(func(name string, val float64) {
|
||||
@ -105,7 +116,7 @@ func (server *Server) metrics(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// collectTraces
|
||||
// collectTraces collects traces until request is canceled.
|
||||
func (server *Server) collectTraces(w http.ResponseWriter, r *http.Request) {
|
||||
cancel := traces.CollectTraces()
|
||||
defer cancel()
|
||||
@ -118,6 +129,7 @@ func (server *Server) collectTraces(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitize formats val to be suitable for prometheus.
|
||||
func sanitize(val string) string {
|
||||
// https://prometheus.io/docs/concepts/data_model/
|
||||
// specifies all metric names must match [a-zA-Z_:][a-zA-Z0-9_:]*
|
||||
|
3
scripts/testdata/satellite-config.yaml.lock
vendored
3
scripts/testdata/satellite-config.yaml.lock
vendored
@ -100,6 +100,9 @@ contact.external-address: ""
|
||||
# address to listen on for debug endpoints
|
||||
# debug.addr: 127.0.0.1:0
|
||||
|
||||
# expose control panel
|
||||
# debug.control: false
|
||||
|
||||
# If set, a path to write a process trace SVG to
|
||||
# debug.trace-out: ""
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user