mirror of https://github.com/tailscale/tailscale/
prober: add a status page handler
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>pull/13010/head
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