add: gui and tsdiag
This commit is contained in:
@@ -0,0 +1,988 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/text"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"tslink/core"
|
||||
"tslink/netdiag"
|
||||
)
|
||||
|
||||
type diagPage struct {
|
||||
app *App
|
||||
list widget.List
|
||||
|
||||
runBtn widget.Clickable
|
||||
copyBtn widget.Clickable
|
||||
skipGeo widget.Bool
|
||||
|
||||
// mu guards everything the background run writes.
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
report *netdiag.Report
|
||||
progress map[string]netdiag.Progress
|
||||
order []string
|
||||
lastRun time.Time
|
||||
runErr string
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func newDiagPage(a *App) *diagPage {
|
||||
p := &diagPage{
|
||||
app: a,
|
||||
progress: make(map[string]netdiag.Progress),
|
||||
}
|
||||
p.list.Axis = layout.Vertical
|
||||
return p
|
||||
}
|
||||
|
||||
func diagLevel(s netdiag.Status) StatusLevel {
|
||||
switch s {
|
||||
case netdiag.StatusOK:
|
||||
return LevelOK
|
||||
case netdiag.StatusWarn:
|
||||
return LevelWarn
|
||||
case netdiag.StatusFail:
|
||||
return LevelFail
|
||||
default:
|
||||
return LevelNeutral
|
||||
}
|
||||
}
|
||||
|
||||
// reportText renders the last report for inclusion in a shared bundle.
|
||||
func (p *diagPage) reportText() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.report == nil {
|
||||
return ""
|
||||
}
|
||||
return p.report.Text()
|
||||
}
|
||||
|
||||
// run starts a diagnostic sweep on a background goroutine.
|
||||
func (p *diagPage) run() {
|
||||
p.mu.Lock()
|
||||
if p.running {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p.running = true
|
||||
p.cancel = cancel
|
||||
p.progress = make(map[string]netdiag.Progress)
|
||||
p.order = nil
|
||||
p.runErr = ""
|
||||
skipGeo := p.skipGeo.Value
|
||||
p.mu.Unlock()
|
||||
|
||||
a := p.app
|
||||
st := a.state()
|
||||
var src netdiag.TailscaleSource
|
||||
if st.Server != nil {
|
||||
src = core.DefaultTailscaleSource(st.Server, a.logger)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
rep := netdiag.Run(ctx, netdiag.Options{
|
||||
Logger: a.logger.With("from", "netdiag"),
|
||||
Tailscale: src,
|
||||
IPInfoToken: a.opt.IPInfoToken,
|
||||
SkipGeo: skipGeo,
|
||||
OnProgress: func(pr netdiag.Progress) {
|
||||
p.mu.Lock()
|
||||
if _, seen := p.progress[pr.Key]; !seen {
|
||||
p.order = append(p.order, pr.Key)
|
||||
}
|
||||
p.progress[pr.Key] = pr
|
||||
p.mu.Unlock()
|
||||
if a.win != nil {
|
||||
a.win.Invalidate()
|
||||
}
|
||||
},
|
||||
})
|
||||
p.mu.Lock()
|
||||
p.report = rep
|
||||
p.running = false
|
||||
p.lastRun = time.Now()
|
||||
p.cancel = nil
|
||||
p.mu.Unlock()
|
||||
if a.win != nil {
|
||||
a.win.Invalidate()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *diagPage) Layout(a *App, gtx C, st core.State) D {
|
||||
th := a.th
|
||||
|
||||
if p.runBtn.Clicked(gtx) {
|
||||
p.run()
|
||||
}
|
||||
if p.copyBtn.Clicked(gtx) {
|
||||
if txt := p.reportText(); txt != "" {
|
||||
a.copyToClipboard(gtx, txt, th.T(KCopied))
|
||||
}
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
running := p.running
|
||||
report := p.report
|
||||
lastRun := p.lastRun
|
||||
progress := make([]netdiag.Progress, 0, len(p.order))
|
||||
for _, k := range p.order {
|
||||
progress = append(progress, p.progress[k])
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
items := []layout.Widget{
|
||||
func(gtx C) D { return p.controlCard(a, gtx, running, report, lastRun, progress) },
|
||||
}
|
||||
if report != nil {
|
||||
items = append(items,
|
||||
func(gtx C) D { return p.natCard(a, gtx, report.NAT) },
|
||||
func(gtx C) D { return p.udpCard(a, gtx, report.UDP) },
|
||||
func(gtx C) D { return p.portMapCard(a, gtx, report.PortMap) },
|
||||
func(gtx C) D { return p.overseasCard(a, gtx, report.Overseas) },
|
||||
func(gtx C) D { return p.egressCard(a, gtx, report.Egress) },
|
||||
func(gtx C) D { return p.ifaceCard(a, gtx, report.Interfaces) },
|
||||
func(gtx C) D { return p.tailscaleCard(a, gtx, report.Tailscale) },
|
||||
)
|
||||
}
|
||||
|
||||
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])
|
||||
})
|
||||
}
|
||||
|
||||
// controlCard is the page's anchor: what the verdict is, when it was measured,
|
||||
// and how to measure again.
|
||||
func (p *diagPage) controlCard(a *App, gtx C, running bool, rep *netdiag.Report, lastRun time.Time, progress []netdiag.Progress) D {
|
||||
th := a.th
|
||||
card := th.Card()
|
||||
if rep != nil {
|
||||
accent := th.StatusColor(diagLevel(rep.Status))
|
||||
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 layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
headline := th.T(KDiagNever)
|
||||
col := th.P.TextSec
|
||||
if running {
|
||||
headline = th.T(KDiagRunning) + "…"
|
||||
col = th.P.TextPri
|
||||
} else if rep != nil {
|
||||
headline = rep.Headline
|
||||
col = th.StatusColor(diagLevel(rep.Status))
|
||||
}
|
||||
l := th.Text(SizeSubtitle, col, headline)
|
||||
l.Font.Weight = font.SemiBold
|
||||
l.MaxLines = 3
|
||||
return l.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if lastRun.IsZero() {
|
||||
return D{}
|
||||
}
|
||||
txt := th.T(KDiagLastRun) + " " + RelTime(th, lastRun, time.Now())
|
||||
if rep != nil {
|
||||
txt += " · " + FormatLatency(rep.Duration)
|
||||
}
|
||||
return layout.Inset{Top: 2}.Layout(gtx, th.Caption(txt).Layout)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
HGap(SpaceMD),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if rep == nil {
|
||||
return D{}
|
||||
}
|
||||
return th.Button(gtx, &p.copyBtn, ButtonStyle{
|
||||
Kind: ButtonSubtle,
|
||||
Text: th.T(KDiagCopyReport),
|
||||
Icon: IconCopy,
|
||||
})
|
||||
}),
|
||||
HGap(SpaceSM),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
label := th.T(KDiagRun)
|
||||
if rep != nil {
|
||||
label = th.T(KDiagRerun)
|
||||
}
|
||||
if running {
|
||||
label = th.T(KDiagRunning)
|
||||
}
|
||||
return th.Button(gtx, &p.runBtn, ButtonStyle{
|
||||
Kind: ButtonPrimary,
|
||||
Text: label,
|
||||
Icon: IconRefresh,
|
||||
Disabled: running,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if !running && len(progress) == 0 {
|
||||
return D{}
|
||||
}
|
||||
return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
return p.progressList(a, gtx, progress, running)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return th.Toggle(gtx, &p.skipGeo, th.T(KDiagSkipGeo))
|
||||
}),
|
||||
HGap(SpaceMD),
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return OneLine(th.Caption(th.T(KDiagSkipGeoHint))).Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *diagPage) progressList(a *App, gtx C, progress []netdiag.Progress, running bool) D {
|
||||
th := a.th
|
||||
children := make([]layout.FlexChild, 0, len(progress))
|
||||
for _, pr := range progress {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return layout.Inset{Top: 3, Bottom: 3}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
gtx.Constraints.Min.X = gtx.Dp(18)
|
||||
switch {
|
||||
case pr.Err != "":
|
||||
return IconWarn(gtx, gtx.Dp(13), th.P.Warn)
|
||||
case pr.Done:
|
||||
return IconCheck(gtx, gtx.Dp(13), th.P.OK)
|
||||
default:
|
||||
return th.Spinner(gtx, gtx.Dp(13), th.P.Accent)
|
||||
}
|
||||
}),
|
||||
HGap(SpaceSM),
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
col := th.P.TextSec
|
||||
if !pr.Done {
|
||||
col = th.P.TextPri
|
||||
}
|
||||
return OneLine(th.Text(SizeCaption, col, pr.Title)).Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if !pr.Done || pr.Elapsed <= 0 {
|
||||
return D{}
|
||||
}
|
||||
return th.MonoLabel(SizeCaption, th.P.TextDim,
|
||||
FormatLatency(pr.Elapsed)).Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// sectionCard is the shared shell for a diagnostic section: title, verdict
|
||||
// chip, and body.
|
||||
func (p *diagPage) sectionCard(a *App, gtx C, icon IconFunc, title string, s netdiag.Status, summary string, body layout.Widget) D {
|
||||
th := a.th
|
||||
level := diagLevel(s)
|
||||
card := th.Card()
|
||||
card.Title = title
|
||||
card.Subtitle = summary
|
||||
card.Trailing = func(gtx C) D {
|
||||
return th.Chip(gtx, ChipStyle{Text: statusWord(th, s), Level: level, Dot: true})
|
||||
}
|
||||
return card.Layout(th, gtx, body)
|
||||
}
|
||||
|
||||
func statusWord(th *Theme, s netdiag.Status) string {
|
||||
switch s {
|
||||
case netdiag.StatusOK:
|
||||
if th.Lang == LangZH {
|
||||
return "正常"
|
||||
}
|
||||
return "OK"
|
||||
case netdiag.StatusWarn:
|
||||
if th.Lang == LangZH {
|
||||
return "注意"
|
||||
}
|
||||
return "Warning"
|
||||
case netdiag.StatusFail:
|
||||
if th.Lang == LangZH {
|
||||
return "异常"
|
||||
}
|
||||
return "Failed"
|
||||
case netdiag.StatusSkipped:
|
||||
if th.Lang == LangZH {
|
||||
return "已跳过"
|
||||
}
|
||||
return "Skipped"
|
||||
default:
|
||||
return th.T(KUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func natTypeLabel(th *Theme, t netdiag.NATType) string {
|
||||
switch t {
|
||||
case netdiag.NATOpen:
|
||||
return th.T(KNatOpen)
|
||||
case netdiag.NATFullCone:
|
||||
return th.T(KNatFullCone)
|
||||
case netdiag.NATRestricted:
|
||||
return th.T(KNatRestricted)
|
||||
case netdiag.NATPortRestrict:
|
||||
return th.T(KNatPortRestricted)
|
||||
case netdiag.NATSymmetric:
|
||||
return th.T(KNatSymmetric)
|
||||
case netdiag.NATUDPBlocked:
|
||||
return th.T(KNatUDPBlocked)
|
||||
case netdiag.NATSymmetricFW:
|
||||
return th.T(KNatSymmetricFW)
|
||||
default:
|
||||
return th.T(KNatUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func behaviorLabel(th *Theme, b netdiag.Behavior) string {
|
||||
if th.Lang != LangZH {
|
||||
return b.String()
|
||||
}
|
||||
switch b {
|
||||
case netdiag.BehaviorEndpointIndependent:
|
||||
return "与目标无关"
|
||||
case netdiag.BehaviorAddressDependent:
|
||||
return "随目标地址变化"
|
||||
case netdiag.BehaviorAddressAndPortDependent:
|
||||
return "随目标地址和端口变化"
|
||||
default:
|
||||
return th.T(KUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *diagPage) triLabel(th *Theme, v *bool) (string, StatusLevel) {
|
||||
if v == nil {
|
||||
return th.T(KUnknown), LevelNeutral
|
||||
}
|
||||
if *v {
|
||||
return th.T(KYes), LevelOK
|
||||
}
|
||||
return th.T(KNo), LevelWarn
|
||||
}
|
||||
|
||||
func (p *diagPage) natCard(a *App, gtx C, r netdiag.NATReport) D {
|
||||
th := a.th
|
||||
hairpin, hairpinLvl := p.triLabel(th, r.Hairpin)
|
||||
preserve, preserveLvl := p.triLabel(th, r.PortPreserving)
|
||||
|
||||
return p.sectionCard(a, gtx, IconShield, th.T(KDiagSecNAT), r.Status, r.Summary, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
// The NAT type is the headline fact of this whole page; give it the
|
||||
// display size so it wins the visual hierarchy against the table.
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
l := th.Display(natTypeLabel(th, r.Type))
|
||||
l.Color = th.StatusColor(diagLevel(r.Status))
|
||||
return l.Layout(gtx)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return th.KVList(gtx, []KV{
|
||||
{Key: th.T(KDiagNatMapping), Value: behaviorLabel(th, r.Mapping)},
|
||||
{Key: th.T(KDiagNatFiltering), Value: behaviorLabel(th, r.Filtering)},
|
||||
{Key: th.T(KDiagNatHairpin), Value: hairpin, Level: hairpinLvl},
|
||||
{Key: th.T(KDiagNatPortPreserve), Value: preserve, Level: preserveLvl},
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if len(r.MappedAddrs) == 0 {
|
||||
return D{}
|
||||
}
|
||||
addrs := make([]string, 0, len(r.MappedAddrs))
|
||||
for _, ap := range r.MappedAddrs {
|
||||
addrs = append(addrs, ap.String())
|
||||
}
|
||||
return th.KV(gtx, KV{
|
||||
Key: th.T(KDiagEgressIP),
|
||||
Value: strings.Join(addrs, " "),
|
||||
Mono: true,
|
||||
Level: mappedAddrLevel(len(r.MappedAddrs)),
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if len(r.Notes) == 0 {
|
||||
return D{}
|
||||
}
|
||||
return layout.Inset{Top: SpaceSM}.Layout(gtx, func(gtx C) D {
|
||||
children := make([]layout.FlexChild, 0, len(r.Notes))
|
||||
for _, n := range r.Notes {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
l := th.Caption("· " + n)
|
||||
l.MaxLines = 3
|
||||
return l.Layout(gtx)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if len(r.Results) == 0 {
|
||||
return D{}
|
||||
}
|
||||
return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
return p.stunTable(a, gtx, r.Results)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func mappedAddrLevel(n int) StatusLevel {
|
||||
if n > 1 {
|
||||
return LevelWarn
|
||||
}
|
||||
return LevelNeutral
|
||||
}
|
||||
|
||||
func (p *diagPage) stunTable(a *App, gtx C, results []netdiag.STUNResult) D {
|
||||
th := a.th
|
||||
children := make([]layout.FlexChild, 0, len(results)+1)
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return p.tableHeader(a, gtx, "STUN", th.T(KDiagEgressIP), "RTT")
|
||||
}))
|
||||
for _, r := range results {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
val, level := r.Mapped.String(), LevelOK
|
||||
if !r.OK {
|
||||
val, level = orDash(r.Err), LevelFail
|
||||
}
|
||||
rtt := ""
|
||||
if r.OK {
|
||||
rtt = FormatLatency(r.RTT)
|
||||
}
|
||||
name := r.Server
|
||||
if r.Name != "" {
|
||||
name = r.Name + " " + r.Server
|
||||
}
|
||||
return p.tableRow(a, gtx, regionTag(th, r.Region)+name, val, rtt, level)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
}
|
||||
|
||||
// regionTag prefixes a probe target so the CN/international split — the whole
|
||||
// reason both are probed — is visible at a glance.
|
||||
func regionTag(th *Theme, r netdiag.Region) string {
|
||||
if r == netdiag.RegionCN {
|
||||
if th.Lang == LangZH {
|
||||
return "[国内] "
|
||||
}
|
||||
return "[CN] "
|
||||
}
|
||||
if th.Lang == LangZH {
|
||||
return "[境外] "
|
||||
}
|
||||
return "[INTL] "
|
||||
}
|
||||
|
||||
func (p *diagPage) tableHeader(a *App, gtx C, cols ...string) D {
|
||||
th := a.th
|
||||
return layout.Inset{Bottom: SpaceXS}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{}.Layout(gtx,
|
||||
layout.Flexed(0.44, func(gtx C) D {
|
||||
return OneLine(th.Caption(cols[0])).Layout(gtx)
|
||||
}),
|
||||
layout.Flexed(0.40, func(gtx C) D {
|
||||
return OneLine(th.Caption(cols[1])).Layout(gtx)
|
||||
}),
|
||||
layout.Flexed(0.16, func(gtx C) D {
|
||||
l := th.Caption(cols[2])
|
||||
l.Alignment = text.End
|
||||
return OneLine(l).Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *diagPage) tableRow(a *App, gtx C, left, mid, right string, level StatusLevel) D {
|
||||
th := a.th
|
||||
col := th.P.TextPri
|
||||
if level != LevelNeutral {
|
||||
col = th.StatusColor(level)
|
||||
}
|
||||
return layout.Inset{Top: 3, Bottom: 3}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(0.44, func(gtx C) D {
|
||||
return OneLine(th.Text(SizeCaption, th.P.TextSec, left)).Layout(gtx)
|
||||
}),
|
||||
layout.Flexed(0.40, func(gtx C) D {
|
||||
return OneLine(th.MonoLabel(SizeCaption, col, mid)).Layout(gtx)
|
||||
}),
|
||||
layout.Flexed(0.16, func(gtx C) D {
|
||||
l := th.MonoLabel(SizeCaption, th.P.TextDim, right)
|
||||
l.Alignment = text.End
|
||||
return OneLine(l).Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *diagPage) udpCard(a *App, gtx C, r netdiag.UDPReport) D {
|
||||
th := a.th
|
||||
v4, v4lvl := boolLabel(th, r.V4OK)
|
||||
v6, v6lvl := boolLabel(th, r.V6OK)
|
||||
// No IPv6 is normal on most Chinese home networks; flagging it red would
|
||||
// train the user to ignore the colour.
|
||||
if !r.V6OK {
|
||||
v6lvl = LevelNeutral
|
||||
}
|
||||
|
||||
return p.sectionCard(a, gtx, IconGlobe, th.T(KDiagSecUDP), r.Status, r.Summary, func(gtx C) D {
|
||||
rows := []KV{
|
||||
{Key: th.T(KDiagUdpV4), Value: v4, Level: v4lvl},
|
||||
{Key: th.T(KDiagUdpV6), Value: v6, Level: v6lvl},
|
||||
{
|
||||
Key: "国内 / 境外",
|
||||
Value: itoa(r.CNReachable) + "/" + itoa(r.CNTotal) + " " +
|
||||
itoa(r.IntlReachabl) + "/" + itoa(r.IntlTotal),
|
||||
Mono: true,
|
||||
},
|
||||
}
|
||||
if th.Lang != LangZH {
|
||||
rows[2].Key = "CN / International"
|
||||
}
|
||||
if len(r.BlockedPorts) > 0 {
|
||||
rows = append(rows, KV{
|
||||
Key: th.T(KDiagUdpPortsBlocked),
|
||||
Value: joinInts(r.BlockedPorts),
|
||||
Mono: true,
|
||||
Level: LevelWarn,
|
||||
})
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D { return th.KVList(gtx, rows) }),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if len(r.Probes) == 0 {
|
||||
return D{}
|
||||
}
|
||||
return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
children := make([]layout.FlexChild, 0, len(r.Probes)+1)
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return p.tableHeader(a, gtx, th.T(KDiagOverseasTarget), th.T(KDiagEgressIP), "RTT")
|
||||
}))
|
||||
for _, pr := range r.Probes {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
val, level := pr.Mapped.String(), LevelOK
|
||||
rtt := FormatLatency(pr.RTT)
|
||||
if !pr.OK {
|
||||
val, level, rtt = orDash(pr.Err), LevelFail, ""
|
||||
}
|
||||
return p.tableRow(a, gtx, regionTag(th, pr.Region)+pr.Target, val, rtt, level)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func boolLabel(th *Theme, v bool) (string, StatusLevel) {
|
||||
if v {
|
||||
return th.T(KYes), LevelOK
|
||||
}
|
||||
return th.T(KNo), LevelFail
|
||||
}
|
||||
|
||||
func joinInts(v []int) string {
|
||||
parts := make([]string, len(v))
|
||||
for i, n := range v {
|
||||
parts[i] = itoa(n)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (p *diagPage) portMapCard(a *App, gtx C, r netdiag.PortMapReport) D {
|
||||
th := a.th
|
||||
return p.sectionCard(a, gtx, IconRouter, th.T(KDiagSecPortMap), r.Status, r.Summary, func(gtx C) D {
|
||||
rows := []KV{}
|
||||
if r.Gateway.IsValid() {
|
||||
rows = append(rows, KV{Key: th.T(KDiagGateway), Value: r.Gateway.String(), Mono: true})
|
||||
}
|
||||
rows = append(rows,
|
||||
serviceKV(th, th.T(KDiagUPnP), r.UPnP),
|
||||
serviceKV(th, th.T(KDiagNATPMP), r.NATPMP),
|
||||
serviceKV(th, th.T(KDiagPCP), r.PCP),
|
||||
)
|
||||
for _, s := range []netdiag.ServiceProbe{r.UPnP, r.NATPMP, r.PCP} {
|
||||
if s.ExternalIP.IsValid() {
|
||||
rows = append(rows, KV{
|
||||
Key: th.T(KDiagExternalIP),
|
||||
Value: s.ExternalIP.String(),
|
||||
Mono: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
return th.KVList(gtx, rows)
|
||||
})
|
||||
}
|
||||
|
||||
func serviceKV(th *Theme, name string, s netdiag.ServiceProbe) KV {
|
||||
val, level := th.T(KUnsupported), LevelWarn
|
||||
if s.Available {
|
||||
val, level = th.T(KSupported), LevelOK
|
||||
}
|
||||
hint := s.Detail
|
||||
if hint == "" {
|
||||
hint = s.Err
|
||||
}
|
||||
return KV{Key: name, Value: val, Level: level, Hint: Truncate(hint, 60)}
|
||||
}
|
||||
|
||||
func (p *diagPage) overseasCard(a *App, gtx C, r netdiag.OverseasReport) D {
|
||||
th := a.th
|
||||
return p.sectionCard(a, gtx, IconGlobe, th.T(KDiagSecOverseas), r.Status, r.Summary, func(gtx C) D {
|
||||
if len(r.Probes) == 0 {
|
||||
return th.EmptyState(gtx, IconGlobe, th.T(KUnknown), "")
|
||||
}
|
||||
children := make([]layout.FlexChild, 0, len(r.Probes)+1)
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return p.tableHeader(a, gtx, th.T(KDiagOverseasTarget), th.T(KDetails), "RTT")
|
||||
}))
|
||||
for _, pr := range r.Probes {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
detail := itoa(pr.StatusCode)
|
||||
level := LevelOK
|
||||
if !pr.OK {
|
||||
detail, level = orDash(Truncate(pr.Err, 48)), LevelFail
|
||||
}
|
||||
via := "direct"
|
||||
if pr.ViaProxy {
|
||||
via = "proxy"
|
||||
}
|
||||
name := regionTag(th, pr.Region) + pr.URL + " (" + via
|
||||
if pr.Network != "" {
|
||||
name += "/" + pr.Network
|
||||
}
|
||||
name += ")"
|
||||
return p.tableRow(a, gtx, name, detail, FormatLatency(pr.RTT), level)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *diagPage) egressCard(a *App, gtx C, r netdiag.EgressReport) D {
|
||||
th := a.th
|
||||
return p.sectionCard(a, gtx, IconGlobe, th.T(KDiagSecEgress), r.Status, r.Summary, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if !r.Divergent {
|
||||
return D{}
|
||||
}
|
||||
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
return p.callout(a, gtx, LevelWarn,
|
||||
th.T(KDiagEgressDivergent), th.T(KDiagEgressDivergentHint))
|
||||
})
|
||||
}),
|
||||
// Geolocation first: "where do I appear to be" is the question, the
|
||||
// per-probe table below is the evidence.
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if len(r.Geo) == 0 {
|
||||
return D{}
|
||||
}
|
||||
children := make([]layout.FlexChild, 0, len(r.Geo))
|
||||
for _, g := range r.Geo {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return p.geoRow(a, gtx, g)
|
||||
}))
|
||||
}
|
||||
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if len(r.Observations) == 0 {
|
||||
return th.EmptyState(gtx, IconGlobe, th.T(KUnknown), "")
|
||||
}
|
||||
children := make([]layout.FlexChild, 0, len(r.Observations)+1)
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return p.tableHeader(a, gtx, th.T(KDiagEgressMethod), th.T(KDiagEgressIP), "RTT")
|
||||
}))
|
||||
for _, o := range r.Observations {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
val, level := o.IP.String(), LevelOK
|
||||
rtt := FormatLatency(o.RTT)
|
||||
if !o.IP.IsValid() {
|
||||
val, level, rtt = orDash(Truncate(o.Err, 44)), LevelFail, ""
|
||||
}
|
||||
label := regionTag(th, o.Region) + string(o.Method) + " · " + o.Source
|
||||
return p.tableRow(a, gtx, label, val, rtt, level)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *diagPage) geoRow(a *App, gtx C, g netdiag.GeoInfo) D {
|
||||
th := a.th
|
||||
loc := []string{}
|
||||
for _, s := range []string{g.CountryName, g.Country, g.Region, g.City} {
|
||||
if s != "" && !containsStr(loc, s) {
|
||||
loc = append(loc, s)
|
||||
}
|
||||
}
|
||||
locText := strings.Join(loc, " · ")
|
||||
if locText == "" {
|
||||
locText = orDash(g.Err)
|
||||
}
|
||||
org := strings.TrimSpace(g.ASN + " " + g.Org)
|
||||
|
||||
return layout.Inset{Top: 4, Bottom: 4}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return IconGlobe(gtx, gtx.Dp(15), th.P.Info)
|
||||
}),
|
||||
HGap(SpaceSM),
|
||||
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(OneLine(th.MonoLabel(SizeBody, th.P.TextPri, g.IP.String())).Layout),
|
||||
HGap(SpaceSM),
|
||||
layout.Flexed(1, OneLine(th.Text(SizeBody, th.P.TextSec, locText)).Layout),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if org == "" {
|
||||
return D{}
|
||||
}
|
||||
hint := org
|
||||
if g.Provider != "" {
|
||||
hint += " · " + g.Provider
|
||||
}
|
||||
return OneLine(th.Caption(hint)).Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func containsStr(ss []string, s string) bool {
|
||||
for _, v := range ss {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *diagPage) ifaceCard(a *App, gtx C, r netdiag.InterfaceReport) D {
|
||||
th := a.th
|
||||
return p.sectionCard(a, gtx, IconRoute, th.T(KDiagSecIface), r.Status, r.Summary, func(gtx C) D {
|
||||
rows := []KV{}
|
||||
if r.DefaultV4Src.IsValid() {
|
||||
rows = append(rows, KV{Key: th.T(KDiagIfaceDefaultV4), Value: r.DefaultV4Src.String(), Mono: true})
|
||||
}
|
||||
if r.DefaultV6Src.IsValid() {
|
||||
rows = append(rows, KV{Key: th.T(KDiagIfaceDefaultV6), Value: r.DefaultV6Src.String(), Mono: true})
|
||||
} else {
|
||||
rows = append(rows, KV{Key: th.T(KDiagIfaceDefaultV6), Value: th.T(KNone), Level: LevelNeutral})
|
||||
}
|
||||
|
||||
children := []layout.FlexChild{
|
||||
layout.Rigid(func(gtx C) D { return th.KVList(gtx, rows) }),
|
||||
}
|
||||
if len(r.Addrs) > 0 {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
sub := make([]layout.FlexChild, 0, len(r.Addrs)+1)
|
||||
sub = append(sub, layout.Rigid(func(gtx C) D {
|
||||
return p.tableHeader(a, gtx, th.T(KPeerAddresses), "", "MTU")
|
||||
}))
|
||||
for _, ad := range r.Addrs {
|
||||
sub = append(sub, layout.Rigid(func(gtx C) D {
|
||||
name := ad.Iface
|
||||
if ad.IsDefaultSrc {
|
||||
name += " *"
|
||||
}
|
||||
mtu := ""
|
||||
if ad.MTU > 0 {
|
||||
mtu = itoa(ad.MTU)
|
||||
}
|
||||
return p.tableRow(a, gtx,
|
||||
name+" "+string(ad.Kind), ad.Addr.String(), mtu,
|
||||
addrKindLevel(ad.Kind))
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, sub...)
|
||||
})
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
}
|
||||
|
||||
func addrKindLevel(k netdiag.AddrKind) StatusLevel {
|
||||
switch k {
|
||||
case netdiag.AddrGlobalV4, netdiag.AddrGlobalV6:
|
||||
return LevelOK
|
||||
case netdiag.AddrTailscale:
|
||||
return LevelInfo
|
||||
case netdiag.AddrLoopback, netdiag.AddrLinkLocal:
|
||||
return LevelNeutral
|
||||
default:
|
||||
return LevelNeutral
|
||||
}
|
||||
}
|
||||
|
||||
func (p *diagPage) tailscaleCard(a *App, gtx C, r netdiag.TailscaleReport) D {
|
||||
th := a.th
|
||||
return p.sectionCard(a, gtx, IconNodes, th.T(KDiagSecTailscale), r.Status, r.Summary, func(gtx C) D {
|
||||
if !r.Available {
|
||||
return th.EmptyState(gtx, IconNodes, orDash(r.Err), "")
|
||||
}
|
||||
upnp, upnpLvl := p.triLabel(th, r.UPnP)
|
||||
pmp, pmpLvl := p.triLabel(th, r.PMP)
|
||||
pcp, pcpLvl := p.triLabel(th, r.PCP)
|
||||
varies, variesLvl := p.triLabel(th, r.MappingVariesByDestIP)
|
||||
if r.MappingVariesByDestIP != nil && *r.MappingVariesByDestIP {
|
||||
variesLvl = LevelWarn
|
||||
} else if r.MappingVariesByDestIP != nil {
|
||||
variesLvl = LevelOK
|
||||
}
|
||||
portal, portalLvl := p.triLabel(th, r.CaptivePortal)
|
||||
if r.CaptivePortal != nil && *r.CaptivePortal {
|
||||
portalLvl = LevelFail
|
||||
} else if r.CaptivePortal != nil {
|
||||
portalLvl = LevelOK
|
||||
}
|
||||
|
||||
rows := []KV{
|
||||
{Key: th.T(KDiagPreferredDERP), Value: orDash(r.PreferredDERP)},
|
||||
{Key: th.T(KDiagMappingVaries), Value: varies, Level: variesLvl},
|
||||
{Key: th.T(KDiagCaptivePortal), Value: portal, Level: portalLvl},
|
||||
{Key: th.T(KDiagUPnP) + " / " + th.T(KDiagNATPMP) + " / " + th.T(KDiagPCP),
|
||||
Value: upnp + " · " + pmp + " · " + pcp,
|
||||
Level: worstLevel(upnpLvl, pmpLvl, pcpLvl)},
|
||||
}
|
||||
if r.GlobalV4 != "" {
|
||||
rows = append(rows, KV{Key: "GlobalV4", Value: r.GlobalV4, Mono: true})
|
||||
}
|
||||
if r.GlobalV6 != "" {
|
||||
rows = append(rows, KV{Key: "GlobalV6", Value: r.GlobalV6, Mono: true})
|
||||
}
|
||||
|
||||
derp := append([]netdiag.DERPLatency(nil), r.DERP...)
|
||||
sort.Slice(derp, func(i, j int) bool { return derp[i].Latency < derp[j].Latency })
|
||||
if len(derp) > 6 {
|
||||
derp = derp[:6]
|
||||
}
|
||||
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D { return th.KVList(gtx, rows) }),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if len(derp) == 0 {
|
||||
return D{}
|
||||
}
|
||||
return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
children := make([]layout.FlexChild, 0, len(derp)+1)
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return p.tableHeader(a, gtx, th.T(KDiagDerpLatency), "", "RTT")
|
||||
}))
|
||||
for _, d := range derp {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
name := d.Name
|
||||
level := LevelNeutral
|
||||
if d.Preferred {
|
||||
name += " ★"
|
||||
level = LevelOK
|
||||
}
|
||||
return p.tableRow(a, gtx, name, d.RegionCode,
|
||||
FormatLatency(d.Latency), level)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func worstLevel(ls ...StatusLevel) StatusLevel {
|
||||
rank := map[StatusLevel]int{LevelOK: 0, LevelNeutral: 1, LevelInfo: 1, LevelWarn: 2, LevelFail: 3}
|
||||
worst := LevelOK
|
||||
for _, l := range ls {
|
||||
if rank[l] > rank[worst] {
|
||||
worst = l
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
// callout is an inline banner for a finding that needs a sentence of
|
||||
// explanation rather than a table cell.
|
||||
func (p *diagPage) callout(a *App, gtx C, level StatusLevel, title, body string) D {
|
||||
th := a.th
|
||||
col := th.StatusColor(level)
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx C) D {
|
||||
FillRRect(gtx, gtx.Constraints.Min, RadiusSM, WithAlpha(col, 0.10))
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
}),
|
||||
layout.Stacked(func(gtx C) D {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return layout.UniformInset(SpaceMD).Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Start}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Inset{Top: 2}.Layout(gtx, func(gtx C) D {
|
||||
return IconWarn(gtx, gtx.Dp(15), col)
|
||||
})
|
||||
}),
|
||||
HGap(SpaceSM),
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
l := th.Text(SizeBody, col, title)
|
||||
l.Font.Weight = font.Medium
|
||||
return l.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
l := th.Text(SizeCaption, th.P.TextSec, body)
|
||||
l.MaxLines = 3
|
||||
return l.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user