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 }