480 lines
12 KiB
Go
480 lines
12 KiB
Go
package gui
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gioui.org/layout"
|
|
"gioui.org/widget"
|
|
"gioui.org/widget/material"
|
|
|
|
"tslink/core"
|
|
)
|
|
|
|
// chartWindow is how much latency history the graph shows. It matches the
|
|
// monitor's default 120-sample ring at a 10s ping interval.
|
|
const chartWindow = 20 * time.Minute
|
|
|
|
// maxChartSeries caps how many peers are plotted at once. Beyond about eight
|
|
// lines a latency graph stops being readable, so linked peers win and the rest
|
|
// can be toggled on from the legend.
|
|
const maxChartSeries = 8
|
|
|
|
type peerRow struct {
|
|
click widget.Clickable
|
|
expanded bool
|
|
}
|
|
|
|
type peersPage struct {
|
|
list widget.List
|
|
chart Chart
|
|
rows map[string]*peerRow
|
|
legend map[string]*widget.Clickable
|
|
hidden map[string]bool
|
|
refresh widget.Clickable
|
|
}
|
|
|
|
func newPeersPage() *peersPage {
|
|
p := &peersPage{
|
|
rows: make(map[string]*peerRow),
|
|
legend: make(map[string]*widget.Clickable),
|
|
hidden: make(map[string]bool),
|
|
}
|
|
p.list.Axis = layout.Vertical
|
|
return p
|
|
}
|
|
|
|
func (p *peersPage) row(id string) *peerRow {
|
|
r, ok := p.rows[id]
|
|
if !ok {
|
|
r = &peerRow{}
|
|
p.rows[id] = r
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (p *peersPage) legendClick(id string) *widget.Clickable {
|
|
c, ok := p.legend[id]
|
|
if !ok {
|
|
c = &widget.Clickable{}
|
|
p.legend[id] = c
|
|
}
|
|
return c
|
|
}
|
|
|
|
func (p *peersPage) Layout(a *App, gtx C, st core.State) D {
|
|
th := a.th
|
|
if st.Peers == nil {
|
|
return th.EmptyState(gtx, IconNodes, th.T(KPeersEmpty), th.T(KLoading))
|
|
}
|
|
snap := st.Peers.Snapshot()
|
|
|
|
if p.refresh.Clicked(gtx) {
|
|
st.Peers.RefreshNow()
|
|
a.notify(th.T(KRefresh), LevelInfo)
|
|
}
|
|
|
|
linked, other := splitPeers(snap.Peers)
|
|
series := p.buildSeries(th, snap.Peers)
|
|
|
|
// Legend clicks toggle series visibility.
|
|
for i := range series {
|
|
id := series[i].id
|
|
if p.legendClick(id).Clicked(gtx) {
|
|
p.hidden[id] = !p.hidden[id]
|
|
}
|
|
series[i].s.Hidden = p.hidden[id]
|
|
}
|
|
|
|
items := make([]layout.Widget, 0, len(snap.Peers)+4)
|
|
items = append(items, func(gtx C) D { return p.chartCard(a, gtx, series) })
|
|
|
|
if len(linked) > 0 {
|
|
items = append(items, func(gtx C) D {
|
|
return a.sectionTitle(gtx, th.T(KPeersLinked), th.T(KGraphLegendHint), nil)
|
|
})
|
|
for _, pr := range linked {
|
|
items = append(items, func(gtx C) D { return p.peerCard(a, gtx, st, pr) })
|
|
}
|
|
}
|
|
if len(other) > 0 {
|
|
items = append(items, func(gtx C) D {
|
|
return a.sectionTitle(gtx, th.T(KPeersOther), "", nil)
|
|
})
|
|
for _, pr := range other {
|
|
items = append(items, func(gtx C) D { return p.peerCard(a, gtx, st, pr) })
|
|
}
|
|
}
|
|
if len(snap.Peers) == 0 {
|
|
items = append(items, func(gtx C) D {
|
|
hint := snap.Err
|
|
if hint == "" {
|
|
hint = snap.BackendState
|
|
}
|
|
return th.EmptyState(gtx, IconNodes, th.T(KPeersEmpty), hint)
|
|
})
|
|
}
|
|
|
|
return material.List(th.Theme, &p.list).Layout(gtx, len(items), func(gtx C, i int) D {
|
|
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, items[i])
|
|
})
|
|
}
|
|
|
|
// splitPeers separates the peers a config rule points at from the rest. Those
|
|
// are the only ones whose latency actually matters to the user's game session.
|
|
func splitPeers(peers []core.PeerInfo) (linked, other []core.PeerInfo) {
|
|
for _, p := range peers {
|
|
if p.Linked {
|
|
linked = append(linked, p)
|
|
} else {
|
|
other = append(other, p)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
type namedSeries struct {
|
|
id string
|
|
s ChartSeries
|
|
}
|
|
|
|
func (p *peersPage) buildSeries(th *Theme, peers []core.PeerInfo) []namedSeries {
|
|
candidates := append([]core.PeerInfo(nil), peers...)
|
|
sort.SliceStable(candidates, func(i, j int) bool {
|
|
if candidates[i].Linked != candidates[j].Linked {
|
|
return candidates[i].Linked
|
|
}
|
|
return candidates[i].Online && !candidates[j].Online
|
|
})
|
|
|
|
out := make([]namedSeries, 0, maxChartSeries)
|
|
for i, pr := range candidates {
|
|
if len(out) >= maxChartSeries {
|
|
break
|
|
}
|
|
if len(pr.Samples) == 0 {
|
|
continue
|
|
}
|
|
pts := make([]ChartPoint, 0, len(pr.Samples))
|
|
for _, s := range pr.Samples {
|
|
pts = append(pts, ChartPoint{
|
|
At: s.At,
|
|
Value: float64(s.Latency) / float64(time.Millisecond),
|
|
OK: s.OK,
|
|
})
|
|
}
|
|
out = append(out, namedSeries{
|
|
id: pr.ID,
|
|
s: ChartSeries{
|
|
Name: pr.DisplayName,
|
|
Color: th.SeriesColor(i),
|
|
Points: pts,
|
|
Subtitle: routeLabel(th, pr.Route),
|
|
},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D {
|
|
th := a.th
|
|
card := th.Card()
|
|
card.Title = th.T(KGraphTitle)
|
|
card.Subtitle = th.T(KGraphWindow)
|
|
card.Trailing = func(gtx C) D {
|
|
return th.IconButton(gtx, &p.refresh, IconRefresh, LevelNeutral)
|
|
}
|
|
return card.Layout(th, gtx, func(gtx C) D {
|
|
if len(series) == 0 {
|
|
return th.EmptyState(gtx, IconPulse, th.T(KGraphEmpty), "")
|
|
}
|
|
plot := make([]ChartSeries, len(series))
|
|
for i, s := range series {
|
|
plot[i] = s.s
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
return p.chart.Layout(th, gtx, ChartStyle{
|
|
Height: 200,
|
|
Window: chartWindow,
|
|
Now: time.Now(),
|
|
Unit: "ms",
|
|
FillSingle: true,
|
|
}, plot)
|
|
}),
|
|
VGap(SpaceMD),
|
|
layout.Rigid(func(gtx C) D {
|
|
return p.legendRow(a, gtx, series)
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (p *peersPage) legendRow(a *App, gtx C, series []namedSeries) D {
|
|
th := a.th
|
|
children := make([]layout.FlexChild, 0, len(series))
|
|
for _, s := range series {
|
|
id := s.id
|
|
entry := LegendEntry{
|
|
Name: s.s.Name,
|
|
Color: s.s.Color,
|
|
Hidden: p.hidden[id],
|
|
Value: lastValue(s.s.Points),
|
|
}
|
|
click := p.legendClick(id)
|
|
children = append(children, layout.Rigid(func(gtx C) D {
|
|
return click.Layout(gtx, func(gtx C) D {
|
|
return th.LegendChip(gtx, entry, click.Hovered())
|
|
})
|
|
}))
|
|
}
|
|
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceEnd}.Layout(gtx, children...)
|
|
}
|
|
|
|
func lastValue(points []ChartPoint) string {
|
|
for i := len(points) - 1; i >= 0; i-- {
|
|
if points[i].OK {
|
|
return FormatLatency(time.Duration(points[i].Value * float64(time.Millisecond)))
|
|
}
|
|
}
|
|
return "—"
|
|
}
|
|
|
|
func routeLabel(th *Theme, r core.PeerRoute) string {
|
|
switch r {
|
|
case core.RouteDirect:
|
|
return th.T(KPeerRouteDirect)
|
|
case core.RouteDERP:
|
|
return th.T(KPeerRouteDERP)
|
|
case core.RoutePeerRelay:
|
|
return th.T(KPeerRoutePeerRelay)
|
|
case core.RouteOffline:
|
|
return th.T(KPeerRouteOffline)
|
|
default:
|
|
return th.T(KPeerRouteUnknown)
|
|
}
|
|
}
|
|
|
|
func routeLevel(r core.PeerRoute) StatusLevel {
|
|
switch r {
|
|
case core.RouteDirect:
|
|
return LevelOK
|
|
case core.RouteDERP, core.RoutePeerRelay:
|
|
return LevelWarn
|
|
case core.RouteOffline:
|
|
return LevelFail
|
|
default:
|
|
return LevelNeutral
|
|
}
|
|
}
|
|
|
|
func (p *peersPage) peerCard(a *App, gtx C, st core.State, pr core.PeerInfo) D {
|
|
th := a.th
|
|
row := p.row(pr.ID)
|
|
if row.click.Clicked(gtx) {
|
|
row.expanded = !row.expanded
|
|
}
|
|
|
|
card := th.Card()
|
|
card.Pad = SpaceMD
|
|
if pr.Linked {
|
|
accent := th.SeriesColor(0)
|
|
if !pr.Online {
|
|
accent = th.P.TextDim
|
|
}
|
|
card.Accent = &accent
|
|
}
|
|
return card.Layout(th, gtx, func(gtx C) D {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
return row.click.Layout(gtx, func(gtx C) D {
|
|
return p.peerHeader(a, gtx, pr, row.expanded)
|
|
})
|
|
}),
|
|
layout.Rigid(func(gtx C) D {
|
|
if !row.expanded {
|
|
return D{}
|
|
}
|
|
return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D {
|
|
return p.peerDetail(a, gtx, pr)
|
|
})
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (p *peersPage) peerHeader(a *App, gtx C, pr core.PeerInfo, expanded bool) D {
|
|
th := a.th
|
|
level := LevelOK
|
|
if !pr.Online {
|
|
level = LevelNeutral
|
|
}
|
|
|
|
latency := "—"
|
|
latLevel := LevelNeutral
|
|
if pr.LatencyOK && pr.LastLatency > 0 {
|
|
latency = FormatLatency(pr.LastLatency)
|
|
latLevel = latencyLevel(pr.LastLatency)
|
|
} else if pr.Online {
|
|
latency = th.T(KUnknown)
|
|
}
|
|
|
|
points := make([]ChartPoint, 0, len(pr.Samples))
|
|
for _, s := range pr.Samples {
|
|
points = append(points, ChartPoint{
|
|
At: s.At,
|
|
Value: float64(s.Latency) / float64(time.Millisecond),
|
|
OK: s.OK,
|
|
})
|
|
}
|
|
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
// Never pulsing: one breathing dot per online peer would keep the
|
|
// whole window redrawing for as long as the page is open.
|
|
return th.StatusDot(gtx, level, false)
|
|
}),
|
|
HGap(SpaceMD),
|
|
layout.Flexed(1, func(gtx C) D {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
|
layout.Rigid(func(gtx C) D {
|
|
return OneLine(th.Body(pr.DisplayName)).Layout(gtx)
|
|
}),
|
|
layout.Rigid(func(gtx C) D {
|
|
if !pr.Linked {
|
|
return D{}
|
|
}
|
|
return layout.Inset{Left: 6}.Layout(gtx, func(gtx C) D {
|
|
return IconLink(gtx, gtx.Dp(12), th.P.Accent)
|
|
})
|
|
}),
|
|
)
|
|
}),
|
|
layout.Rigid(func(gtx C) D {
|
|
sub := pr.DNSName
|
|
if sub == "" && len(pr.TailscaleIPs) > 0 {
|
|
sub = pr.TailscaleIPs[0].String()
|
|
}
|
|
if len(pr.LinkTags) > 0 {
|
|
sub = strings.Join(pr.LinkTags, ", ") + " · " + sub
|
|
}
|
|
return OneLine(th.Caption(sub)).Layout(gtx)
|
|
}),
|
|
)
|
|
}),
|
|
HGap(SpaceMD),
|
|
layout.Rigid(func(gtx C) D {
|
|
return th.Sparkline(gtx, points, th.StatusColor(latLevel), 84, 22)
|
|
}),
|
|
HGap(SpaceMD),
|
|
layout.Rigid(func(gtx C) D {
|
|
gtx.Constraints.Min.X = gtx.Dp(70)
|
|
l := th.MonoLabel(SizeBody, th.StatusColor(latLevel), latency)
|
|
return l.Layout(gtx)
|
|
}),
|
|
HGap(SpaceSM),
|
|
layout.Rigid(func(gtx C) D {
|
|
return th.Chip(gtx, ChipStyle{
|
|
Text: routeLabel(th, pr.Route),
|
|
Level: routeLevel(pr.Route),
|
|
})
|
|
}),
|
|
HGap(SpaceSM),
|
|
layout.Rigid(func(gtx C) D {
|
|
icon := IconChevronRight
|
|
if expanded {
|
|
icon = IconChevronDown
|
|
}
|
|
return icon(gtx, gtx.Dp(14), th.P.TextDim)
|
|
}),
|
|
)
|
|
}
|
|
|
|
// latencyLevel colours a latency figure. The thresholds are chosen for the
|
|
// thing this tool carries: under 60ms a Minecraft session feels local, past
|
|
// 150ms block placement starts to feel wrong.
|
|
func latencyLevel(d time.Duration) StatusLevel {
|
|
switch {
|
|
case d <= 0:
|
|
return LevelNeutral
|
|
case d < 60*time.Millisecond:
|
|
return LevelOK
|
|
case d < 150*time.Millisecond:
|
|
return LevelWarn
|
|
default:
|
|
return LevelFail
|
|
}
|
|
}
|
|
|
|
func (p *peersPage) peerDetail(a *App, gtx C, pr core.PeerInfo) D {
|
|
th := a.th
|
|
addrs := make([]string, 0, len(pr.TailscaleIPs))
|
|
for _, ip := range pr.TailscaleIPs {
|
|
addrs = append(addrs, ip.String())
|
|
}
|
|
|
|
endpoint := pr.CurAddr
|
|
if endpoint == "" {
|
|
endpoint = pr.Relay
|
|
}
|
|
if endpoint == "" {
|
|
endpoint = "—"
|
|
}
|
|
|
|
rows := []KV{
|
|
{Key: th.T(KPeerAddresses), Value: strings.Join(addrs, " "), Mono: true},
|
|
{Key: th.T(KPeerEndpoint), Value: endpoint, Mono: true},
|
|
{Key: th.T(KPeerOS), Value: orDash(pr.OS)},
|
|
{
|
|
Key: th.T(KPeerAvg) + " / " + th.T(KPeerMin) + " / " + th.T(KPeerMax),
|
|
Value: FormatLatency(pr.AvgLatency) + " " +
|
|
FormatLatency(pr.MinLatency) + " " + FormatLatency(pr.MaxLatency),
|
|
Mono: true,
|
|
},
|
|
{
|
|
Key: th.T(KPeerJitter) + " / " + th.T(KPeerLoss),
|
|
Value: trimZero(pr.JitterMs, 1) + " ms · " + trimZero(pr.LossPct, 1) + " %",
|
|
Mono: true,
|
|
Level: lossLevel(pr.LossPct),
|
|
},
|
|
{
|
|
Key: th.T(KPeerRx) + " / " + th.T(KPeerTx),
|
|
Value: FormatBytes(pr.RxBytes) + " · " + FormatBytes(pr.TxBytes),
|
|
Mono: true,
|
|
},
|
|
{Key: th.T(KPeerLastHandshake), Value: RelTime(th, pr.LastHandshake, time.Now())},
|
|
}
|
|
if !pr.Online {
|
|
rows = append(rows, KV{
|
|
Key: th.T(KPeerLastSeen),
|
|
Value: RelTime(th, pr.LastSeen, time.Now()),
|
|
Level: LevelWarn,
|
|
})
|
|
}
|
|
if pr.ExitNode {
|
|
rows = append(rows, KV{Key: th.T(KPeerExitNode), Value: th.T(KYes), Level: LevelInfo})
|
|
}
|
|
return th.KVList(gtx, rows)
|
|
}
|
|
|
|
func lossLevel(pct float64) StatusLevel {
|
|
switch {
|
|
case pct <= 0:
|
|
return LevelNeutral
|
|
case pct < 5:
|
|
return LevelWarn
|
|
default:
|
|
return LevelFail
|
|
}
|
|
}
|
|
|
|
func orDash(s string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return "—"
|
|
}
|
|
return s
|
|
}
|