Files
tslink/gui/page_overview.go
T
2026-07-26 20:44:49 +08:00

381 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gui
import (
"image"
"strings"
"time"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
"tslink/netdiag"
)
type overviewPage struct {
list widget.List
diagBtn widget.Clickable
peersBtn widget.Clickable
copySelf widget.Clickable
// svcCopy holds one clickable per service address, allocated on demand.
svcCopy map[string]*widget.Clickable
}
func newOverviewPage() *overviewPage {
p := &overviewPage{}
p.list.Axis = layout.Vertical
return p
}
func (p *overviewPage) Layout(a *App, gtx C, st core.State) D {
th := a.th
if p.diagBtn.Clicked(gtx) {
a.current = pageDiag
a.diag.run()
}
if p.peersBtn.Clicked(gtx) {
a.current = pagePeers
}
var snap core.PeerSnapshot
if st.Peers != nil {
snap = st.Peers.Snapshot()
}
servers := buildServices(st.Config, snap)
if p.copySelf.Clicked(gtx) {
a.copyToClipboard(gtx, selfAddrText(snap.Self), "")
}
items := []layout.Widget{
func(gtx C) D { return p.statRow(a, gtx, st, snap, servers) },
func(gtx C) D { return p.healthCard(a, gtx) },
func(gtx C) D { return p.selfCard(a, gtx, st, snap) },
func(gtx C) D { return p.servicesCard(a, gtx, servers) },
func(gtx C) D { return p.linkedCard(a, gtx, snap) },
}
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])
})
}
// statTile is a headline number with its label. Four of them across the top
// answer "is anything obviously wrong" before the user reads anything else.
func (p *overviewPage) statTile(a *App, gtx C, value, label, hint string, level StatusLevel, icon IconFunc) D {
th := a.th
card := th.Card()
card.Pad = SpaceLG
return card.Layout(th, gtx, 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 {
if icon == nil {
return D{}
}
return layout.Inset{Right: 6}.Layout(gtx, func(gtx C) D {
return icon(gtx, gtx.Dp(13), th.P.TextDim)
})
}),
layout.Flexed(1, OneLine(th.Caption(label)).Layout),
)
}),
VGap(SpaceSM),
layout.Rigid(func(gtx C) D {
l := th.Display(value)
if level != LevelNeutral {
l.Color = th.StatusColor(level)
}
// Single line, always. A value like "19 / 25" wraps at narrow
// tile widths where "8" does not, and one tile a whole line
// taller than its neighbours is what makes the row look broken.
return OneLine(l).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if hint != "" {
return OneLine(th.Caption(hint)).Layout(gtx)
}
// Reserve the hint line even when there is no hint. These tiles
// sit in a row, and Flex does not equalise child heights, so a
// tile that skipped this line came out shorter than its
// neighbours and the row looked misaligned.
macro := op.Record(gtx.Ops)
d := OneLine(th.Caption("X")).Layout(gtx)
macro.Stop()
return D{Size: image.Pt(0, d.Size.Y)}
}),
)
})
}
func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, servers []serviceServer) D {
th := a.th
online, linked := 0, 0
for _, pr := range snap.Peers {
if pr.Online {
online++
}
if pr.Linked {
linked++
}
}
forwardRules, connectRules := 0, 0
if st.Config != nil {
for _, rs := range st.Config.Forward {
forwardRules += len(rs)
}
for _, rs := range st.Config.Connect {
connectRules += len(rs)
}
}
services, broadcast := 0, 0
for _, srv := range servers {
services += len(srv.Services)
for _, svc := range srv.Services {
if svc.Broadcast {
broadcast++
}
}
}
peerLevel := LevelOK
if len(snap.Peers) > 0 && online == 0 {
peerLevel = LevelFail
}
uptime := "—"
if !st.ReadyAt.IsZero() {
uptime = FormatDuration(time.Since(st.ReadyAt))
}
tiles := []layout.Widget{
func(gtx C) D {
return p.statTile(a, gtx,
itoa(online)+" / "+itoa(len(snap.Peers)),
th.T(KOvPeersOnline),
itoa(linked)+" "+th.T(KPeersLinked),
peerLevel, IconNodes)
},
func(gtx C) D {
return p.statTile(a, gtx,
itoa(services),
th.T(KSvcTitle),
itoa(broadcast)+" "+th.T(KSvcBroadcast),
LevelNeutral, IconServer)
},
func(gtx C) D {
return p.statTile(a, gtx,
itoa(connectRules)+" / "+itoa(forwardRules),
th.T(KOvConnectRules)+" / "+th.T(KOvForwardRules),
"", LevelNeutral, IconLink)
},
func(gtx C) D {
hint := ""
if st.Restarts > 0 {
hint = itoa(st.Restarts) + "×" + th.T(KStateRetrying)
}
return p.statTile(a, gtx, uptime, th.T(KOvUptime), hint, LevelNeutral, IconPulse)
},
}
children := make([]layout.FlexChild, 0, len(tiles)*2-1)
for i, t := range tiles {
if i > 0 {
children = append(children, HGap(SpaceMD))
}
children = append(children, layout.Flexed(1, t))
}
return layout.Flex{Alignment: layout.Start}.Layout(gtx, children...)
}
func (p *overviewPage) healthCard(a *App, gtx C) D {
th := a.th
a.diag.mu.Lock()
rep := a.diag.report
running := a.diag.running
lastRun := a.diag.lastRun
a.diag.mu.Unlock()
card := th.Card()
card.Title = th.T(KOvHealth)
if rep != nil {
card.Subtitle = th.T(KDiagLastRun) + " " + RelTime(th, lastRun, time.Now())
accent := th.StatusColor(diagLevel(rep.Status))
card.Accent = &accent
}
return card.Layout(th, gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
if rep == nil {
return th.Secondary(th.T(KDiagNever)).Layout(gtx)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
l := th.Text(SizeBody, th.StatusColor(diagLevel(rep.HeadlineStatus)), rep.Headline)
l.Font.Weight = font.Medium
l.MaxLines = 2
return l.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: SpaceSM}.Layout(gtx, func(gtx C) D {
return p.healthChips(a, gtx, rep)
})
}),
)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
label := th.T(KOvQuickDiag)
if running {
label = th.T(KDiagRunning)
}
return th.Button(gtx, &p.diagBtn, ButtonStyle{
Kind: ButtonPrimary,
Text: label,
Icon: IconPulse,
Disabled: running,
})
}),
)
})
}
func (p *overviewPage) healthChips(a *App, gtx C, rep *netdiag.Report) D {
th := a.th
type chip struct {
label string
level StatusLevel
}
chips := []chip{
{th.T(KDiagSecNAT) + ": " + natTypeLabel(th, rep.NAT.Type), diagLevel(rep.NAT.Status)},
{th.T(KDiagSecUDP), diagLevel(rep.UDP.Status)},
{th.T(KDiagSecOverseas), diagLevel(rep.Overseas.Status)},
{th.T(KDiagSecPortMap), diagLevel(rep.PortMap.Status)},
{th.T(KDiagSecEgress), diagLevel(rep.Egress.Status)},
}
children := make([]layout.FlexChild, 0, len(chips)*2)
for i, c := range chips {
if i > 0 {
children = append(children, HGap(SpaceSM))
}
children = append(children, layout.Rigid(func(gtx C) D {
return th.Chip(gtx, ChipStyle{Text: c.label, Level: c.level, Dot: true})
}))
}
return layout.Flex{Spacing: layout.SpaceEnd}.Layout(gtx, children...)
}
func (p *overviewPage) selfCard(a *App, gtx C, st core.State, snap core.PeerSnapshot) D {
th := a.th
card := th.Card()
card.Title = th.T(KOvSelf)
card.Trailing = func(gtx C) D {
return th.IconButton(gtx, &p.copySelf, IconCopy, LevelNeutral)
}
return card.Layout(th, gtx, func(gtx C) D {
rows := []KV{
{Key: th.T(KOvTailnet), Value: orDash(snap.TailnetName)},
{Key: "Hostname", Value: orDash(snap.Self.DisplayName), Mono: true},
{Key: th.T(KPeerAddresses), Value: orDash(selfAddrText(snap.Self)), Mono: true},
}
if snap.MagicDNSSuffix != "" {
rows = append(rows, KV{Key: "MagicDNS", Value: snap.MagicDNSSuffix, Mono: true})
}
if snap.Err != "" {
rows = append(rows, KV{Key: th.T(KError), Value: snap.Err, Level: LevelFail})
}
return th.KVList(gtx, rows)
})
}
func selfAddrText(self core.PeerInfo) string {
parts := make([]string, 0, len(self.TailscaleIPs))
for _, ip := range self.TailscaleIPs {
parts = append(parts, ip.String())
}
return strings.Join(parts, " ")
}
func (p *overviewPage) linkedCard(a *App, gtx C, snap core.PeerSnapshot) D {
th := a.th
linked, _ := splitPeers(snap.Peers)
card := th.Card()
card.Title = th.T(KPeersLinked)
card.Trailing = func(gtx C) D {
return th.Button(gtx, &p.peersBtn, ButtonStyle{
Kind: ButtonGhost, Text: th.T(KDetails), Icon: IconChevronRight,
})
}
return card.Layout(th, gtx, func(gtx C) D {
if len(linked) == 0 {
return th.EmptyState(gtx, IconLink, th.T(KPeersEmpty), th.T(KOvConnectRules))
}
children := make([]layout.FlexChild, 0, len(linked)*2)
for i, pr := range linked {
if i > 0 {
children = append(children, layout.Rigid(th.Divider))
}
children = append(children, layout.Rigid(func(gtx C) D {
return p.linkedRow(a, gtx, pr)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
func (p *overviewPage) linkedRow(a *App, gtx C, pr core.PeerInfo) D {
th := a.th
level := LevelOK
if !pr.Online {
level = LevelFail
}
latency := "—"
latLevel := LevelNeutral
if pr.LatencyOK && pr.LastLatency > 0 {
latency = FormatLatency(pr.LastLatency)
latLevel = latencyLevel(pr.LastLatency)
}
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.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.StatusDot(gtx, level, false)
}),
HGap(SpaceMD),
layout.Flexed(1, func(gtx C) D {
return OneLine(th.Body(pr.DisplayName)).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
return th.Sparkline(gtx, points, th.StatusColor(latLevel), 70, 18)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(66)
return th.MonoLabel(SizeBody, th.StatusColor(latLevel), latency).Layout(gtx)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
return th.Chip(gtx, ChipStyle{
Text: routeLabel(th, pr.Route),
Level: routeLevel(pr.Route),
})
}),
)
})
}