diff --git a/pkg/debug/panel.go b/pkg/debug/panel.go
new file mode 100644
index 000000000..24de8f39c
--- /dev/null
+++ b/pkg/debug/panel.go
@@ -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(`
+
+
+Control Panel
+
+
+
+ Control Panel
+ {{ $url := .URL }}
+ {{ range $cat := .Categories }}
+
+
{{$cat.Name}}
+ {{ range $but := $cat.Buttons }}
+
{{.Name}}
+ {{ end }}
+
+ {{ end }}
+
+`))
+
+// 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
+ },
+ },
+ },
+ }
+}
diff --git a/pkg/debug/server.go b/pkg/debug/server.go
index fd69fb56b..52aeb681b 100644
--- a/pkg/debug/server.go
+++ b/pkg/debug/server.go
@@ -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_:]*
diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock
index 0576b32fe..d79c9a869 100644
--- a/scripts/testdata/satellite-config.yaml.lock
+++ b/scripts/testdata/satellite-config.yaml.lock
@@ -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: ""