Add status page
This commit is contained in:
parent
1b33388e2a
commit
3587b362db
4 changed files with 486 additions and 6 deletions
20
main.go
20
main.go
|
@ -36,6 +36,7 @@ import (
|
|||
|
||||
"gitea.drjosh.dev/josh/jrouter/aurp"
|
||||
"gitea.drjosh.dev/josh/jrouter/router"
|
||||
"gitea.drjosh.dev/josh/jrouter/status"
|
||||
|
||||
"github.com/google/gopacket/pcap"
|
||||
"github.com/sfiera/multitalk/pkg/ddp"
|
||||
|
@ -102,7 +103,13 @@ func main() {
|
|||
defer cancel()
|
||||
ctx, _ := signal.NotifyContext(cctx, os.Interrupt)
|
||||
|
||||
// Open PCAP session
|
||||
// --------------------------------- HTTP ---------------------------------
|
||||
http.HandleFunc("/status", status.Handle)
|
||||
go func() {
|
||||
log.Print(http.ListenAndServe(":9459", nil))
|
||||
}()
|
||||
|
||||
// --------------------------------- Pcap ---------------------------------
|
||||
// First check the interface
|
||||
iface, err := net.InterfaceByName(cfg.EtherTalk.Device)
|
||||
if err != nil {
|
||||
|
@ -129,6 +136,12 @@ func main() {
|
|||
}
|
||||
defer pcapHandle.Close()
|
||||
|
||||
// -------------------------------- Tables --------------------------------
|
||||
routes := router.NewRoutingTable()
|
||||
zones := router.NewZoneTable()
|
||||
zones.Upsert(cfg.EtherTalk.NetStart, cfg.EtherTalk.ZoneName, true)
|
||||
|
||||
// -------------------------------- Peers ---------------------------------
|
||||
// Wait until all peer handlers have finished before closing the port
|
||||
var handlersWG sync.WaitGroup
|
||||
defer func() {
|
||||
|
@ -144,11 +157,6 @@ func main() {
|
|||
}()
|
||||
}
|
||||
|
||||
// -------------------------------- Tables --------------------------------
|
||||
routes := router.NewRoutingTable()
|
||||
zones := router.NewZoneTable()
|
||||
zones.Upsert(cfg.EtherTalk.NetStart, cfg.EtherTalk.ZoneName, true)
|
||||
|
||||
// ------------------------- Configured peer setup ------------------------
|
||||
if cfg.PeerListURL != "" {
|
||||
log.Printf("Fetching peer list from %s...", cfg.PeerListURL)
|
||||
|
|
303
status/status.go
Normal file
303
status/status.go
Normal file
|
@ -0,0 +1,303 @@
|
|||
// Package status provides a status page handler, for exposing a summary of what
|
||||
// the various pieces of the agent are doing.
|
||||
//
|
||||
// Inspired heavily by Google "/statsuz" - one public example is at:
|
||||
// https://github.com/youtube/doorman/blob/master/go/status/status.go
|
||||
package status
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const errorTmplSrc = `<div class="error">❌ {{.Operation}}: <code>{{.Error}}</code><br>
|
||||
Raw item data:<br>
|
||||
<pre>{{.Item | printJSON}}</pre>
|
||||
</div>`
|
||||
|
||||
var (
|
||||
//go:embed status.html.tmpl
|
||||
statusTmplSrc string
|
||||
|
||||
// Errors ignored below, as the status page is "best effort".
|
||||
hostname, _ = os.Hostname()
|
||||
username = func() string {
|
||||
user, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("unknown (uid=unknown; error=%v)", err)
|
||||
}
|
||||
return fmt.Sprintf("%s (uid=%s)", user.Username, user.Uid)
|
||||
}()
|
||||
exepath, _ = os.Executable()
|
||||
startTime = time.Now()
|
||||
|
||||
rootItem = &simpleItem{
|
||||
baseItem: baseItem{
|
||||
items: make(map[string]item),
|
||||
},
|
||||
}
|
||||
|
||||
funcMap = template.FuncMap{
|
||||
"printJSON": printJSON,
|
||||
}
|
||||
|
||||
// The inbuilt templates should always parse. Rather than use template.Must,
|
||||
// successful parsing is enforced by the smoke tests.
|
||||
statusTmpl, _ = template.New("status").Funcs(funcMap).Parse(statusTmplSrc)
|
||||
errorTmpl, _ = template.New("item-error").Funcs(funcMap).Parse(errorTmplSrc)
|
||||
)
|
||||
|
||||
type statusData struct {
|
||||
Items map[string]item
|
||||
Version string
|
||||
Build string
|
||||
Hostname string
|
||||
Username string
|
||||
ExePath string
|
||||
PID int
|
||||
Compiler string
|
||||
RuntimeVer string
|
||||
GOOS string
|
||||
GOARCH string
|
||||
NumCPU int
|
||||
NumGoroutine int
|
||||
StartTime string
|
||||
StartTimeAgo time.Duration
|
||||
CurrentTime string
|
||||
Ctx context.Context // request context for Eval calls inside the template execution only
|
||||
}
|
||||
|
||||
type errorData struct {
|
||||
Operation string
|
||||
Error error
|
||||
Item any
|
||||
}
|
||||
|
||||
type item interface {
|
||||
addSubItem(string, item)
|
||||
delSubItem(string)
|
||||
|
||||
Eval(context.Context) template.HTML
|
||||
Items() map[string]item
|
||||
}
|
||||
|
||||
type itemCtxKey struct{}
|
||||
|
||||
func parentItem(ctx context.Context) item {
|
||||
v := ctx.Value(itemCtxKey{})
|
||||
if v == nil {
|
||||
return rootItem
|
||||
}
|
||||
return v.(item)
|
||||
}
|
||||
|
||||
type baseItem struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]item
|
||||
}
|
||||
|
||||
func (i *baseItem) addSubItem(title string, sub item) {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
i.items[title] = sub
|
||||
}
|
||||
|
||||
func (i *baseItem) delSubItem(title string) {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
delete(i.items, title)
|
||||
}
|
||||
|
||||
func (i *baseItem) Items() map[string]item {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
icopy := make(map[string]item, len(i.items))
|
||||
for k, v := range i.items {
|
||||
icopy[k] = v
|
||||
}
|
||||
return icopy
|
||||
}
|
||||
|
||||
// SimpleItem is for untemplated status items that only report a simple non-HTML string.
|
||||
type simpleItem struct {
|
||||
baseItem
|
||||
stat string
|
||||
}
|
||||
|
||||
// SetStatus sets the status of the item.
|
||||
func (i *simpleItem) setStatus(s string) {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
i.stat = s
|
||||
}
|
||||
|
||||
// Eval escapes the status string, and returns the current item value.
|
||||
func (i *simpleItem) Eval(ctx context.Context) template.HTML {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
return template.HTML(template.HTMLEscapeString(i.stat))
|
||||
}
|
||||
|
||||
// ItemCallback funcs are used by templated status items to provide the value to
|
||||
// hydrate the item template.
|
||||
type ItemCallback = func(context.Context) (any, error)
|
||||
|
||||
// templatedItem uses a template to format the item.
|
||||
type templatedItem struct {
|
||||
baseItem
|
||||
tmpl *template.Template
|
||||
cb ItemCallback
|
||||
}
|
||||
|
||||
// Eval calls the item callback, and feeds the result through the item's
|
||||
// own template.
|
||||
func (i *templatedItem) Eval(ctx context.Context) template.HTML {
|
||||
var sb strings.Builder
|
||||
|
||||
data, err := i.cb(ctx)
|
||||
if err != nil {
|
||||
errorTmpl.Execute(&sb, errorData{
|
||||
Operation: "Error from item callback",
|
||||
Error: err,
|
||||
Item: data,
|
||||
})
|
||||
return template.HTML(sb.String())
|
||||
}
|
||||
if err := i.tmpl.Execute(&sb, data); err != nil {
|
||||
errorTmpl.Execute(&sb, errorData{
|
||||
Operation: "Error while executing item template",
|
||||
Error: err,
|
||||
Item: data,
|
||||
})
|
||||
}
|
||||
return template.HTML(sb.String())
|
||||
}
|
||||
|
||||
// Handle handles status page requests.
|
||||
func Handle(w http.ResponseWriter, r *http.Request) {
|
||||
data := &statusData{
|
||||
Items: rootItem.items,
|
||||
Version: "TODO",
|
||||
Build: "TODO",
|
||||
Hostname: hostname,
|
||||
Username: username,
|
||||
ExePath: exepath,
|
||||
PID: os.Getpid(),
|
||||
Compiler: runtime.Compiler,
|
||||
RuntimeVer: runtime.Version(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
NumCPU: runtime.NumCPU(),
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
StartTime: startTime.Format(time.RFC1123),
|
||||
StartTimeAgo: time.Since(startTime),
|
||||
CurrentTime: time.Now().Format(time.RFC1123),
|
||||
Ctx: r.Context(),
|
||||
}
|
||||
|
||||
// The status template ranges over the items.
|
||||
rootItem.mu.RLock()
|
||||
defer rootItem.mu.RUnlock()
|
||||
if err := statusTmpl.Execute(w, data); err != nil {
|
||||
errorTmpl.Execute(w, errorData{
|
||||
Operation: "Error while executing main template",
|
||||
Error: err,
|
||||
Item: data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AddItem adds an item to be displayed on the status page. On each page load,
|
||||
// the item's callback is called, and the data returned used to fill the
|
||||
// HTML template in tmpl. The title should be unique (among items under this
|
||||
// parent.
|
||||
func AddItem(parent context.Context, title, tmpl string, cb func(context.Context) (any, error)) (context.Context, func()) {
|
||||
if cb == nil {
|
||||
cb = func(context.Context) (any, error) { return nil, nil }
|
||||
}
|
||||
|
||||
item := &templatedItem{
|
||||
baseItem: baseItem{
|
||||
items: make(map[string]item),
|
||||
},
|
||||
tmpl: template.New(title).Funcs(funcMap),
|
||||
cb: cb,
|
||||
}
|
||||
|
||||
if _, err := item.tmpl.Parse(tmpl); err != nil {
|
||||
// Insert an item, but swap the template for the error template, and
|
||||
// wrap the callback's return in an errorData.
|
||||
item.tmpl = errorTmpl
|
||||
item.cb = wrapInItemError("Could not parse item template", err, cb)
|
||||
}
|
||||
|
||||
pitem := parentItem(parent)
|
||||
pitem.addSubItem(title, item)
|
||||
|
||||
return context.WithValue(parent, itemCtxKey{}, item), func() { pitem.delSubItem(title) }
|
||||
}
|
||||
|
||||
// AddSimpleItem adds a simple status item. Set the value shown by the
|
||||
// item by calling setStatus.
|
||||
func AddSimpleItem(parent context.Context, title string) (ctx context.Context, setStatus func(string), done func()) {
|
||||
item := &simpleItem{
|
||||
baseItem: baseItem{
|
||||
items: make(map[string]item),
|
||||
},
|
||||
stat: "Unknown status",
|
||||
}
|
||||
pitem := parentItem(parent)
|
||||
pitem.addSubItem(title, item)
|
||||
|
||||
return context.WithValue(parent, itemCtxKey{}, item), item.setStatus, func() { pitem.delSubItem(title) }
|
||||
}
|
||||
|
||||
// DelItem removes a status item, specified by the title, from a parent context.
|
||||
func DelItem(parent context.Context, title string) {
|
||||
pitem := item(rootItem)
|
||||
if v := parent.Value(itemCtxKey{}); v != nil {
|
||||
pitem = v.(item)
|
||||
}
|
||||
|
||||
pitem.delSubItem(title)
|
||||
}
|
||||
|
||||
// wrapInItemError takes a callback and returns a new callback wrapping the
|
||||
// result in an errorData.
|
||||
func wrapInItemError(op string, err error, cb ItemCallback) ItemCallback {
|
||||
return func(ctx context.Context) (any, error) {
|
||||
if cb == nil {
|
||||
return &errorData{
|
||||
Operation: op,
|
||||
Error: err,
|
||||
}, nil
|
||||
}
|
||||
// Surface item callback errors first.
|
||||
data, err2 := cb(ctx)
|
||||
if err2 != nil {
|
||||
return data, err2
|
||||
}
|
||||
// Surface the item template parse error.
|
||||
return &errorData{
|
||||
Operation: op,
|
||||
Error: err,
|
||||
Item: data,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// printJSON is used as a fallback renderer for item status values.
|
||||
func printJSON(v any) (string, error) {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
return string(b), err
|
||||
}
|
81
status/status.html.tmpl
Normal file
81
status/status.html.tmpl
Normal file
|
@ -0,0 +1,81 @@
|
|||
{{- define "render_items" -}}
|
||||
{{- range $title, $item := . -}}
|
||||
<details open>
|
||||
<summary>{{$title}}</summary>
|
||||
<div class="itemdata">{{$item.Eval $.Ctx}}</div>
|
||||
<div class="subitems">{{template "render_items" $item.Items}}</div>
|
||||
</details>
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Status for buildkite-agent</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
background: #fff;
|
||||
}
|
||||
h1 {
|
||||
clear: both;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 120%;
|
||||
background: #eef;
|
||||
padding: 4px;
|
||||
}
|
||||
summary {
|
||||
width: 100%;
|
||||
font-size: 120%;
|
||||
background: #eef;
|
||||
padding: 0.2em;
|
||||
border: 1px #eef solid;
|
||||
}
|
||||
details {
|
||||
display: block;
|
||||
padding: 1em 0;
|
||||
}
|
||||
details[open] > summary {
|
||||
background: #fff;
|
||||
border: 1px #000 solid;
|
||||
}
|
||||
.error {
|
||||
background: #fee;
|
||||
}
|
||||
div {
|
||||
padding: 0.2em;
|
||||
}
|
||||
div.warning {
|
||||
background: #ffd;
|
||||
}
|
||||
.itemdata {
|
||||
margin: 0 0 0.5em 0.86em;
|
||||
padding: 0.5em 0em 0.5em 1.5em;
|
||||
border-left: 0.1em #ccc dotted;
|
||||
}
|
||||
.subitems {
|
||||
margin: 0 0 0.5em 0.86em;
|
||||
padding: 0.5em 0em 0.5em 1.5em;
|
||||
border-left: 0.1em #ccc dotted;
|
||||
border-bottom: 0.1em #ccc dotted;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Status for buildkite-agent</h1>
|
||||
<div class="warning">
|
||||
⚠️ This page is intended only for debugging purposes!<br>
|
||||
Do not expect the structure and formatting of this page to be stable across versions.
|
||||
</div>
|
||||
<div class="summary">
|
||||
{{.ExePath}}<br>
|
||||
Started at {{.StartTime}} ({{.StartTimeAgo}} ago)<br>
|
||||
Current time {{.CurrentTime}}<br>
|
||||
Version {{.Version}}, build {{.Build}}<br>
|
||||
Running in PID {{.PID}} as {{.Username}} on {{.Hostname}}<br>
|
||||
{{.RuntimeVer}} on {{.GOOS}}/{{.GOARCH}} compiled by {{.Compiler}}<br>
|
||||
{{.NumGoroutine}} goroutines running/{{.NumCPU}} logical CPUs usable<br>
|
||||
</div>
|
||||
{{template "render_items" .Items}}
|
||||
</body>
|
||||
</html>
|
88
status/status_test.go
Normal file
88
status/status_test.go
Normal file
|
@ -0,0 +1,88 @@
|
|||
package status
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSmokeErrorTemplate(t *testing.T) {
|
||||
errData := &errorData{
|
||||
Operation: "Couldn't fluff the llamas",
|
||||
Error: errors.New("llama comb unavailable"),
|
||||
Item: map[string]any{
|
||||
"llamas": "yes",
|
||||
"alpacas": 42,
|
||||
},
|
||||
}
|
||||
|
||||
if err := errorTmpl.Execute(io.Discard, errData); err != nil {
|
||||
t.Errorf("errorTmpl.Execute(io.Discard, errData) = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmokeStatusTemplate(t *testing.T) {
|
||||
data := &statusData{
|
||||
Items: map[string]item{
|
||||
"Llamas": &simpleItem{
|
||||
stat: "✅ Llamas enabled",
|
||||
},
|
||||
"Alpacas": &templatedItem{
|
||||
tmpl: template.Must(template.New("alpacas").Parse("Alpacas enabled at: {{.AlpacasEnabled}}")),
|
||||
cb: func(context.Context) (any, error) {
|
||||
return struct {
|
||||
AlpacasEnabled time.Time
|
||||
}{time.Now()}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
Version: "0.0.0",
|
||||
Build: "1234",
|
||||
Hostname: hostname,
|
||||
Username: username,
|
||||
ExePath: exepath,
|
||||
PID: os.Getpid(),
|
||||
Compiler: runtime.Compiler,
|
||||
RuntimeVer: runtime.Version(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
NumCPU: runtime.NumCPU(),
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
StartTime: startTime.Format(time.RFC1123),
|
||||
StartTimeAgo: time.Since(startTime),
|
||||
CurrentTime: time.Now().Format(time.RFC1123),
|
||||
Ctx: context.Background(),
|
||||
}
|
||||
|
||||
if err := statusTmpl.Execute(io.Discard, data); err != nil {
|
||||
t.Errorf("statusData.Execute(io.Discard, data) = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmokeHandle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cctx, setStat, done := AddSimpleItem(ctx, "Llamas")
|
||||
defer done()
|
||||
setStat("Essence of Llama")
|
||||
|
||||
_, setStat2, done2 := AddSimpleItem(cctx, "Kuzco")
|
||||
defer done2()
|
||||
setStat2("Oh, right. The poison. The poison for Kuzco, the poison chosen especially to kill Kuzco, Kuzco's poison.")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/status", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewReqeustWithContext(GET /status) error = %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
Handle(rec, req)
|
||||
if got, want := rec.Result().StatusCode, http.StatusOK; got != want {
|
||||
t.Errorf("Handle(rec, req): rec.Result().StatusCode = %v, want %v", got, want)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue