This change adds an HTTP handler with a table showing a list of all probes, their status, and a button that allows triggering a specific probe. Updates tailscale/corp#20583 Signed-off-by: Anton Tolchanov <anton@tailscale.com>main
parent
153a476957
commit
9b08399d9e
@ -0,0 +1,124 @@ |
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package prober |
||||
|
||||
import ( |
||||
"embed" |
||||
"fmt" |
||||
"html/template" |
||||
"net/http" |
||||
"strings" |
||||
"time" |
||||
|
||||
"tailscale.com/tsweb" |
||||
"tailscale.com/util/mak" |
||||
) |
||||
|
||||
//go:embed status.html
|
||||
var statusFiles embed.FS |
||||
var statusTpl = template.Must(template.ParseFS(statusFiles, "status.html")) |
||||
|
||||
type statusHandlerOpt func(*statusHandlerParams) |
||||
type statusHandlerParams struct { |
||||
title string |
||||
|
||||
pageLinks map[string]string |
||||
probeLinks map[string]string |
||||
} |
||||
|
||||
// WithTitle sets the title of the status page.
|
||||
func WithTitle(title string) statusHandlerOpt { |
||||
return func(opts *statusHandlerParams) { |
||||
opts.title = title |
||||
} |
||||
} |
||||
|
||||
// WithPageLink adds a top-level link to the status page.
|
||||
func WithPageLink(text, url string) statusHandlerOpt { |
||||
return func(opts *statusHandlerParams) { |
||||
mak.Set(&opts.pageLinks, text, url) |
||||
} |
||||
} |
||||
|
||||
// WithProbeLink adds a link to each probe on the status page.
|
||||
// The textTpl and urlTpl are Go templates that will be rendered
|
||||
// with the respective ProbeInfo struct as the data.
|
||||
func WithProbeLink(textTpl, urlTpl string) statusHandlerOpt { |
||||
return func(opts *statusHandlerParams) { |
||||
mak.Set(&opts.probeLinks, textTpl, urlTpl) |
||||
} |
||||
} |
||||
|
||||
// StatusHandler is a handler for the probe overview HTTP endpoint.
|
||||
// It shows a list of probes and their current status.
|
||||
func (p *Prober) StatusHandler(opts ...statusHandlerOpt) tsweb.ReturnHandlerFunc { |
||||
params := &statusHandlerParams{ |
||||
title: "Prober Status", |
||||
} |
||||
for _, opt := range opts { |
||||
opt(params) |
||||
} |
||||
return func(w http.ResponseWriter, r *http.Request) error { |
||||
type probeStatus struct { |
||||
ProbeInfo |
||||
TimeSinceLast time.Duration |
||||
Links map[string]template.URL |
||||
} |
||||
vars := struct { |
||||
Title string |
||||
Links map[string]template.URL |
||||
TotalProbes int64 |
||||
UnhealthyProbes int64 |
||||
Probes map[string]probeStatus |
||||
}{ |
||||
Title: params.title, |
||||
} |
||||
|
||||
for text, url := range params.pageLinks { |
||||
mak.Set(&vars.Links, text, template.URL(url)) |
||||
} |
||||
|
||||
for name, info := range p.ProbeInfo() { |
||||
vars.TotalProbes++ |
||||
if !info.Result { |
||||
vars.UnhealthyProbes++ |
||||
} |
||||
s := probeStatus{ProbeInfo: info} |
||||
if !info.End.IsZero() { |
||||
s.TimeSinceLast = time.Since(info.End) |
||||
} |
||||
for textTpl, urlTpl := range params.probeLinks { |
||||
text, err := renderTemplate(textTpl, info) |
||||
if err != nil { |
||||
return tsweb.Error(500, err.Error(), err) |
||||
} |
||||
url, err := renderTemplate(urlTpl, info) |
||||
if err != nil { |
||||
return tsweb.Error(500, err.Error(), err) |
||||
} |
||||
mak.Set(&s.Links, text, template.URL(url)) |
||||
} |
||||
mak.Set(&vars.Probes, name, s) |
||||
} |
||||
|
||||
if err := statusTpl.ExecuteTemplate(w, "status", vars); err != nil { |
||||
return tsweb.HTTPError{Code: 500, Err: err, Msg: "error rendering status page"} |
||||
} |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
// renderTemplate renders the given Go template with the provided data
|
||||
// and returns the result as a string.
|
||||
func renderTemplate(tpl string, data any) (string, error) { |
||||
t, err := template.New("").Parse(tpl) |
||||
if err != nil { |
||||
return "", fmt.Errorf("error parsing template %q: %w", tpl, err) |
||||
} |
||||
var buf strings.Builder |
||||
if err := t.ExecuteTemplate(&buf, "", data); err != nil { |
||||
return "", fmt.Errorf("error rendering template %q with data %v: %w", tpl, data, err) |
||||
} |
||||
return buf.String(), nil |
||||
} |
||||
@ -0,0 +1,132 @@ |
||||
{{define "status"}} |
||||
<html> |
||||
<head><title>{{.Title}}</title></head> |
||||
<style> |
||||
body { |
||||
/* max-width: 60rem; */ |
||||
margin-left: auto; |
||||
margin-right: auto; |
||||
padding: 3rem 1rem 8rem; |
||||
line-height: 1.4; |
||||
font-size: 1rem; |
||||
font-weight: 400; |
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; |
||||
text-rendering: optimizeLegibility; |
||||
} |
||||
.small { |
||||
font-size: 0.7rem; |
||||
} |
||||
h1 { |
||||
font-weight: 500; |
||||
letter-spacing: -.025em; |
||||
} |
||||
a { color: rgb(74 125 221); } |
||||
a:hover { color: rgb(73 100 149); } |
||||
ul { |
||||
list-style: none; |
||||
margin: 0; |
||||
padding: 0; |
||||
} |
||||
ul>li::before { |
||||
position: absolute; |
||||
top: .625rem; |
||||
left: .125rem; |
||||
height: .375rem; |
||||
width: .375rem; |
||||
border-radius: 9999px; |
||||
background-color: currentColor; |
||||
opacity: .4; |
||||
content: ""; |
||||
} |
||||
ul>li { |
||||
position: relative; |
||||
padding-left: 1.25rem; |
||||
} |
||||
th, td { |
||||
padding: 5px; |
||||
text-align: left; |
||||
background: #eeeeee; |
||||
} |
||||
.error { |
||||
color: red; |
||||
} |
||||
</style> |
||||
<body> |
||||
<h1>{{.Title}}</h1> |
||||
<ul> |
||||
<li>Prober Status: |
||||
{{if .UnhealthyProbes }} |
||||
<span class="error">{{.UnhealthyProbes}}</span> |
||||
out of {{.TotalProbes}} probes failed or never ran. |
||||
{{else}} |
||||
All {{.TotalProbes}} probes are healthy |
||||
{{end}} |
||||
</li> |
||||
{{ range $text, $url := .Links }} |
||||
<li><a href="{{$url}}">{{$text}}</a></li> |
||||
{{end}} |
||||
</ul> |
||||
|
||||
<h1>Probes:</h1> |
||||
<table class="sortable"> |
||||
<thead><tr> |
||||
<th>Name</th> |
||||
<th>Class & Labels</th> |
||||
<th>Interval</th> |
||||
<th>Result</th> |
||||
<th>Success</th> |
||||
<th>Latency</th> |
||||
<th>Error</th> |
||||
</tr></thead> |
||||
<tbody> |
||||
{{range $name, $probeInfo := .Probes}} |
||||
<tr> |
||||
<td> |
||||
{{$name}} |
||||
{{range $text, $url := $probeInfo.Links}} |
||||
<br/> |
||||
<button onclick="location.href='{{$url}}';" type="button"> |
||||
{{$text}} |
||||
</button> |
||||
{{end}} |
||||
</td> |
||||
<td>{{$probeInfo.Class}}<br/> |
||||
<div class="small"> |
||||
{{range $label, $value := $probeInfo.Labels}} |
||||
{{$label}}={{$value}}<br/> |
||||
{{end}} |
||||
</div> |
||||
</td> |
||||
<td>{{$probeInfo.Interval}}</td> |
||||
<td data-sort="{{$probeInfo.TimeSinceLast.Milliseconds}}"> |
||||
{{if $probeInfo.TimeSinceLast}} |
||||
{{$probeInfo.TimeSinceLast.String}}<br/> |
||||
<span class="small">{{$probeInfo.End}}</span> |
||||
{{else}} |
||||
Never |
||||
{{end}} |
||||
</td> |
||||
<td> |
||||
{{if $probeInfo.Result}} |
||||
{{$probeInfo.Result}} |
||||
{{else}} |
||||
<span class="error">{{$probeInfo.Result}}</span> |
||||
{{end}}<br/> |
||||
<div class="small">Recent: {{$probeInfo.RecentResults}}</div> |
||||
<div class="small">Mean: {{$probeInfo.RecentSuccessRatio}}</div> |
||||
</td> |
||||
<td data-sort="{{$probeInfo.Latency.Milliseconds}}"> |
||||
{{$probeInfo.Latency.String}} |
||||
<div class="small">Recent: {{$probeInfo.RecentLatencies}}</div> |
||||
<div class="small">Median: {{$probeInfo.RecentMedianLatency}}</div> |
||||
</td> |
||||
<td class="small">{{$probeInfo.Error}}</td> |
||||
</tr> |
||||
{{end}} |
||||
</tbody> |
||||
</table> |
||||
<link href="https://cdn.jsdelivr.net/gh/tofsjonas/sortable@latest/sortable-base.min.css" rel="stylesheet" /> |
||||
<script src="https://cdn.jsdelivr.net/gh/tofsjonas/sortable@latest/sortable.min.js"></script> |
||||
</body> |
||||
</html> |
||||
{{end}} |
||||
Loading…
Reference in new issue